This is an automated email from the ASF dual-hosted git repository.

ChenSammi pushed a commit to branch HDDS-13323-sts
in repository https://gitbox.apache.org/repos/asf/ozone.git


The following commit(s) were added to refs/heads/HDDS-13323-sts by this push:
     new 30a353d780b HDDS-16371. [STS] Better messaging for invalid endpoint 
path and prevent non-canonical token use (#11186)
30a353d780b is described below

commit 30a353d780bff4deb0fd1d024921ddf965bd55a4
Author: fmorg-git <[email protected]>
AuthorDate: Wed Sep 2 20:52:17 2026 -0700

    HDDS-16371. [STS] Better messaging for invalid endpoint path and prevent 
non-canonical token use (#11186)
---
 .../smoketest/security/ozone-secure-sts.resource   |   6 +-
 .../main/smoketest/security/ozone-secure-sts.robot |  12 +++
 .../hadoop/ozone/security/STSSecurityUtil.java     |   4 +
 .../hadoop/ozone/security/TestSTSSecurityUtil.java |  26 +++++
 .../s3/exception/OSTSNotFoundExceptionMapper.java  |  93 ++++++++++++++++
 .../org/apache/hadoop/ozone/s3sts/Application.java |   2 +
 .../exception/TestOSTSNotFoundExceptionMapper.java | 120 +++++++++++++++++++++
 7 files changed, 260 insertions(+), 3 deletions(-)

diff --git 
a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource 
b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource
index 19cb6f4e202..8e63f622545 100644
--- a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource
+++ b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.resource
@@ -178,13 +178,13 @@ Assume Role And Configure STS Profile
     Configure STS Profile         ${STS_ACCESS_KEY_ID}  ${STS_SECRET_KEY}  
${STS_SESSION_TOKEN}
 
 Assume Role Should Fail
-    [Arguments]                   ${perm_access_key_id}  ${perm_secret_key}  
${policy_json}=${EMPTY}  ${expected_error}=AccessDenied  
${expected_http_code}=${EMPTY}  ${role_arn}=${ROLE_ARN_OBS}  
${role_session_name}=${ROLE_SESSION_NAME}  ${duration_seconds}=900  
${extra_cli_args}=${EMPTY}
+    [Arguments]                   ${perm_access_key_id}  ${perm_secret_key}  
${policy_json}=${EMPTY}  ${expected_error}=AccessDenied  
${expected_http_code}=${EMPTY}  ${role_arn}=${ROLE_ARN_OBS}  
${role_session_name}=${ROLE_SESSION_NAME}  ${duration_seconds}=900  
${extra_cli_args}=${EMPTY}  ${sts_endpoint_url}=${STS_ENDPOINT_URL}
     Configure AWS Profile         permanent  ${perm_access_key_id}  
${perm_secret_key}
 
     IF                            '${expected_http_code}' != '${EMPTY}'
         # Note: curl in the s3g container doesn't reliably support --aws-sigv4,
         # so use awscli debug output to capture the HTTP response code.
-        ${cmd} =                  Set Variable                  aws sts 
assume-role --endpoint-url ${STS_ENDPOINT_URL} --role-arn ${role_arn} 
--role-session-name ${role_session_name} --profile permanent --debug 2>&1
+        ${cmd} =                  Set Variable                  aws sts 
assume-role --endpoint-url ${sts_endpoint_url} --role-arn ${role_arn} 
--role-session-name ${role_session_name} --profile permanent --debug 2>&1
         ${cmd} =                  Set Variable If               
'${duration_seconds}' != '${EMPTY}'    ${cmd} --duration-seconds 
${duration_seconds}    ${cmd}
         ${cmd} =                  Set Variable If               
'${policy_json}' != '${EMPTY}'    ${cmd} --policy '${policy_json}'    ${cmd}
         ${cmd} =                  Set Variable If               
'${extra_cli_args}' != '${EMPTY}'    ${cmd} ${extra_cli_args}    ${cmd}
@@ -198,7 +198,7 @@ Assume Role Should Fail
         ${http_code} =            Get From List                 ${http_codes}  
  -1
         Should Be Equal As Strings  ${http_code}                
${expected_http_code}
     ELSE
-        ${cmd} =                  Set Variable                  aws sts 
assume-role --endpoint-url ${STS_ENDPOINT_URL} --role-arn ${role_arn} 
--role-session-name ${role_session_name} --output json --profile permanent
+        ${cmd} =                  Set Variable                  aws sts 
assume-role --endpoint-url ${sts_endpoint_url} --role-arn ${role_arn} 
--role-session-name ${role_session_name} --output json --profile permanent
         ${cmd} =                  Set Variable If               
'${duration_seconds}' != '${EMPTY}'    ${cmd} --duration-seconds 
${duration_seconds}    ${cmd}
         ${cmd} =                  Set Variable If               
'${policy_json}' != '${EMPTY}'    ${cmd} --policy '${policy_json}'    ${cmd}
 
diff --git 
a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot 
b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot
index 3ee4e19a5ae..c07ee6afc98 100644
--- a/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot
+++ b/hadoop-ozone/dist/src/main/smoketest/security/ozone-secure-sts.robot
@@ -611,6 +611,14 @@ Assume Role Request With Oversized Payload Should Fail
     ${large_policy} =             Generate Oversized Session Policy
     Assume Role Should Fail       
perm_access_key_id=${PERMANENT_ACCESS_KEY_ID}  
perm_secret_key=${PERMANENT_SECRET_KEY}  policy_json=${large_policy}  
expected_error=PayloadTooLarge  role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN}
 
+Doubled STS Session Token Must Fail
+    # Concatenating a valid session token with itself must be rejected as 
non-canonical.
+    Assume Role And Get Temporary Credentials                   
perm_access_key_id=${PERMANENT_ACCESS_KEY_ID}  
perm_secret_key=${PERMANENT_SECRET_KEY}  
role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN}
+    ${doubled_token} =            Catenate                      SEPARATOR=  
${STS_SESSION_TOKEN}  ${STS_SESSION_TOKEN}
+    Configure STS Profile                                       
${STS_ACCESS_KEY_ID}  ${STS_SECRET_KEY}  ${doubled_token}
+    Get Object Should Fail      ${ICEBERG_BUCKET_OBS}  
${ICEBERG_BUCKET_TESTFILE}  AccessDenied
+    Put Object Should Fail      ${ICEBERG_BUCKET_OBS}  
${ICEBERG_BUCKET_TESTFILE}  AccessDenied
+
 Tampered STS Token Service, Policy, or Signature Must Fail
     # Taking valid STS session token and mutating different parts of it must 
render it unusable
     ${session_policy} =           Set Variable                  
{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::${ICEBERG_BUCKET_OBS}/*"}]}
@@ -660,6 +668,10 @@ Assume Role Without Duration Should Default To One Hour
     Configure STS Profile         ${STS_ACCESS_KEY_ID}  ${STS_SECRET_KEY}  
${STS_SESSION_TOKEN}
     Get Object Should Succeed     ${ICEBERG_BUCKET_OBS}  
${ICEBERG_BUCKET_TESTFILE}
 
+Assume Role Should Fail For Invalid Sts Endpoint Path
+    Assume Role Should Fail       
perm_access_key_id=${PERMANENT_ACCESS_KEY_ID}  
perm_secret_key=${PERMANENT_SECRET_KEY}  expected_error=ValidationError  
expected_http_code=400  role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN}  
sts_endpoint_url=${STS_ENDPOINT_URL}/sts
+    Assume Role Should Fail       
perm_access_key_id=${PERMANENT_ACCESS_KEY_ID}  
perm_secret_key=${PERMANENT_SECRET_KEY}  expected_error=ValidationError  
expected_http_code=400  role_arn=${ICEBERG_ALL_ACCESS_ROLE_OBS_ARN}  
sts_endpoint_url=${STS_ENDPOINT_URL}/invalidEndpoint
+
 Assume Role Should Fail For Too Short Role Arn
     Assume Role Should Fail Using Curl  
perm_access_key_id=${PERMANENT_ACCESS_KEY_ID}  
perm_secret_key=${PERMANENT_SECRET_KEY}  expected_error=ValidationError  
expected_http_code=400  role_arn=a
 
diff --git 
a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java
 
b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java
index ead735f12ea..8862f16d235 100644
--- 
a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java
+++ 
b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java
@@ -160,6 +160,10 @@ private static Token<STSTokenIdentifier> 
decodeTokenFromString(String encodedTok
     // instead of failing the OM request.
     try {
       token.decodeFromUrlString(encodedToken);
+      final String canonical = token.encodeToUrlString();
+      if (!canonical.equals(encodedToken)) {
+        throw new SecretManager.InvalidToken("Failed to decode STS token 
string: non-canonical token encoding");
+      }
       return token;
     } catch (IOException | RuntimeException e) {
       throw new SecretManager.InvalidToken("Failed to decode STS token string: 
" + e);
diff --git 
a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java
 
b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java
index 290d848535a..608be8f6603 100644
--- 
a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java
+++ 
b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java
@@ -303,6 +303,32 @@ public void 
testConstructValidateAndDecryptSTSTokenInvalidSignature() throws Exc
         .hasMessageContaining("Invalid STS token format: Invalid STS token - 
signature is not correct for token");
   }
 
+  @Test
+  public void testConstructValidateAndDecryptSTSTokenRejectsDoubledToken() 
throws Exception {
+    final String tokenString = tokenSecretManager.createSTSTokenString(
+        TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS,
+        SECRET_ACCESS_KEY, SESSION_POLICY, clock);
+
+    assertThatThrownBy(() ->
+        STSSecurityUtil.constructValidateAndDecryptSTSToken(tokenString + 
tokenString, secretKeyClient, clock))
+        .isInstanceOf(OMException.class)
+        .satisfies(exception -> assertThat(((OMException) 
exception).getResult()).isEqualTo(INVALID_TOKEN))
+        .hasMessageContaining("non-canonical token encoding");
+  }
+
+  @Test
+  public void testConstructValidateAndDecryptSTSTokenRejectsTokenWithSuffix() 
throws Exception {
+    final String tokenString = tokenSecretManager.createSTSTokenString(
+        TEMP_ACCESS_KEY, ORIGINAL_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS,
+        SECRET_ACCESS_KEY, SESSION_POLICY, clock);
+
+    assertThatThrownBy(() ->
+        STSSecurityUtil.constructValidateAndDecryptSTSToken(tokenString + 
"garbage", secretKeyClient, clock))
+        .isInstanceOf(OMException.class)
+        .satisfies(exception -> assertThat(((OMException) 
exception).getResult()).isEqualTo(INVALID_TOKEN))
+        .hasMessageContaining("non-canonical token encoding");
+  }
+
   @Test
   public void testConstructValidateAndDecryptSTSTokenEmptyString() {
     // Try to decrypt an empty token string
diff --git 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OSTSNotFoundExceptionMapper.java
 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OSTSNotFoundExceptionMapper.java
new file mode 100644
index 00000000000..511ea26547b
--- /dev/null
+++ 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/exception/OSTSNotFoundExceptionMapper.java
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.s3.exception;
+
+import static 
org.apache.hadoop.ozone.s3.exception.S3ErrorTable.STS_VALIDATION_ERROR;
+
+import com.google.common.annotations.VisibleForTesting;
+import javax.inject.Inject;
+import javax.ws.rs.NotFoundException;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.UriInfo;
+import javax.ws.rs.ext.ExceptionMapper;
+import javax.ws.rs.ext.Provider;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hadoop.ozone.s3.RequestIdentifier;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Maps unmatched STS endpoint paths to AWS STS compatible XML errors.
+ * <p>
+ * Without this mapper, Jersey returns Jetty HTML 404 pages for paths such as 
{@code /sts},
+ * which AWS clients report as {@code Unknown}.
+ */
+@Provider
+public class OSTSNotFoundExceptionMapper implements 
ExceptionMapper<NotFoundException> {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(OSTSNotFoundExceptionMapper.class);
+
+  @Inject
+  private RequestIdentifier requestIdentifier;
+
+  @Context
+  private UriInfo uriInfo;
+
+  @Override
+  public Response toResponse(NotFoundException exception) {
+    final String requestPath = getRequestPath();
+    final String validationMessage = buildValidationMessage(requestPath);
+    if (LOG.isDebugEnabled()) {
+      LOG.debug("Returning STS validation error for unmatched path: {}", 
requestPath);
+    }
+    final OSTSException stsException = new 
OSTSException(STS_VALIDATION_ERROR).withMessage(validationMessage);
+    stsException.setRequestId(requestIdentifier.getRequestId());
+    return Response.status(stsException.getHttpCode())
+        .entity(stsException.toXml())
+        .type(MediaType.APPLICATION_XML)
+        .build();
+  }
+
+  private String getRequestPath() {
+    if (uriInfo == null) {
+      return "/";
+    }
+    final String path = uriInfo.getPath();
+    if (StringUtils.isBlank(path)) {
+      return "/";
+    }
+    return path.startsWith("/") ? path : "/" + path;
+  }
+
+  private static String buildValidationMessage(String requestPath) {
+    return "1 validation error detected: Invalid STS endpoint path '" + 
requestPath + "'. "
+        + "Ozone STS is served at the root path /.";
+  }
+
+  @VisibleForTesting
+  public void setRequestIdentifier(RequestIdentifier requestIdentifier) {
+    this.requestIdentifier = requestIdentifier;
+  }
+
+  @VisibleForTesting
+  public void setUriInfo(UriInfo uriInfo) {
+    this.uriInfo = uriInfo;
+  }
+}
diff --git 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/Application.java
 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/Application.java
index b4db14dfa61..0d6e4b4c4c2 100644
--- 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/Application.java
+++ 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/Application.java
@@ -19,6 +19,7 @@
 
 import org.apache.hadoop.ozone.s3.S3STSHeadersResponseFilter;
 import org.apache.hadoop.ozone.s3.exception.OSTSExceptionMapper;
+import org.apache.hadoop.ozone.s3.exception.OSTSNotFoundExceptionMapper;
 import org.glassfish.jersey.server.ResourceConfig;
 
 /**
@@ -30,6 +31,7 @@ public Application() {
     register(org.apache.hadoop.ozone.s3.AuthorizationFilter.class);
     register(org.apache.hadoop.ozone.s3.ClientIpFilter.class);
     register(OSTSExceptionMapper.class);
+    register(OSTSNotFoundExceptionMapper.class);
     register(S3STSHeadersResponseFilter.class);
   }
 }
diff --git 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/exception/TestOSTSNotFoundExceptionMapper.java
 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/exception/TestOSTSNotFoundExceptionMapper.java
new file mode 100644
index 00000000000..c023d7b2c9c
--- /dev/null
+++ 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/exception/TestOSTSNotFoundExceptionMapper.java
@@ -0,0 +1,120 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.s3.exception;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.StringReader;
+import javax.ws.rs.NotFoundException;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.UriInfo;
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import org.apache.hadoop.ozone.s3.RequestIdentifier;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.xml.sax.InputSource;
+
+/**
+ * Unit tests for {@link OSTSNotFoundExceptionMapper}.
+ */
+public class TestOSTSNotFoundExceptionMapper {
+  private static final String REQUEST_ID = "test-request-id";
+  private static final String STS_NS = 
"https://sts.amazonaws.com/doc/2011-06-15/";;
+
+  private OSTSNotFoundExceptionMapper mapper;
+
+  @BeforeEach
+  public void setup() {
+    mapper = new OSTSNotFoundExceptionMapper();
+    final RequestIdentifier requestIdentifier = mock(RequestIdentifier.class);
+    when(requestIdentifier.getRequestId()).thenReturn(REQUEST_ID);
+    mapper.setRequestIdentifier(requestIdentifier);
+  }
+
+  @Test
+  public void testMapsNotFoundToStsValidationErrorForStsPath() throws 
Exception {
+    mapper.setUriInfo(createUriInfo("sts"));
+
+    try (Response response = mapper.toResponse(new NotFoundException())) {
+
+      assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), 
response.getStatus());
+      assertEquals(MediaType.APPLICATION_XML_TYPE, response.getMediaType());
+      assertStsValidationErrorXml((String) response.getEntity(), "/sts");
+    }
+  }
+
+  @Test
+  public void testMapsNotFoundToStsValidationErrorForUnknownPath() throws 
Exception {
+    mapper.setUriInfo(createUriInfo("foo/bar"));
+
+    try (Response response = mapper.toResponse(new NotFoundException())) {
+
+      assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), 
response.getStatus());
+      assertEquals(MediaType.APPLICATION_XML_TYPE, response.getMediaType());
+      assertStsValidationErrorXml((String) response.getEntity(), "/foo/bar");
+    }
+  }
+
+  @Test
+  public void testMapsNotFoundWhenRequestContextIsUnavailable() throws 
Exception {
+    try (Response response = mapper.toResponse(new NotFoundException())) {
+
+      assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), 
response.getStatus());
+      assertEquals(MediaType.APPLICATION_XML_TYPE, response.getMediaType());
+      assertStsValidationErrorXml((String) response.getEntity(), "/");
+    }
+  }
+
+  private static UriInfo createUriInfo(String path) {
+    final UriInfo uriInfo = mock(UriInfo.class);
+    when(uriInfo.getPath()).thenReturn(path);
+    return uriInfo;
+  }
+
+  private static void assertStsValidationErrorXml(String xml, String 
expectedPathInMessage) throws Exception {
+    final Document doc = parseXml(xml);
+    final Element root = doc.getDocumentElement();
+    assertEquals("ErrorResponse", root.getLocalName());
+    assertEquals(STS_NS, root.getNamespaceURI());
+    assertEquals("Sender", 
doc.getElementsByTagName("Type").item(0).getTextContent());
+    assertEquals("ValidationError", 
doc.getElementsByTagName("Code").item(0).getTextContent());
+    assertEquals(REQUEST_ID, 
doc.getElementsByTagName("RequestId").item(0).getTextContent());
+
+    final String message = 
doc.getElementsByTagName("Message").item(0).getTextContent();
+    assertTrue(
+        message.contains("Invalid STS endpoint path '" + expectedPathInMessage 
+ "'"),
+        "Expected message to mention path: " + expectedPathInMessage);
+    assertTrue(message.contains("root path /"), "Expected message to mention 
root path");
+  }
+
+  private static Document parseXml(String xml) throws Exception {
+    assertNotNull(xml);
+    final DocumentBuilderFactory documentBuilderFactory = 
DocumentBuilderFactory.newInstance();
+    documentBuilderFactory.setNamespaceAware(true);
+    final DocumentBuilder documentBuilder = 
documentBuilderFactory.newDocumentBuilder();
+    return documentBuilder.parse(new InputSource(new StringReader(xml)));
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to