Skip to content

Commit 57bb93c

Browse files
authored
feat: validate caller-provided regions in client config (#1741)
1 parent d3c3e4a commit 57bb93c

File tree

7 files changed

+213
-5
lines changed

7 files changed

+213
-5
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"id": "afeacad1-0f2c-483d-a755-4df1fd6fd440",
3+
"type": "feature",
4+
"description": "Validate caller-specified AWS regions in client config (i.e., `region` and `regionProvider`)"
5+
}

aws-runtime/aws-config/api/aws-config.api

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,3 +707,8 @@ public final class aws/sdk/kotlin/runtime/region/ResolveRegionKt {
707707
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;
708708
}
709709

710+
public final class aws/sdk/kotlin/runtime/region/ValidateRegionKt {
711+
public static final fun isRegionValid (Ljava/lang/String;)Z
712+
public static final fun validateRegion (Ljava/lang/String;)Ljava/lang/String;
713+
}
714+
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
package aws.sdk.kotlin.runtime.region
6+
7+
import aws.sdk.kotlin.runtime.ConfigurationException
8+
import aws.sdk.kotlin.runtime.InternalSdkApi
9+
10+
internal fun charSet(chars: String) = chars.toCharArray().toSet()
11+
internal fun charSet(range: CharRange) = range.toSet()
12+
13+
private object Rfc3986CharSets {
14+
val alpha = charSet('A'..'Z') + charSet('a'..'z')
15+
val digit = charSet('0'..'9')
16+
val unreserved = alpha + digit + charSet("-._~")
17+
val hexdig = digit + charSet('A'..'F')
18+
val pctEncoded = hexdig + '%'
19+
val subDelims = charSet("!$&'()*+,;=")
20+
val regName = unreserved + pctEncoded + subDelims
21+
}
22+
23+
/**
24+
* Determines if the given region is valid for the purposes of endpoint lookup, specifically that the region is suitable
25+
* to use in a URI hostname according to [RFC 3986 § 3.2.2](https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2).
26+
*
27+
* Valid characters for regions include:
28+
* * URI unreserved characters:
29+
* * Uppercase letters (`A` through `Z`)
30+
* * Lowercase letters (`a` through `z`)
31+
* * Digits (`0` through `9`)
32+
* * Hyphen (`-`)
33+
* * Period/dot (`.`)
34+
* * Tilde (`~`)
35+
* * Underscore (`_`)
36+
* * Percent (`%`)
37+
* * URI sub-delimiters
38+
* * Ampersand (`&`)
39+
* * Apostrophe (`'`)
40+
* * Asterisk (`*`)
41+
* * Comma (`,`)
42+
* * Dollar sign (`$`)
43+
* * Equals sign (`=`)
44+
* * Exclamation point (`!`)
45+
* * Parentheses (`(` and `)`)
46+
* * Plus (`+`)
47+
* * Semicolon (`;`)
48+
*
49+
* Notable characters which are _invalid_ for regions include:
50+
* * Space (` `)
51+
* * At sign (`@`)
52+
* * Backtick/grave (`` ` ``)
53+
* * Braces (`{` and `}`)
54+
* * Brackets (`[` and `]`)
55+
* * Caret (`^`)
56+
* * Colon (`:`)
57+
* * Double quote (`"`)
58+
* * Hash/number sign (`#`)
59+
* * Inequality signs (`<` and `>`)
60+
* * Pipe (`|`)
61+
* * Question mark (`?`)
62+
* * Slashes (`/` and `\`)
63+
* * All non-ASCII characters (e.g., Unicode characters)
64+
*/
65+
@InternalSdkApi
66+
public fun isRegionValid(region: String): Boolean = region.isNotEmpty() && region.all(Rfc3986CharSets.regName::contains)
67+
68+
/**
69+
* Validates that a region is suitable to use in a URI hostname according to
70+
* [RFC 3986 § 3.2.2](https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2). See [isRegionValid] for a detailed
71+
* description of the validation criteria.
72+
*/
73+
@InternalSdkApi
74+
public fun validateRegion(region: String): String = region.also {
75+
if (!isRegionValid(region)) {
76+
throw ConfigurationException("""Configured region "$region" is invalid. A region must be a valid URI host component.""")
77+
}
78+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
package aws.sdk.kotlin.runtime.region
6+
7+
import aws.sdk.kotlin.runtime.ConfigurationException
8+
import kotlin.test.*
9+
10+
/**
11+
* Forms the [combinations](https://en.wikipedia.org/wiki/Combination) of a given length for the given set
12+
*/
13+
private fun combinations(ofSet: Set<Char>, length: Int): Set<String> {
14+
if (length <= 0) return emptySet()
15+
if (length == 1) return ofSet.map { it.toString() }.toSet()
16+
17+
val elements = ofSet.toList()
18+
19+
return buildSet {
20+
fun generate(current: String, startIndex: Int) {
21+
if (current.length == length) {
22+
add(current)
23+
} else {
24+
for (i in startIndex until elements.size) {
25+
generate(current + elements[i], i + 1)
26+
}
27+
}
28+
}
29+
30+
generate("", 0)
31+
}
32+
}
33+
34+
private object TestData {
35+
private val validChars = charSet("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~%!$&'()*+,;=")
36+
37+
/**
38+
* Non-exhaustive set of [actual AWS regions][1].
39+
*
40+
* [1]: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Concepts.RegionsAndAvailabilityZones.html
41+
*/
42+
private val realRegions = setOf(
43+
"af-south-1",
44+
"ap-east-1",
45+
"ap-east-2",
46+
"ap-northeast-1",
47+
"ap-northeast-2",
48+
"ap-northeast-3",
49+
"ap-south-1",
50+
"ap-south-2",
51+
"ap-southeast-1",
52+
"ap-southeast-2",
53+
"ap-southeast-3",
54+
"ap-southeast-4",
55+
"ap-southeast-5",
56+
"ap-southeast-6",
57+
"ap-southeast-7",
58+
"ca-central-1",
59+
"ca-west-1",
60+
"eu-central-1",
61+
"eu-central-2",
62+
"eu-north-1",
63+
"eu-south-1",
64+
"eu-south-2",
65+
"eu-west-1",
66+
"eu-west-2",
67+
"eu-west-3",
68+
"il-central-1",
69+
"me-central-1",
70+
"me-south-1",
71+
"mx-central-1",
72+
"sa-east-1",
73+
"us-east-1",
74+
"us-east-2",
75+
"us-west-1",
76+
"us-west-2",
77+
)
78+
79+
private val kitchenSinkRegion = validChars.joinToString("") // ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-.+~%!$&'()*+,;=
80+
private val regionsWithSpecialChars = combinations(validChars, 3).map { "region-$it" }.toSet() // region-XXX
81+
val validRegions = realRegions + regionsWithSpecialChars + kitchenSinkRegion
82+
83+
private val printableAsciiChars = charSet(32.toChar()..126.toChar()) // ASCII codepoints 32-126 (inclusive)
84+
private val invalidChars = printableAsciiChars - validChars
85+
val invalidRegions = combinations(invalidChars, 3).map { "region-$it" }.toSet() // region-XXX
86+
}
87+
88+
class ValidateRegionTest {
89+
@Test
90+
fun testIsRegionValid() {
91+
TestData.validRegions.forEach {
92+
println("Valid region: $it")
93+
assertTrue(isRegionValid(it))
94+
}
95+
TestData.invalidRegions.forEach {
96+
println("Invalid region: $it")
97+
assertFalse(isRegionValid(it))
98+
}
99+
}
100+
101+
@Test
102+
fun testValidateRegion() {
103+
TestData.validRegions.forEach {
104+
assertEquals(it, validateRegion(it))
105+
}
106+
107+
TestData.invalidRegions.forEach {
108+
assertFailsWith<ConfigurationException> {
109+
validateRegion(it)
110+
}
111+
}
112+
}
113+
}

codegen/aws-sdk-codegen/src/main/kotlin/aws/sdk/kotlin/codegen/AwsRuntimeTypes.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ object AwsRuntimeTypes {
6464
object Region : RuntimeTypePackage(AwsKotlinDependency.AWS_CONFIG, "region") {
6565
val DefaultRegionProviderChain = symbol("DefaultRegionProviderChain")
6666
val resolveRegion = symbol("resolveRegion")
67+
val validateRegion = symbol("validateRegion")
6768
}
6869
}
6970

codegen/aws-sdk-codegen/src/main/kotlin/aws/sdk/kotlin/codegen/AwsServiceConfigIntegration.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@
44
*/
55
package aws.sdk.kotlin.codegen
66

7-
import software.amazon.smithy.kotlin.codegen.core.*
7+
import software.amazon.smithy.kotlin.codegen.core.CodegenContext
8+
import software.amazon.smithy.kotlin.codegen.core.RuntimeTypes
9+
import software.amazon.smithy.kotlin.codegen.core.getContextValue
810
import software.amazon.smithy.kotlin.codegen.integration.AppendingSectionWriter
911
import software.amazon.smithy.kotlin.codegen.integration.KotlinIntegration
1012
import software.amazon.smithy.kotlin.codegen.integration.SectionWriterBinding
1113
import software.amazon.smithy.kotlin.codegen.lang.KotlinTypes
1214
import software.amazon.smithy.kotlin.codegen.model.asNullable
1315
import software.amazon.smithy.kotlin.codegen.model.knowledge.AwsSignatureVersion4
1416
import software.amazon.smithy.kotlin.codegen.model.nullable
15-
import software.amazon.smithy.kotlin.codegen.rendering.*
17+
import software.amazon.smithy.kotlin.codegen.rendering.ServiceClientGenerator
1618
import software.amazon.smithy.kotlin.codegen.rendering.protocol.HttpProtocolClientGenerator
1719
import software.amazon.smithy.kotlin.codegen.rendering.util.ConfigProperty
1820
import software.amazon.smithy.kotlin.codegen.rendering.util.ConfigPropertyType
@@ -42,11 +44,12 @@ class AwsServiceConfigIntegration : KotlinIntegration {
4244
propertyType = ConfigPropertyType.Custom(
4345
render = { prop, writer ->
4446
writer.write(
45-
"override val #1L: #2T? = builder.#1L ?: #3T { builder.regionProvider?.getRegion() ?: #4T() }",
47+
"override val #1L: #2T? = (builder.#1L ?: #3T { builder.regionProvider?.getRegion() ?: #4T() })?.let { #5T(it) }",
4648
prop.propertyName,
4749
prop.symbol,
4850
RuntimeTypes.KotlinxCoroutines.runBlocking,
4951
AwsRuntimeTypes.Config.Region.resolveRegion,
52+
AwsRuntimeTypes.Config.Region.validateRegion,
5053
)
5154
},
5255
)

codegen/aws-sdk-codegen/src/test/kotlin/aws/sdk/kotlin/codegen/AwsServiceConfigIntegrationTest.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import org.junit.jupiter.api.Test
99
import software.amazon.smithy.kotlin.codegen.core.KotlinWriter
1010
import software.amazon.smithy.kotlin.codegen.model.expectShape
1111
import software.amazon.smithy.kotlin.codegen.rendering.ServiceClientConfigGenerator
12-
import software.amazon.smithy.kotlin.codegen.test.*
12+
import software.amazon.smithy.kotlin.codegen.test.newTestContext
13+
import software.amazon.smithy.kotlin.codegen.test.shouldContainOnlyOnceWithDiff
14+
import software.amazon.smithy.kotlin.codegen.test.toRenderingContext
15+
import software.amazon.smithy.kotlin.codegen.test.toSmithyModel
1316
import software.amazon.smithy.model.shapes.ServiceShape
1417

1518
class AwsServiceConfigIntegrationTest {
@@ -45,7 +48,7 @@ class AwsServiceConfigIntegrationTest {
4548
val contents = writer.toString()
4649

4750
val expectedProps = """
48-
override val region: String? = builder.region ?: runBlocking { builder.regionProvider?.getRegion() ?: resolveRegion() }
51+
override val region: String? = (builder.region ?: runBlocking { builder.regionProvider?.getRegion() ?: resolveRegion() })?.let { validateRegion(it) }
4952
override val regionProvider: RegionProvider = builder.regionProvider ?: DefaultRegionProviderChain()
5053
override val credentialsProvider: CredentialsProvider = builder.credentialsProvider ?: DefaultChainCredentialsProvider(httpClient = httpClient, region = region).manage()
5154
"""

0 commit comments

Comments
 (0)