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]

Reply via email to