diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/AwsRoleArnValidator.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AwsRoleArnValidator.java similarity index 87% rename from hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/AwsRoleArnValidator.java rename to hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AwsRoleArnValidator.java index 1f5af2fcc598..a60a514c674d 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/AwsRoleArnValidator.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AwsRoleArnValidator.java @@ -15,9 +15,9 @@ * limitations under the License. */ -package org.apache.hadoop.ozone.om.request.s3.security; +package org.apache.hadoop.ozone.om.helpers; -import org.apache.commons.lang3.StringUtils; +import com.google.common.base.Strings; import org.apache.hadoop.ozone.om.exceptions.OMException; /** @@ -46,8 +46,10 @@ private AwsRoleArnValidator() { * @throws OMException if the ARN is invalid */ public static String validateAndExtractRoleNameFromArn(String roleArn) throws OMException { - if (StringUtils.isBlank(roleArn)) { - throw new OMException("Role ARN is required", OMException.ResultCodes.INVALID_REQUEST); + if (Strings.isNullOrEmpty(roleArn)) { + throw new OMException( + "Value null at 'roleArn' failed to satisfy constraint: Member must not be null", + OMException.ResultCodes.INVALID_REQUEST); } final int roleArnLength = roleArn.length(); @@ -125,7 +127,7 @@ private static boolean isAllDigits(String s) { */ private static boolean hasCharNotAllowedInIamRoleArn(String s) { for (int i = 0; i < s.length(); i++) { - if (!isCharAllowedInIamRoleArn(s.charAt(i))) { + if (!isCharAllowedInIamRoleArn(s.codePointAt(i))) { return true; } } @@ -134,12 +136,11 @@ private static boolean hasCharNotAllowedInIamRoleArn(String s) { /** * Checks if the supplied char is allowed in IAM Role ARN. + * Pattern: [\u0009\u000A\u000D\u0020-\u007E\u0085\u00A0-\uD7FF\uE000-\uFFFD\u10000-\u10FFFF]+ */ - private static boolean isCharAllowedInIamRoleArn(char c) { - return (c >= 'A' && c <= 'Z') - || (c >= 'a' && c <= 'z') - || (c >= '0' && c <= '9') - || c == '+' || c == '=' || c == ',' || c == '.' || c == '@' || c == '_' || c == '-'; + private static boolean isCharAllowedInIamRoleArn(int c) { + return c == 0x09 || c == 0x0A || c == 0x0D || (c >= 0x20 && c <= 0x7E) || c == 0x85 || (c >= 0xA0 && c <= 0xD7FF) || + (c >= 0xE000 && c <= 0xFFFD) || (c >= 0x10000 && c <= 0x10FFFF); } } diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/S3STSUtils.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/S3STSUtils.java index 8d261e6c68e7..c70c01a8723d 100644 --- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/S3STSUtils.java +++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/S3STSUtils.java @@ -17,13 +17,28 @@ package org.apache.hadoop.ozone.om.helpers; +import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_REQUEST; + import com.google.common.base.Strings; import java.util.Map; +import net.jcip.annotations.Immutable; +import org.apache.hadoop.ozone.om.exceptions.OMException; /** * Utility class containing constants and validation methods shared by STS endpoint and OzoneManager processing. */ +@Immutable public final class S3STSUtils { + // STS API constants + public static final int DEFAULT_DURATION_SECONDS = 3600; // 1 hour + public static final int MAX_DURATION_SECONDS = 43200; // 12 hours + public static final int MIN_DURATION_SECONDS = 900; // 15 minutes + + public static final int ASSUME_ROLE_SESSION_NAME_MIN_LENGTH = 2; + public static final int ASSUME_ROLE_SESSION_NAME_MAX_LENGTH = 64; + + // AWS limit for session policy is 2048 characters + public static final int MAX_SESSION_POLICY_LENGTH = 2048; private S3STSUtils() { } @@ -41,4 +56,115 @@ public static void addAssumeRoleAuditParams(Map auditParams, Str auditParams.put("isPolicyIncluded", Strings.isNullOrEmpty(awsIamSessionPolicy) ? "N" : "Y"); auditParams.put("requestId", requestId); } + + /** + * Validates the duration in seconds. + * @param durationSeconds duration in seconds + * @return validated duration + * @throws OMException if duration is invalid + */ + public static int validateDuration(Integer durationSeconds) throws OMException { + if (durationSeconds == null) { + return DEFAULT_DURATION_SECONDS; + } + + if (durationSeconds < MIN_DURATION_SECONDS || durationSeconds > MAX_DURATION_SECONDS) { + throw new OMException( + "Invalid Value: DurationSeconds must be between " + MIN_DURATION_SECONDS + " and " + MAX_DURATION_SECONDS + + " seconds", INVALID_REQUEST); + } + + return durationSeconds; + } + + /** + * Validates the role session name. + * @param roleSessionName role session name + * @throws OMException if role session name is invalid + */ + public static void validateRoleSessionName(String roleSessionName) throws OMException { + if (Strings.isNullOrEmpty(roleSessionName)) { + throw new OMException( + "Value null at 'roleSessionName' failed to satisfy constraint: Member must not be null", INVALID_REQUEST); + } + + final int roleSessionNameLength = roleSessionName.length(); + if (roleSessionNameLength < ASSUME_ROLE_SESSION_NAME_MIN_LENGTH || + roleSessionNameLength > ASSUME_ROLE_SESSION_NAME_MAX_LENGTH) { + throw new OMException("Invalid RoleSessionName length " + roleSessionNameLength + ": it must be " + + ASSUME_ROLE_SESSION_NAME_MIN_LENGTH + "-" + ASSUME_ROLE_SESSION_NAME_MAX_LENGTH + " characters long and " + + "contain only alphanumeric characters and +, =, ,, ., @, -", INVALID_REQUEST); + } + + // AWS allows: alphanumeric, +, =, ,, ., @, - + // Pattern: [\w+=,.@-]* + // Don't use regex for performance reasons + for (int i = 0; i < roleSessionNameLength; i++) { + final char c = roleSessionName.charAt(i); + if (!isRoleSessionNameChar(c)) { + throw new OMException("Invalid character '" + c + "' in RoleSessionName: it must be " + + ASSUME_ROLE_SESSION_NAME_MIN_LENGTH + "-" + ASSUME_ROLE_SESSION_NAME_MAX_LENGTH + " characters long and " + + "contain only alphanumeric characters and +, =, ,, ., @, -", INVALID_REQUEST); + } + } + } + + private static boolean isRoleSessionNameChar(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || + c == '_' || c == '+' || c == '=' || c == ',' || c == '.' || c == '@' || c == '-'; + } + + /** + * Validates the session policy length. + * @param awsIamSessionPolicy session policy + * @throws OMException if policy length is invalid + */ + public static void validateSessionPolicy(String awsIamSessionPolicy) throws OMException { + if (awsIamSessionPolicy != null && awsIamSessionPolicy.length() > MAX_SESSION_POLICY_LENGTH) { + throw new OMException( + "Value '" + awsIamSessionPolicy + "' at 'policy' failed to satisfy constraint: Member " + + "must have length less than or equal to " + MAX_SESSION_POLICY_LENGTH, INVALID_REQUEST); + } + } + + /** + * Generates the assumed role user ARN. + * @param validRoleArn valid role ARN + * @param roleSessionName role session name + * @return assumed role user ARN + */ + public static String toAssumedRoleUserArn(String validRoleArn, String roleSessionName) { + // We already know the roleArn is valid, so perform the conversion for assumed role user arn format + // RoleArn format: arn:aws:iam:::role/ + // Assumed role user arn format: arn:aws:sts:::assumed-role// + final String[] parts = splitRoleArnWithoutRegex(validRoleArn); + + final String partition = parts[1]; + final String accountId = parts[4]; + final String resource = parts[5]; + final String roleName = resource.substring("role/".length()); + + //noinspection StringBufferReplaceableByString + final StringBuilder stringBuilder = new StringBuilder("arn:"); + stringBuilder.append(partition); + stringBuilder.append(":sts::"); + stringBuilder.append(accountId); + stringBuilder.append(":assumed-role/"); + stringBuilder.append(roleName); + stringBuilder.append('/'); + stringBuilder.append(roleSessionName); + return stringBuilder.toString(); + } + + private static String[] splitRoleArnWithoutRegex(String roleArn) { + final String[] parts = new String[6]; + int start = 0; + for (int i = 0; i < 5; i++) { + final int end = roleArn.indexOf(':', start); + parts[i] = roleArn.substring(start, end); + start = end + 1; + } + parts[5] = roleArn.substring(start); + return parts; + } } diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestAwsRoleArnValidator.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAwsRoleArnValidator.java similarity index 89% rename from hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestAwsRoleArnValidator.java rename to hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAwsRoleArnValidator.java index b5deffc1e0de..ea6db63c5557 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestAwsRoleArnValidator.java +++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAwsRoleArnValidator.java @@ -15,11 +15,12 @@ * limitations under the License. */ -package org.apache.hadoop.ozone.om.request.s3.security; +package org.apache.hadoop.ozone.om.helpers; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; +import org.apache.commons.lang3.StringUtils; import org.apache.hadoop.ozone.om.exceptions.OMException; import org.junit.jupiter.api.Test; @@ -38,12 +39,12 @@ public void testValidateAndExtractRoleNameFromArnSuccessCases() throws OMExcepti assertThat(AwsRoleArnValidator.validateAndExtractRoleNameFromArn(ROLE_ARN_2)).isEqualTo("Role2"); // Path name right at 511-char max boundary - final String arnPrefixLen511 = S3SecurityTestUtils.repeat('p', 510) + "/"; // 510 chars + '/' = 511 + final String arnPrefixLen511 = StringUtils.repeat('p', 510) + "/"; // 510 chars + '/' = 511 final String arnMaxPath = "arn:aws:iam::123456789012:role/" + arnPrefixLen511 + "RoleB"; assertThat(AwsRoleArnValidator.validateAndExtractRoleNameFromArn(arnMaxPath)).isEqualTo("RoleB"); // Role name right at 64-char max boundary - final String roleName64 = S3SecurityTestUtils.repeat('A', 64); + final String roleName64 = StringUtils.repeat('A', 64); final String arn64 = "arn:aws:iam::123456789012:role/" + roleName64; assertThat(AwsRoleArnValidator.validateAndExtractRoleNameFromArn(arn64)).isEqualTo(roleName64); } @@ -61,7 +62,8 @@ public void testValidateAndExtractRoleNameFromArnFailureCases() { final OMException e2 = assertThrows( OMException.class, () -> AwsRoleArnValidator.validateAndExtractRoleNameFromArn(null)); assertThat(e2.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST); - assertThat(e2.getMessage()).isEqualTo("Role ARN is required"); + assertThat(e2.getMessage()).isEqualTo( + "Value null at 'roleArn' failed to satisfy constraint: Member must not be null"); // String without role name final OMException e3 = assertThrows( @@ -90,7 +92,8 @@ public void testValidateAndExtractRoleNameFromArnFailureCases() { final OMException e6 = assertThrows( OMException.class, () -> AwsRoleArnValidator.validateAndExtractRoleNameFromArn("")); assertThat(e6.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST); - assertThat(e6.getMessage()).isEqualTo("Role ARN is required"); + assertThat(e6.getMessage()).isEqualTo( + "Value null at 'roleArn' failed to satisfy constraint: Member must not be null"); // String with only slash final OMException e7 = assertThrows( @@ -102,10 +105,10 @@ public void testValidateAndExtractRoleNameFromArnFailureCases() { final OMException e8 = assertThrows( OMException.class, () -> AwsRoleArnValidator.validateAndExtractRoleNameFromArn(" ")); assertThat(e8.getResult()).isEqualTo(OMException.ResultCodes.INVALID_REQUEST); - assertThat(e8.getMessage()).isEqualTo("Role ARN is required"); + assertThat(e8.getMessage()).isEqualTo("Role ARN length must be between 20 and 2048"); // Path name too long (> 511 characters) - final String arnPrefixLen512 = S3SecurityTestUtils.repeat('q', 511) + "/"; // 511 chars + '/' = 512 + final String arnPrefixLen512 = StringUtils.repeat('q', 511) + "/"; // 511 chars + '/' = 512 final String arnTooLongPath = "arn:aws:iam::123456789012:role/" + arnPrefixLen512 + "RoleA"; final OMException e9 = assertThrows( OMException.class, () -> AwsRoleArnValidator.validateAndExtractRoleNameFromArn(arnTooLongPath)); @@ -120,7 +123,7 @@ public void testValidateAndExtractRoleNameFromArnFailureCases() { assertThat(e10.getMessage()).isEqualTo("Invalid role ARN: missing role name"); // MyRole/ is considered a path // 65-char role name - final String roleName65 = S3SecurityTestUtils.repeat('B', 65); + final String roleName65 = StringUtils.repeat('B', 65); final String roleArn65 = "arn:aws:iam::123456789012:role/" + roleName65; final OMException e11 = assertThrows( OMException.class, () -> AwsRoleArnValidator.validateAndExtractRoleNameFromArn(roleArn65)); @@ -128,4 +131,3 @@ public void testValidateAndExtractRoleNameFromArnFailureCases() { assertThat(e11.getMessage()).isEqualTo("Invalid role name: " + roleName65); } } - diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleRequest.java index 1b00454b70cb..030a4aeffebf 100644 --- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleRequest.java +++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleRequest.java @@ -37,6 +37,7 @@ import org.apache.hadoop.ozone.om.OzoneManager; import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.om.execution.flowcontrol.ExecutionContext; +import org.apache.hadoop.ozone.om.helpers.AwsRoleArnValidator; import org.apache.hadoop.ozone.om.helpers.S3STSUtils; import org.apache.hadoop.ozone.om.request.OMClientRequest; import org.apache.hadoop.ozone.om.request.util.OmResponseUtil; @@ -68,14 +69,10 @@ public class S3AssumeRoleRequest extends OMClientRequest { SECURE_RANDOM = secureRandom; } - private static final int MIN_TOKEN_EXPIRATION_SECONDS = 900; // 15 minutes in seconds - private static final int MAX_TOKEN_EXPIRATION_SECONDS = 43200; // 12 hours in seconds private static final int STS_ACCESS_KEY_ID_LENGTH = 20; private static final int STS_SECRET_ACCESS_KEY_LENGTH = 40; private static final int STS_ROLE_ID_LENGTH = 16; private static final String ASSUME_ROLE_ID_PREFIX = "AROA"; - private static final int ASSUME_ROLE_SESSION_NAME_MIN_LENGTH = 2; - private static final int ASSUME_ROLE_SESSION_NAME_MAX_LENGTH = 64; private static final String CHARS_FOR_ACCESS_KEY_IDS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; private static final int CHARS_FOR_ACCESS_KEY_IDS_LENGTH = CHARS_FOR_ACCESS_KEY_IDS.length(); private static final String CHARS_FOR_SECRET_ACCESS_KEYS = CHARS_FOR_ACCESS_KEY_IDS + @@ -113,14 +110,10 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut OMClientResponse omClientResponse; try { // Validate duration - if (durationSeconds < MIN_TOKEN_EXPIRATION_SECONDS || durationSeconds > MAX_TOKEN_EXPIRATION_SECONDS) { - throw new OMException( - "Duration must be between " + MIN_TOKEN_EXPIRATION_SECONDS + " and " + MAX_TOKEN_EXPIRATION_SECONDS, - OMException.ResultCodes.INVALID_REQUEST); - } + S3STSUtils.validateDuration(durationSeconds); // Validate role session name - validateRoleSessionName(roleSessionName); + S3STSUtils.validateRoleSessionName(roleSessionName); // Validate role ARN and extract role final String targetRoleName = AwsRoleArnValidator.validateAndExtractRoleNameFromArn(roleArn); @@ -178,21 +171,6 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut return omClientResponse; } - /** - * Ensures RoleSessionName is valid. - */ - private void validateRoleSessionName(String roleSessionName) throws OMException { - if (StringUtils.isBlank(roleSessionName)) { - throw new OMException("RoleSessionName is required", OMException.ResultCodes.INVALID_REQUEST); - } - if (roleSessionName.length() < ASSUME_ROLE_SESSION_NAME_MIN_LENGTH || - roleSessionName.length() > ASSUME_ROLE_SESSION_NAME_MAX_LENGTH) { - throw new OMException( - "RoleSessionName length must be between " + ASSUME_ROLE_SESSION_NAME_MIN_LENGTH + " and " + - ASSUME_ROLE_SESSION_NAME_MAX_LENGTH, OMException.ResultCodes.INVALID_REQUEST); - } - } - /** * Generates session token using components from the AssumeRoleRequest. */ diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleRequest.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleRequest.java index bda871386fed..004a6b0ab695 100644 --- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleRequest.java +++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleRequest.java @@ -154,7 +154,8 @@ public void testInvalidDurationTooShort() { final OMResponse omResponse = response.getOMResponse(); assertThat(omResponse.getStatus()).isEqualTo(Status.INVALID_REQUEST); - assertThat(omResponse.getMessage()).isEqualTo("Duration must be between 900 and 43200"); + assertThat(omResponse.getMessage()).isEqualTo( + "Invalid Value: DurationSeconds must be between 900 and 43200 seconds"); assertThat(omResponse.hasAssumeRoleResponse()).isFalse(); assertMarkForAuditCalled(request); } @@ -175,7 +176,8 @@ public void testInvalidDurationTooLong() { final OMResponse omResponse = response.getOMResponse(); assertThat(omResponse.getStatus()).isEqualTo(Status.INVALID_REQUEST); - assertThat(omResponse.getMessage()).isEqualTo("Duration must be between 900 and 43200"); + assertThat(omResponse.getMessage()).isEqualTo( + "Invalid Value: DurationSeconds must be between 900 and 43200 seconds"); assertThat(omResponse.hasAssumeRoleResponse()).isFalse(); assertMarkForAuditCalled(request); } @@ -355,7 +357,8 @@ public void testAssumeRoleWithEmptySessionName() { final S3AssumeRoleRequest request = new S3AssumeRoleRequest(omRequest, CLOCK); final OMClientResponse response = request.validateAndUpdateCache(ozoneManager, context); assertThat(response.getOMResponse().getStatus()).isEqualTo(Status.INVALID_REQUEST); - assertThat(response.getOMResponse().getMessage()).isEqualTo("RoleSessionName is required"); + assertThat(response.getOMResponse().getMessage()).isEqualTo( + "Value null at 'roleSessionName' failed to satisfy constraint: Member must not be null"); assertMarkForAuditCalled(request); } @@ -374,7 +377,9 @@ public void testInvalidAssumeRoleSessionNameTooShort() { final OMResponse omResponse = response.getOMResponse(); assertThat(omResponse.getStatus()).isEqualTo(Status.INVALID_REQUEST); - assertThat(omResponse.getMessage()).isEqualTo("RoleSessionName length must be between 2 and 64"); + assertThat(omResponse.getMessage()).isEqualTo( + "Invalid RoleSessionName length 1: it must be 2-64 characters long and contain only alphanumeric " + + "characters and +, =, ,, ., @, -"); assertThat(omResponse.hasAssumeRoleResponse()).isFalse(); assertMarkForAuditCalled(request); } @@ -395,7 +400,10 @@ public void testInvalidRoleSessionNameTooLong() { final OMResponse omResponse = response.getOMResponse(); assertThat(omResponse.getStatus()).isEqualTo(Status.INVALID_REQUEST); - assertThat(omResponse.getMessage()).isEqualTo("RoleSessionName length must be between 2 and 64"); + assertThat(omResponse.getMessage()).isEqualTo( + "Invalid RoleSessionName length 70: it must be 2-64 characters long and contain only alphanumeric " + + "characters and +, =, ,, ., @, -" + ); assertThat(omResponse.hasAssumeRoleResponse()).isFalse(); assertMarkForAuditCalled(request); } diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java index 62bef03586a5..e4da7b604c70 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java @@ -23,13 +23,14 @@ import static javax.ws.rs.core.Response.Status.NOT_IMPLEMENTED; import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.Strings; import java.io.IOException; import java.io.StringWriter; import java.time.Instant; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; +import java.util.HashSet; import java.util.Map; +import java.util.Set; import javax.inject.Inject; import javax.ws.rs.FormParam; import javax.ws.rs.GET; @@ -45,6 +46,7 @@ import org.apache.hadoop.ozone.audit.S3GAction; import org.apache.hadoop.ozone.om.exceptions.OMException; import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo; +import org.apache.hadoop.ozone.om.helpers.AwsRoleArnValidator; import org.apache.hadoop.ozone.om.helpers.S3STSUtils; import org.apache.hadoop.ozone.s3.RequestIdentifier; import org.apache.hadoop.ozone.s3.exception.OS3Exception; @@ -71,7 +73,6 @@ public class S3STSEndpoint extends S3STSEndpointBase { // STS API constants private static final String ASSUME_ROLE_ACTION = "AssumeRole"; - private static final String ROLE_DURATION_SECONDS_PARAM = "DurationSeconds"; private static final String GET_SESSION_TOKEN_ACTION = "GetSessionToken"; private static final String ASSUME_ROLE_WITH_SAML_ACTION = "AssumeRoleWithSAML"; private static final String ASSUME_ROLE_WITH_WEB_IDENTITY_ACTION = "AssumeRoleWithWebIdentity"; @@ -86,13 +87,6 @@ public class S3STSEndpoint extends S3STSEndpointBase { private static final String ACCESS_DENIED = "AccessDenied"; private static final String INVALID_CLIENT_TOKEN_ID = "InvalidClientTokenId"; - // Default token duration (in seconds) - AWS default is 3600 (1 hour) - // TODO - add these constants and also validations in a common place that both endpoint and backend can use - private static final int DEFAULT_DURATION_SECONDS = 3600; - private static final int MAX_DURATION_SECONDS = 43200; // 12 hours - private static final int MIN_DURATION_SECONDS = 900; // 15 minutes - private static final int MAX_SESSION_POLICY_SIZE = 2048; - @Inject private RequestIdentifier requestIdentifier; @@ -195,19 +189,10 @@ private Response handleAssumeRole(String roleArn, String roleSessionName, Intege final Map auditParams = getAuditParameters(); S3STSUtils.addAssumeRoleAuditParams( auditParams, roleArn, roleSessionName, awsIamSessionPolicy, - durationSeconds == null ? DEFAULT_DURATION_SECONDS : durationSeconds, + durationSeconds == null ? S3STSUtils.DEFAULT_DURATION_SECONDS : durationSeconds, requestId); - int duration; - try { - // Validate parameters - duration = validateDuration(durationSeconds); - } catch (IllegalArgumentException e) { - final OSTSException exception = new OSTSException(VALIDATION_ERROR, e.getMessage(), BAD_REQUEST.getStatusCode()); - getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.ASSUME_ROLE, auditParams, exception)); - throw exception; - } - + // Validate parameters if (version == null || !version.equals(EXPECTED_VERSION)) { final OSTSException exception = new OSTSException( INVALID_ACTION, "Could not find operation " + action + " for version " + @@ -217,50 +202,48 @@ private Response handleAssumeRole(String roleArn, String roleSessionName, Intege throw exception; } - if (roleArn == null || roleArn.isEmpty()) { - final OSTSException exception = new OSTSException( - VALIDATION_ERROR, "Value null at 'roleArn' failed to satisfy constraint: Member must not be null", - BAD_REQUEST.getStatusCode()); - getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.ASSUME_ROLE, auditParams, exception)); - throw exception; + final Set validationErrors = new HashSet<>(); + int duration = durationSeconds == null ? S3STSUtils.DEFAULT_DURATION_SECONDS : durationSeconds; + try { + duration = S3STSUtils.validateDuration(durationSeconds); + } catch (OMException e) { + validationErrors.add(e.getMessage()); } - if (roleSessionName == null || roleSessionName.isEmpty()) { - final OSTSException exception = new OSTSException( - VALIDATION_ERROR, "Value null at 'roleSessionName' failed to satisfy constraint: Member must not be null", - BAD_REQUEST.getStatusCode()); - getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.ASSUME_ROLE, auditParams, exception)); - throw exception; + try { + AwsRoleArnValidator.validateAndExtractRoleNameFromArn(roleArn); + } catch (OMException e) { + validationErrors.add(e.getMessage()); } - // Validate role session name format (AWS requirements) - if (!isValidRoleSessionName(roleSessionName)) { - final OSTSException exception = new OSTSException( - VALIDATION_ERROR, "Invalid RoleSessionName: must be 2-64 characters long and " + - "contain only alphanumeric characters, +, =, ,, ., @, -", - BAD_REQUEST.getStatusCode()); - getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.ASSUME_ROLE, auditParams, exception)); - throw exception; + try { + S3STSUtils.validateRoleSessionName(roleSessionName); + } catch (OMException e) { + validationErrors.add(e.getMessage()); } - // Check Policy size if available - if (awsIamSessionPolicy != null && awsIamSessionPolicy.length() > MAX_SESSION_POLICY_SIZE) { - final OSTSException exception = new OSTSException( - VALIDATION_ERROR, "Value '" + awsIamSessionPolicy + "' at 'policy' failed to satisfy constraint: Member " + - "must have length less than or equal to 2048", BAD_REQUEST.getStatusCode()); - getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.ASSUME_ROLE, auditParams, exception)); - throw exception; + try { + S3STSUtils.validateSessionPolicy(awsIamSessionPolicy); + } catch (OMException e) { + validationErrors.add(e.getMessage()); } - final String assumedRoleUserArn; - try { - assumedRoleUserArn = toAssumedRoleUserArn(roleArn, roleSessionName); - } catch (IllegalArgumentException e) { - final OSTSException exception = new OSTSException(VALIDATION_ERROR, e.getMessage(), BAD_REQUEST.getStatusCode()); + final int numValidationErrors = validationErrors.size(); + if (numValidationErrors > 0) { + //noinspection StringBufferReplaceableByString + final StringBuilder builder = new StringBuilder(); + builder.append(numValidationErrors); + builder.append(" validation "); + builder.append(numValidationErrors > 1 ? "errors detected: " : "error detected: "); + builder.append(String.join(";", validationErrors)); + final String validationMessage = builder.toString(); + final OSTSException exception = new OSTSException( + VALIDATION_ERROR, validationMessage, BAD_REQUEST.getStatusCode()); getAuditLogger().logWriteFailure(buildAuditMessageForFailure(S3GAction.ASSUME_ROLE, auditParams, exception)); throw exception; } + final String assumedRoleUserArn = S3STSUtils.toAssumedRoleUserArn(roleArn, roleSessionName); try { final AssumeRoleResponseInfo responseInfo = getClient() .getObjectStore() @@ -301,29 +284,6 @@ private Response handleAssumeRole(String roleArn, String roleSessionName, Intege } } - private int validateDuration(Integer durationSeconds) throws IllegalArgumentException { - if (durationSeconds == null) { - return DEFAULT_DURATION_SECONDS; - } - - if (durationSeconds < MIN_DURATION_SECONDS || durationSeconds > MAX_DURATION_SECONDS) { - throw new IllegalArgumentException( - "Invalid Value: " + ROLE_DURATION_SECONDS_PARAM + " must be between " + MIN_DURATION_SECONDS + - " and " + MAX_DURATION_SECONDS + " seconds"); - } - - return durationSeconds; - } - - private boolean isValidRoleSessionName(String roleSessionName) { - if (roleSessionName.length() < 2 || roleSessionName.length() > 64) { - return false; - } - - // AWS allows: alphanumeric, +, =, ,, ., @, - - return roleSessionName.matches("[a-zA-Z0-9+=,.@\\-]+"); - } - private String generateAssumeRoleResponse(String assumedRoleUserArn, AssumeRoleResponseInfo responseInfo, String requestId) throws IOException { final String accessKeyId = responseInfo.getAccessKeyId(); @@ -362,36 +322,5 @@ private String generateAssumeRoleResponse(String assumedRoleUserArn, AssumeRoleR throw new IOException("Failed to marshal AssumeRole response", e); } } - - private String toAssumedRoleUserArn(String roleArn, String roleSessionName) { - // RoleArn format: arn:aws:iam:::role/ - // Assumed role user arn format: arn:aws:sts:::assumed-role// - // TODO - refactor and reuse AwsRoleArnValidator for validation in future PR - final String errMsg = "Invalid RoleArn: must be in the format arn:aws:iam:::role/"; - final String[] parts = roleArn.split(":", 6); - if (parts.length != 6 || !"arn".equals(parts[0]) || parts[1].isEmpty() || !"iam".equals(parts[2])) { - throw new IllegalArgumentException(errMsg); - } - - final String partition = parts[1]; - final String accountId = parts[4]; - final String resource = parts[5]; // role/ - - if (Strings.isNullOrEmpty(accountId) || Strings.isNullOrEmpty(resource) || !resource.startsWith("role/") || - resource.length() == "role/".length()) { - throw new IllegalArgumentException(errMsg); - } - - final String roleName = resource.substring("role/".length()); - //noinspection StringBufferReplaceableByString - final StringBuilder stringBuilder = new StringBuilder("arn:"); - stringBuilder.append(partition); - stringBuilder.append(":sts::"); - stringBuilder.append(accountId); - stringBuilder.append(":assumed-role/"); - stringBuilder.append(roleName); - stringBuilder.append('/'); - stringBuilder.append(roleSessionName); - return stringBuilder.toString(); - } } + diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSEndpoint.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSEndpoint.java index 34891d089453..d0eaca9a5dca 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSEndpoint.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSEndpoint.java @@ -118,8 +118,7 @@ public void setup() throws Exception { @Test public void testStsAssumeRoleValidForGetMethod() throws Exception { - Response response = endpoint.get( - "AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null); + final Response response = endpoint.get("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null); assertEquals(200, response.getStatus()); verify(auditLogger).logWriteSuccess(any(AuditMessage.class)); @@ -157,6 +156,7 @@ public void testStsAssumeRoleValidForGetMethod() throws Exception { @Test public void testStsAssumeRoleValidForPostMethod() throws Exception { + //noinspection resource final Response response = endpoint.post("AssumeRole", ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null); assertEquals(200, response.getStatus()); @@ -305,7 +305,7 @@ public void testStsInvalidRoleArn() throws Exception { final String requestId = "test-request-id"; ex.setRequestId(requestId); assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", - "Invalid RoleArn: must be in the format arn:aws:iam:::role/"); + "Invalid role ARN (does not start with arn:aws:iam::)"); } @Test @@ -335,7 +335,7 @@ public void testStsInvalidRoleArnMissingRoleName() throws Exception { final String requestId = "test-request-id"; ex.setRequestId(requestId); - assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid RoleArn: must be in the format"); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid role ARN: missing role name"); } @Test @@ -351,7 +351,7 @@ public void testStsInvalidRoleArnMissingAccountId() throws Exception { final String requestId = "test-request-id"; ex.setRequestId(requestId); - assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid RoleArn: must be in the format" + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid AWS account ID in ARN" ); } @@ -395,7 +395,10 @@ public void testStsInvalidRoleSessionNameWithInvalidCharacter() throws Exception final String requestId = "test-request-id"; ex.setRequestId(requestId); - assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid RoleSessionName"); + assertStsErrorXml( + ex.toXml(), STS_NS, "Sender", "ValidationError", "1 validation error detected: " + + "Invalid character '/' in RoleSessionName: it must be 2-64 characters long and contain only alphanumeric " + + "characters and +, =, ,, ., @, -"); } @Test @@ -410,7 +413,9 @@ public void testStsInvalidRoleSessionNameTooShort() throws Exception { final String requestId = "test-request-id"; ex.setRequestId(requestId); - assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid RoleSessionName"); + assertStsErrorXml( + ex.toXml(), STS_NS, "Sender", "ValidationError", "1 validation error detected: Invalid RoleSessionName " + + "length 1: it must be 2-64 characters long and contain only alphanumeric characters and +, =, ,, ., @, -"); } @Test @@ -426,7 +431,7 @@ public void testStsInvalidRoleArnResourceType() throws Exception { final String requestId = "test-request-id"; ex.setRequestId(requestId); - assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid RoleArn: must be in the format"); + assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError", "Invalid role ARN (unexpected field count)"); } @Test @@ -481,6 +486,35 @@ public void testStsIOExceptionWrappedAsInternalFailure() throws Exception { assertStsErrorXml(ex.toXml(), STS_NS, "Receiver", "InternalFailure", "An internal error has occurred."); } + @Test + public void testStsMultipleValidationErrors() throws Exception { + final String invalidRoleSessionName = "test/session"; + final String tooLargePolicy = RandomStringUtils.insecure().nextAlphanumeric(2049); + final int invalidDurationSeconds = -1; + + final OSTSException ex = assertThrows(OSTSException.class, () -> + endpoint.get("AssumeRole", ROLE_ARN, invalidRoleSessionName, invalidDurationSeconds, "2011-06-15", + tooLargePolicy)); + + assertEquals(400, ex.getHttpCode()); + verify(auditLogger).logWriteFailure(any(AuditMessage.class)); + verify(auditLogger, never()).logWriteSuccess(any(AuditMessage.class)); + + final String requestId = "test-request-id"; + ex.setRequestId(requestId); + + final String xml = ex.toXml(); + // The order of individual validation errors is not guaranteed because it's a HashSet, so check + // that multiple messages are included + final Document doc = parseXml(xml); + final String message = doc.getElementsByTagName("Message").item(0).getTextContent(); + assertTrue(message.contains("3 validation errors detected")); + assertTrue(message.contains("Invalid Value: DurationSeconds")); + assertTrue(message.contains("Invalid character '/' in RoleSessionName")); + assertTrue(message.contains( + "'policy' failed to satisfy constraint: Member must have length less than or equal to 2048")); + } + private static Document parseXml(String xml) throws Exception { final DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); documentBuilderFactory.setNamespaceAware(true);