This is an automated email from the ASF dual-hosted git repository.
smengcl pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ozone.git
The following commit(s) were added to refs/heads/master by this push:
new 26557dfafb5 HDDS-16140. Qualify S3 Gateway path-style access over IPv6
(#11124)
26557dfafb5 is described below
commit 26557dfafb564aaa42601908362c1b6c958cd024
Author: rjgoyln <[email protected]>
AuthorDate: Sat Sep 19 17:29:59 2026 +0800
HDDS-16140. Qualify S3 Gateway path-style access over IPv6 (#11124)
Generated-by: Claude Code (Opus 5)
---
.../hadoop/ozone/s3/VirtualHostStyleFilter.java | 46 +++++++++++++++-
.../ozone/s3/TestVirtualHostStyleFilter.java | 61 ++++++++++++++++++++--
.../s3/signature/TestStringToSignProducer.java | 56 ++++++++++++++++++++
3 files changed, 159 insertions(+), 4 deletions(-)
diff --git
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/VirtualHostStyleFilter.java
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/VirtualHostStyleFilter.java
index 539c960e366..6d6186aacf3 100644
---
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/VirtualHostStyleFilter.java
+++
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/VirtualHostStyleFilter.java
@@ -21,6 +21,7 @@
import com.google.common.annotations.VisibleForTesting;
import com.google.common.net.HostAndPort;
+import com.google.common.net.InetAddresses;
import java.io.IOException;
import java.net.URI;
import java.util.Arrays;
@@ -127,12 +128,29 @@ public void setConfiguration(OzoneConfiguration config) {
}
/**
- * This method finds the longest match with the domain name.
+ * This method finds the longest match with the domain name, except for a
host
+ * that still holds a colon once its port is stripped, which has to be one of
+ * the configured domains itself, compared as an address rather than as text.
* @param host
* @return domain name matched with the host. if none of them are matching,
* return null.
*/
private String getDomainName(String host) {
+ // A colon at this point means an IPv6 literal (or a Host header malformed
+ // enough that checkHostWithoutPort returned it as it came), and neither
can
+ // carry a bucket prefix. Matched by suffix, 2001:db8::1 would match the
+ // domain ::1 and be read as a request for a bucket named "2001:db8".
+ if (host.indexOf(':') >= 0) {
+ for (String domainVal : domains) {
+ if (isSameAddress(host, domainVal)) {
+ // The host itself, so that the caller sees nothing left in front of
+ // the domain to read as a bucket, however the two spell the address.
+ return host;
+ }
+ }
+ return null;
+ }
+
String match = null;
int length = 0;
for (String domainVal : domains) {
@@ -166,6 +184,32 @@ String checkHostWithoutPort(String host) {
}
}
+ /**
+ * Compares a host with a configured domain by address rather than by
+ * spelling, so that a client writing the gateway's address in a form other
+ * than the configured one still reaches it: ::1 and 0:0:0:0:0:0:0:1 are the
+ * same address, and hexadecimal digits may be in either case. A zone names
an
+ * interface of the node holding the address and must not be sent on the wire
+ * (RFC 9844), so one the operator wrote in the domain is dropped before
+ * comparing, while a Host header carrying one names no gateway. Neither is
+ * ever resolved: Guava accepts a scoped literal as a valid address but
throws
+ * on parsing one whose interface this node does not have.
+ */
+ private static boolean isSameAddress(String host, String domain) {
+ if (host.indexOf('%') >= 0) {
+ return false;
+ }
+ String domainAddress = stripZone(domain);
+ return host.equals(domainAddress)
+ || (InetAddresses.isInetAddress(host) &&
InetAddresses.isInetAddress(domainAddress)
+ &&
InetAddresses.forString(host).equals(InetAddresses.forString(domainAddress)));
+ }
+
+ private static String stripZone(String host) {
+ int mark = host.indexOf('%');
+ return mark < 0 ? host : host.substring(0, mark);
+ }
+
/**
* Strips surrounding brackets from a configured IPv6 domain so it matches
the
* unbracketed host produced by {@link #checkHostWithoutPort(String)},
letting
diff --git
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestVirtualHostStyleFilter.java
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestVirtualHostStyleFilter.java
index 5c0edb6f084..6a4729fa496 100644
---
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestVirtualHostStyleFilter.java
+++
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestVirtualHostStyleFilter.java
@@ -222,10 +222,21 @@ public void testVirtualHostStyleWithoutPort() throws
Exception {
* IPv6 Host header whether it is configured with or without brackets. Before
* normalization a bracketed config ({@code [::1]}) failed to match
* {@code Host: [::1]:9878}, which {@code getHost()} reduces to {@code ::1}.
+ * The client does not have to spell the address the way the operator did
+ * either, since both name the same gateway.
*/
@ParameterizedTest
- @CsvSource(value = {"[::1]", "::1"})
- public void testPathStyleWithIPv6Domain(String configuredDomain)
+ @CsvSource(value = {
+ "[::1],[::1]:9878",
+ "[::1],[::1]",
+ "::1,[::1]:9878",
+ "::1,[::1]",
+ "[::1],[0:0:0:0:0:0:0:1]:9878",
+ "2001:db8::1,[2001:DB8::1]:9878",
+ "fe80::1%eth0,[fe80::1]:9878",
+ "fe80::1%eth0,[fe80:0:0:0:0:0:0:1]:9878",
+ })
+ public void testPathStyleWithIPv6Domain(String configuredDomain, String host)
throws Exception {
conf.set(S3GatewayConfigKeys.OZONE_S3G_DOMAIN_NAME, configuredDomain);
VirtualHostStyleFilter virtualHostStyleFilter = new
VirtualHostStyleFilter();
@@ -233,8 +244,52 @@ public void testPathStyleWithIPv6Domain(String
configuredDomain)
// Path-style request whose Host matches the IPv6 domain is left unchanged.
ContainerRequestContext requestContext =
mock(ContainerRequestContext.class);
-
when(requestContext.getHeaderString(HttpHeaders.HOST)).thenReturn("[::1]:9878");
+ when(requestContext.getHeaderString(HttpHeaders.HOST)).thenReturn(host);
+ virtualHostStyleFilter.filter(requestContext);
+ verify(requestContext, never()).setRequestUri(any(URI.class),
any(URI.class));
+ }
+
+ /**
+ * An IPv4 domain reached over IPv6 is still the same gateway: a dual stack
+ * client may write the configured address as an IPv4-mapped IPv6 literal.
+ */
+ @Test
+ public void testPathStyleWithIPv4MappedHost() throws Exception {
+ conf.set(S3GatewayConfigKeys.OZONE_S3G_DOMAIN_NAME, "192.168.1.10");
+ VirtualHostStyleFilter virtualHostStyleFilter = new
VirtualHostStyleFilter();
+ virtualHostStyleFilter.setConfiguration(conf);
+
+ ContainerRequestContext requestContext =
mock(ContainerRequestContext.class);
+ when(requestContext.getHeaderString(HttpHeaders.HOST))
+ .thenReturn("[::ffff:192.168.1.10]:9878");
virtualHostStyleFilter.filter(requestContext);
verify(requestContext, never()).setRequestUri(any(URI.class),
any(URI.class));
}
+
+ /**
+ * An IPv6 literal cannot carry a bucket prefix, so an address that merely
+ * ends with the configured domain is a different host, not a virtual host
+ * style request for a bucket named after the leading segments. A zone must
not
+ * be sent on the wire, so a Host that carries one names no gateway, in
either
+ * the plain or the URI encoded form, and is never resolved to an interface.
+ */
+ @ParameterizedTest
+ @CsvSource(value = {
+ "[::1],[2001:db8::1]:9878",
+ "[::1],[2001:db8::1]",
+ "[::1],2001:db8::1",
+ "fe80::1,[fe80::1%25eth0]:9878",
+ "fe80::1%eth0,[fe80::1%eth0]:9878",
+ })
+ public void testIPv6HostOutsideConfiguredDomain(String configuredDomain,
String host) {
+ conf.set(S3GatewayConfigKeys.OZONE_S3G_DOMAIN_NAME, configuredDomain);
+ VirtualHostStyleFilter virtualHostStyleFilter = new
VirtualHostStyleFilter();
+ virtualHostStyleFilter.setConfiguration(conf);
+
+ ContainerRequestContext requestContext =
+ createRequestContext(host, "/mybucket/myfile");
+ InvalidRequestException exception =
assertThrows(InvalidRequestException.class,
+ () -> virtualHostStyleFilter.filter(requestContext));
+ assertThat(exception).hasMessageContaining("No matching domain");
+ }
}
diff --git
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/signature/TestStringToSignProducer.java
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/signature/TestStringToSignProducer.java
index 2e3721a0165..43305d452b2 100644
---
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/signature/TestStringToSignProducer.java
+++
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/signature/TestStringToSignProducer.java
@@ -45,6 +45,7 @@
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
/**
* Test string2sign creation.
@@ -145,6 +146,61 @@ public void testUrlEncodeInCanonicalRequest() {
canonicalRequest);
}
+ /**
+ * A client reaching the gateway over IPv6 signs the bracketed Host header as
+ * it sent it, so the string to sign has to carry that value through
+ * unchanged: splitting the address on its colons would produce a different
+ * canonical request and reject every request. Nothing in the signing path
+ * reads the Host today, so this pins that rather than guarding a change.
+ */
+ @ParameterizedTest
+ @ValueSource(strings = {"[::1]:9878", "[2001:db8::1]:9878", "[::1]"})
+ public void testIPv6HostInStringToSign(String host) throws Exception {
+ String signedHeaders = "host;x-amz-content-sha256;x-amz-date";
+ String credentialScope = DATE_FORMATTER.format(LocalDate.now())
+ + "/us-east-1/s3/aws4_request";
+ String authHeader = "AWS4-HMAC-SHA256 Credential=ozone/" + credentialScope
+ + ", SignedHeaders=" + signedHeaders
+ + ", Signature=db81b057718d7c1b3b8"
+ + "dffa29933099551c51d787b3b13b9e0f9ebed45982bf2";
+
+ MultivaluedMap<String, String> headerMap = new MultivaluedHashMap<>();
+ headerMap.putSingle("Authorization", authHeader);
+ headerMap.putSingle("Host", host);
+ headerMap.putSingle("X-Amz-Content-Sha256", "Content-SHA");
+ headerMap.putSingle("X-Amz-Date", DATETIME);
+
+ // Path style request: the bucket and key stay in the URI, as they do for a
+ // client that cannot use virtual host style against an address literal.
+ ContainerRequestContext context = setupContext(
+ new URI("http://" + host + "/mybucket/mykey"),
+ "GET",
+ headerMap,
+ new MultivaluedHashMap<>());
+ SignatureInfo signatureInfo = new AuthorizationV4HeaderParser(
+ authHeader, DATETIME).parseSignature();
+ signatureInfo.setUnfilteredURI("/mybucket/mykey");
+
+ String canonicalRequest = "GET\n"
+ + "/mybucket/mykey\n"
+ + "\n"
+ + "host:" + host + "\n"
+ + "x-amz-content-sha256:Content-SHA\n"
+ + "x-amz-date:" + DATETIME + "\n"
+ + "\n"
+ + signedHeaders + "\n"
+ + "Content-SHA";
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ md.update(canonicalRequest.getBytes(StandardCharsets.UTF_8));
+
+ assertEquals("AWS4-HMAC-SHA256\n"
+ + DATETIME + "\n"
+ + credentialScope + "\n"
+ + Hex.encode(md.digest()).toLowerCase(),
+ StringToSignProducer.createSignatureBase(signatureInfo, context),
+ "String to sign is invalid");
+ }
+
private ContainerRequestContext setupContext(
URI uri,
String method,
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]