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]