diff --git a/.changes/afeacad1-0f2c-483d-a755-4df1fd6fd440.json b/.changes/afeacad1-0f2c-483d-a755-4df1fd6fd440.json new file mode 100644 index 00000000000..ba820b1c999 --- /dev/null +++ b/.changes/afeacad1-0f2c-483d-a755-4df1fd6fd440.json @@ -0,0 +1,5 @@ +{ + "id": "afeacad1-0f2c-483d-a755-4df1fd6fd440", + "type": "feature", + "description": "Validate caller-specified AWS regions in client config (i.e., `region` and `regionProvider`)" +} \ No newline at end of file diff --git a/aws-runtime/aws-config/api/aws-config.api b/aws-runtime/aws-config/api/aws-config.api index 6b7718887cb..8f626ac72bf 100644 --- a/aws-runtime/aws-config/api/aws-config.api +++ b/aws-runtime/aws-config/api/aws-config.api @@ -707,3 +707,8 @@ public final class aws/sdk/kotlin/runtime/region/ResolveRegionKt { public static synthetic fun resolveSigV4aSigningRegionSet$default (Laws/smithy/kotlin/runtime/util/PlatformProvider;Laws/smithy/kotlin/runtime/util/LazyAsyncValue;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } +public final class aws/sdk/kotlin/runtime/region/ValidateRegionKt { + public static final fun isRegionValid (Ljava/lang/String;)Z + public static final fun validateRegion (Ljava/lang/String;)Ljava/lang/String; +} + diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/region/ValidateRegion.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/region/ValidateRegion.kt new file mode 100644 index 00000000000..a79e1783048 --- /dev/null +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/region/ValidateRegion.kt @@ -0,0 +1,78 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package aws.sdk.kotlin.runtime.region + +import aws.sdk.kotlin.runtime.ConfigurationException +import aws.sdk.kotlin.runtime.InternalSdkApi + +internal fun charSet(chars: String) = chars.toCharArray().toSet() +internal fun charSet(range: CharRange) = range.toSet() + +private object Rfc3986CharSets { + val alpha = charSet('A'..'Z') + charSet('a'..'z') + val digit = charSet('0'..'9') + val unreserved = alpha + digit + charSet("-._~") + val hexdig = digit + charSet('A'..'F') + val pctEncoded = hexdig + '%' + val subDelims = charSet("!$&'()*+,;=") + val regName = unreserved + pctEncoded + subDelims +} + +/** + * Determines if the given region is valid for the purposes of endpoint lookup, specifically that the region is suitable + * to use in a URI hostname according to [RFC 3986 § 3.2.2](https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2). + * + * Valid characters for regions include: + * * URI unreserved characters: + * * Uppercase letters (`A` through `Z`) + * * Lowercase letters (`a` through `z`) + * * Digits (`0` through `9`) + * * Hyphen (`-`) + * * Period/dot (`.`) + * * Tilde (`~`) + * * Underscore (`_`) + * * Percent (`%`) + * * URI sub-delimiters + * * Ampersand (`&`) + * * Apostrophe (`'`) + * * Asterisk (`*`) + * * Comma (`,`) + * * Dollar sign (`$`) + * * Equals sign (`=`) + * * Exclamation point (`!`) + * * Parentheses (`(` and `)`) + * * Plus (`+`) + * * Semicolon (`;`) + * + * Notable characters which are _invalid_ for regions include: + * * Space (` `) + * * At sign (`@`) + * * Backtick/grave (`` ` ``) + * * Braces (`{` and `}`) + * * Brackets (`[` and `]`) + * * Caret (`^`) + * * Colon (`:`) + * * Double quote (`"`) + * * Hash/number sign (`#`) + * * Inequality signs (`<` and `>`) + * * Pipe (`|`) + * * Question mark (`?`) + * * Slashes (`/` and `\`) + * * All non-ASCII characters (e.g., Unicode characters) + */ +@InternalSdkApi +public fun isRegionValid(region: String): Boolean = region.isNotEmpty() && region.all(Rfc3986CharSets.regName::contains) + +/** + * Validates that a region is suitable to use in a URI hostname according to + * [RFC 3986 § 3.2.2](https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2). See [isRegionValid] for a detailed + * description of the validation criteria. + */ +@InternalSdkApi +public fun validateRegion(region: String): String = region.also { + if (!isRegionValid(region)) { + throw ConfigurationException("""Configured region "$region" is invalid. A region must be a valid URI host component.""") + } +} diff --git a/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/region/ValidateRegionTest.kt b/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/region/ValidateRegionTest.kt new file mode 100644 index 00000000000..8cc23864e99 --- /dev/null +++ b/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/region/ValidateRegionTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package aws.sdk.kotlin.runtime.region + +import aws.sdk.kotlin.runtime.ConfigurationException +import kotlin.test.* + +/** + * Forms the [combinations](https://en.wikipedia.org/wiki/Combination) of a given length for the given set + */ +private fun combinations(ofSet: Set, length: Int): Set { + if (length <= 0) return emptySet() + if (length == 1) return ofSet.map { it.toString() }.toSet() + + val elements = ofSet.toList() + + return buildSet { + fun generate(current: String, startIndex: Int) { + if (current.length == length) { + add(current) + } else { + for (i in startIndex until elements.size) { + generate(current + elements[i], i + 1) + } + } + } + + generate("", 0) + } +} + +private object TestData { + private val validChars = charSet("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~%!$&'()*+,;=") + + /** + * Non-exhaustive set of [actual AWS regions][1]. + * + * [1]: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Concepts.RegionsAndAvailabilityZones.html + */ + private val realRegions = setOf( + "af-south-1", + "ap-east-1", + "ap-east-2", + "ap-northeast-1", + "ap-northeast-2", + "ap-northeast-3", + "ap-south-1", + "ap-south-2", + "ap-southeast-1", + "ap-southeast-2", + "ap-southeast-3", + "ap-southeast-4", + "ap-southeast-5", + "ap-southeast-6", + "ap-southeast-7", + "ca-central-1", + "ca-west-1", + "eu-central-1", + "eu-central-2", + "eu-north-1", + "eu-south-1", + "eu-south-2", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "il-central-1", + "me-central-1", + "me-south-1", + "mx-central-1", + "sa-east-1", + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + ) + + private val kitchenSinkRegion = validChars.joinToString("") // ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-.+~%!$&'()*+,;= + private val regionsWithSpecialChars = combinations(validChars, 3).map { "region-$it" }.toSet() // region-XXX + val validRegions = realRegions + regionsWithSpecialChars + kitchenSinkRegion + + private val printableAsciiChars = charSet(32.toChar()..126.toChar()) // ASCII codepoints 32-126 (inclusive) + private val invalidChars = printableAsciiChars - validChars + val invalidRegions = combinations(invalidChars, 3).map { "region-$it" }.toSet() // region-XXX +} + +class ValidateRegionTest { + @Test + fun testIsRegionValid() { + TestData.validRegions.forEach { + println("Valid region: $it") + assertTrue(isRegionValid(it)) + } + TestData.invalidRegions.forEach { + println("Invalid region: $it") + assertFalse(isRegionValid(it)) + } + } + + @Test + fun testValidateRegion() { + TestData.validRegions.forEach { + assertEquals(it, validateRegion(it)) + } + + TestData.invalidRegions.forEach { + assertFailsWith { + validateRegion(it) + } + } + } +} diff --git a/codegen/aws-sdk-codegen/src/main/kotlin/aws/sdk/kotlin/codegen/AwsRuntimeTypes.kt b/codegen/aws-sdk-codegen/src/main/kotlin/aws/sdk/kotlin/codegen/AwsRuntimeTypes.kt index f5a7c40846d..07addc794d5 100644 --- a/codegen/aws-sdk-codegen/src/main/kotlin/aws/sdk/kotlin/codegen/AwsRuntimeTypes.kt +++ b/codegen/aws-sdk-codegen/src/main/kotlin/aws/sdk/kotlin/codegen/AwsRuntimeTypes.kt @@ -64,6 +64,7 @@ object AwsRuntimeTypes { object Region : RuntimeTypePackage(AwsKotlinDependency.AWS_CONFIG, "region") { val DefaultRegionProviderChain = symbol("DefaultRegionProviderChain") val resolveRegion = symbol("resolveRegion") + val validateRegion = symbol("validateRegion") } } diff --git a/codegen/aws-sdk-codegen/src/main/kotlin/aws/sdk/kotlin/codegen/AwsServiceConfigIntegration.kt b/codegen/aws-sdk-codegen/src/main/kotlin/aws/sdk/kotlin/codegen/AwsServiceConfigIntegration.kt index 999bd8f53c3..3ac38225239 100644 --- a/codegen/aws-sdk-codegen/src/main/kotlin/aws/sdk/kotlin/codegen/AwsServiceConfigIntegration.kt +++ b/codegen/aws-sdk-codegen/src/main/kotlin/aws/sdk/kotlin/codegen/AwsServiceConfigIntegration.kt @@ -4,7 +4,9 @@ */ package aws.sdk.kotlin.codegen -import software.amazon.smithy.kotlin.codegen.core.* +import software.amazon.smithy.kotlin.codegen.core.CodegenContext +import software.amazon.smithy.kotlin.codegen.core.RuntimeTypes +import software.amazon.smithy.kotlin.codegen.core.getContextValue import software.amazon.smithy.kotlin.codegen.integration.AppendingSectionWriter import software.amazon.smithy.kotlin.codegen.integration.KotlinIntegration import software.amazon.smithy.kotlin.codegen.integration.SectionWriterBinding @@ -12,7 +14,7 @@ import software.amazon.smithy.kotlin.codegen.lang.KotlinTypes import software.amazon.smithy.kotlin.codegen.model.asNullable import software.amazon.smithy.kotlin.codegen.model.knowledge.AwsSignatureVersion4 import software.amazon.smithy.kotlin.codegen.model.nullable -import software.amazon.smithy.kotlin.codegen.rendering.* +import software.amazon.smithy.kotlin.codegen.rendering.ServiceClientGenerator import software.amazon.smithy.kotlin.codegen.rendering.protocol.HttpProtocolClientGenerator import software.amazon.smithy.kotlin.codegen.rendering.util.ConfigProperty import software.amazon.smithy.kotlin.codegen.rendering.util.ConfigPropertyType @@ -42,11 +44,12 @@ class AwsServiceConfigIntegration : KotlinIntegration { propertyType = ConfigPropertyType.Custom( render = { prop, writer -> writer.write( - "override val #1L: #2T? = builder.#1L ?: #3T { builder.regionProvider?.getRegion() ?: #4T() }", + "override val #1L: #2T? = (builder.#1L ?: #3T { builder.regionProvider?.getRegion() ?: #4T() })?.let { #5T(it) }", prop.propertyName, prop.symbol, RuntimeTypes.KotlinxCoroutines.runBlocking, AwsRuntimeTypes.Config.Region.resolveRegion, + AwsRuntimeTypes.Config.Region.validateRegion, ) }, ) diff --git a/codegen/aws-sdk-codegen/src/test/kotlin/aws/sdk/kotlin/codegen/AwsServiceConfigIntegrationTest.kt b/codegen/aws-sdk-codegen/src/test/kotlin/aws/sdk/kotlin/codegen/AwsServiceConfigIntegrationTest.kt index a35643213eb..526e2ef0dba 100644 --- a/codegen/aws-sdk-codegen/src/test/kotlin/aws/sdk/kotlin/codegen/AwsServiceConfigIntegrationTest.kt +++ b/codegen/aws-sdk-codegen/src/test/kotlin/aws/sdk/kotlin/codegen/AwsServiceConfigIntegrationTest.kt @@ -9,7 +9,10 @@ import org.junit.jupiter.api.Test import software.amazon.smithy.kotlin.codegen.core.KotlinWriter import software.amazon.smithy.kotlin.codegen.model.expectShape import software.amazon.smithy.kotlin.codegen.rendering.ServiceClientConfigGenerator -import software.amazon.smithy.kotlin.codegen.test.* +import software.amazon.smithy.kotlin.codegen.test.newTestContext +import software.amazon.smithy.kotlin.codegen.test.shouldContainOnlyOnceWithDiff +import software.amazon.smithy.kotlin.codegen.test.toRenderingContext +import software.amazon.smithy.kotlin.codegen.test.toSmithyModel import software.amazon.smithy.model.shapes.ServiceShape class AwsServiceConfigIntegrationTest { @@ -45,7 +48,7 @@ class AwsServiceConfigIntegrationTest { val contents = writer.toString() val expectedProps = """ - override val region: String? = builder.region ?: runBlocking { builder.regionProvider?.getRegion() ?: resolveRegion() } + override val region: String? = (builder.region ?: runBlocking { builder.regionProvider?.getRegion() ?: resolveRegion() })?.let { validateRegion(it) } override val regionProvider: RegionProvider = builder.regionProvider ?: DefaultRegionProviderChain() override val credentialsProvider: CredentialsProvider = builder.credentialsProvider ?: DefaultChainCredentialsProvider(httpClient = httpClient, region = region).manage() """