Skip to content

Commit b907743

Browse files
authored
fix: handle S3 server encoding of non-US-ASCII object metadata (#375)
* fix: decode S3 double encoding of non-US-ASCII object metadata * add separate dependency for MimeUtility * add for testvectors, and a simple implementation for unicode ec decryption
1 parent 8a69959 commit b907743

File tree

8 files changed

+302
-6
lines changed

8 files changed

+302
-6
lines changed

cfn/S3EC-GitHub-CF-Template.yml

+75-2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,20 @@ Resources:
1414
Action: 'kms:*'
1515
Resource: '*'
1616

17+
S3ECGitHubKMSKeyIDTestVectors:
18+
Type: 'AWS::KMS::Key'
19+
Properties:
20+
Description: KMS Key for GitHub Action Workflow
21+
Enabled: true
22+
KeyPolicy:
23+
Version: 2012-10-17
24+
Statement:
25+
- Effect: Allow
26+
Principal:
27+
AWS: !Sub 'arn:aws:iam::${AWS::AccountId}:root'
28+
Action: 'kms:*'
29+
Resource: '*'
30+
1731
S3ECGitHubKMSKeyIDAlternate:
1832
Type: 'AWS::KMS::Key'
1933
Properties:
@@ -34,6 +48,12 @@ Resources:
3448
AliasName: alias/S3EC-Github-KMS-Key
3549
TargetKeyId: !Ref S3ECGitHubKMSKeyID
3650

51+
S3ECGitHubKMSKeyAliasTestVectors:
52+
Type: 'AWS::KMS::Alias'
53+
Properties:
54+
AliasName: alias/S3EC-Github-KMS-Key-TestVectors
55+
TargetKeyId: !Ref S3ECGitHubKMSKeyIDTestVectors
56+
3757
S3ECGitHubTestS3Bucket:
3858
Type: 'AWS::S3::Bucket'
3959
Properties:
@@ -94,6 +114,36 @@ Resources:
94114
Resource:
95115
- !Join [ "", [ !GetAtt S3ECGitHubTestS3BucketAlternate.Arn, '/*'] ]
96116

117+
S3ECGitHubTestS3BucketTestVectors:
118+
Type: 'AWS::S3::Bucket'
119+
Properties:
120+
BucketName: s3ec-github-test-bucket-testvectors
121+
PublicAccessBlockConfiguration:
122+
BlockPublicAcls: false
123+
BlockPublicPolicy: false
124+
IgnorePublicAcls: false
125+
RestrictPublicBuckets: false
126+
127+
S3ECGitHubS3BucketPolicyTestVectors:
128+
Type: 'AWS::IAM::ManagedPolicy'
129+
Properties:
130+
ManagedPolicyName: S3EC-GitHub-S3-Bucket-Policy-testvectors
131+
PolicyDocument:
132+
Version: 2012-10-17
133+
Statement:
134+
- Effect: Allow
135+
Action:
136+
- 's3:ListBucket'
137+
Resource:
138+
- !GetAtt S3ECGitHubTestS3BucketTestVectors.Arn
139+
- Effect: Allow
140+
Action:
141+
- 's3:PutObject'
142+
- 's3:GetObject'
143+
- 's3:DeleteObject'
144+
Resource:
145+
- !Join [ "", [ !GetAtt S3ECGitHubTestS3BucketTestVectors.Arn, '/*'] ]
146+
97147
S3ECGitHubKMSKeyPolicy:
98148
Type: 'AWS::IAM::ManagedPolicy'
99149
Properties:
@@ -117,6 +167,29 @@ Resources:
117167
}
118168
ManagedPolicyName: S3EC-GitHub-KMS-Key-Policy
119169

170+
S3ECGitHubKMSKeyPolicyTestVectors:
171+
Type: 'AWS::IAM::ManagedPolicy'
172+
Properties:
173+
PolicyDocument: !Sub |
174+
{
175+
"Version": "2012-10-17",
176+
"Statement": [
177+
{
178+
"Effect": "Allow",
179+
"Resource": [
180+
"arn:aws:kms:*:${AWS::AccountId}:key/${S3ECGitHubKMSKeyIDTestVectors}",
181+
"arn:aws:kms:*:${AWS::AccountId}:${S3ECGitHubKMSKeyAliasTestVectors}"
182+
],
183+
"Action": [
184+
"kms:Decrypt",
185+
"kms:GenerateDataKey",
186+
"kms:GenerateDataKeyPair"
187+
]
188+
}
189+
]
190+
}
191+
ManagedPolicyName: S3EC-GitHub-KMS-Key-Policy-TestVectors
192+
120193
S3ECGitHubKMSKeyPolicyAlternate:
121194
Type: 'AWS::IAM::ManagedPolicy'
122195
Properties:
@@ -235,7 +308,7 @@ Resources:
235308
for testing
236309
ManagedPolicyArns:
237310
- !Ref S3ECGitHubKMSKeyPolicy
311+
- !Ref S3ECGitHubKMSKeyPolicyTestVectors
238312
- !Ref S3ECGitHubS3BucketPolicy
239313
- !Ref S3ECGitHubAssumeAlternatePolicy
240-
241-
314+
- !Ref S3ECGitHubS3BucketPolicyTestVectors

cfn/release.yml

+75
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,9 @@ Resources:
9595
- !Ref SecretsManagerPolicyRelease
9696
- !Ref ParameterStorePolicy
9797
- !Ref S3ECReleaseTestKMSKeyPolicy
98+
- !Ref S3ECReleaseTestKMSKeyPolicyTestVectors
9899
- !Ref S3ECReleaseS3BucketPolicy
100+
- !Ref S3ECReleaseS3BucketPolicyTestVectors
99101
- "arn:aws:iam::aws:policy/AWSCodeArtifactReadOnlyAccess"
100102
- "arn:aws:iam::aws:policy/AWSCodeArtifactAdminAccess"
101103

@@ -269,12 +271,85 @@ Resources:
269271
Action: 'kms:*'
270272
Resource: '*'
271273

274+
S3ECReleaseKMSKeyIDTestVectors:
275+
Type: 'AWS::KMS::Key'
276+
Properties:
277+
Description: KMS Key for S3EC Test Vectors
278+
Enabled: true
279+
KeyPolicy:
280+
Version: 2012-10-17
281+
Statement:
282+
- Effect: Allow
283+
Principal:
284+
AWS: !Sub 'arn:aws:iam::${AWS::AccountId}:root'
285+
Action: 'kms:*'
286+
Resource: '*'
287+
288+
S3ECReleaseKMSKeyAliasTestVectors:
289+
Type: 'AWS::KMS::Alias'
290+
Properties:
291+
AliasName: alias/S3EC-Release-KMS-Key-TestVectors
292+
TargetKeyId: !Ref S3ECReleaseKMSKeyIDTestVectors
293+
272294
S3ECReleaseKMSKeyAlias:
273295
Type: 'AWS::KMS::Alias'
274296
Properties:
275297
AliasName: alias/S3EC-Release-Testing-KMS-Key
276298
TargetKeyId: !Ref S3ECReleaseTestingKMSKeyID
277299

300+
S3ECReleaseKMSKeyPolicyTestVectors:
301+
Type: 'AWS::IAM::ManagedPolicy'
302+
Properties:
303+
PolicyDocument: !Sub |
304+
{
305+
"Version": "2012-10-17",
306+
"Statement": [
307+
{
308+
"Effect": "Allow",
309+
"Resource": [
310+
"arn:aws:kms:*:${AWS::AccountId}:key/${S3ECReleaseKMSKeyIDTestVectors}",
311+
"arn:aws:kms:*:${AWS::AccountId}:${S3ECReleaseKMSKeyAliasTestVectors}"
312+
],
313+
"Action": [
314+
"kms:Decrypt",
315+
"kms:GenerateDataKey",
316+
"kms:GenerateDataKeyPair"
317+
]
318+
}
319+
]
320+
}
321+
ManagedPolicyName: S3EC-Release-KMS-Key-Policy-TestVectors
322+
323+
S3ECReleaseTestS3BucketTestVectors:
324+
Type: 'AWS::S3::Bucket'
325+
Properties:
326+
BucketName: s3ec-release-test-bucket-testvectors
327+
PublicAccessBlockConfiguration:
328+
BlockPublicAcls: false
329+
BlockPublicPolicy: false
330+
IgnorePublicAcls: false
331+
RestrictPublicBuckets: false
332+
333+
S3ECReleaseS3BucketPolicyTestVectors:
334+
Type: 'AWS::IAM::ManagedPolicy'
335+
Properties:
336+
ManagedPolicyName: S3EC-Release-S3-Bucket-Policy-testvectors
337+
PolicyDocument:
338+
Version: 2012-10-17
339+
Statement:
340+
- Effect: Allow
341+
Action:
342+
- 's3:ListBucket'
343+
Resource:
344+
- !GetAtt S3ECReleaseTestS3BucketTestVectors.Arn
345+
- Effect: Allow
346+
Action:
347+
- 's3:PutObject'
348+
- 's3:GetObject'
349+
- 's3:DeleteObject'
350+
Resource:
351+
- !Join [ "", [ !GetAtt S3ECReleaseTestS3BucketTestVectors.Arn, '/*'] ]
352+
278353
S3ECReleaseTestS3Bucket:
279354
Type: 'AWS::S3::Bucket'
280355
Properties:

pom.xml

+6
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,12 @@
113113
<version>1.2</version>
114114
</dependency>
115115

116+
<dependency>
117+
<groupId>com.sun.xml.messaging.saaj</groupId>
118+
<artifactId>saaj-impl</artifactId>
119+
<version>2.0.1</version>
120+
</dependency>
121+
116122
<!-- Test Dependencies -->
117123
<!-- https://mvnrepository.com/artifact/org.mockito/mockito-core -->
118124
<dependency>

src/main/java/software/amazon/encryption/s3/internal/ContentMetadataDecodingStrategy.java

+50-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// SPDX-License-Identifier: Apache-2.0
33
package software.amazon.encryption.s3.internal;
44

5+
import com.sun.xml.messaging.saaj.packaging.mime.internet.MimeUtility;
56
import software.amazon.awssdk.core.ResponseInputStream;
67
import software.amazon.awssdk.core.async.AsyncResponseTransformer;
78
import software.amazon.awssdk.protocols.jsoncore.JsonNode;
@@ -15,6 +16,10 @@
1516
import software.amazon.encryption.s3.materials.EncryptedDataKey;
1617
import software.amazon.encryption.s3.materials.S3Keyring;
1718

19+
import java.io.ByteArrayOutputStream;
20+
import java.io.DataOutputStream;
21+
import java.io.IOException;
22+
import java.io.UnsupportedEncodingException;
1823
import java.nio.charset.StandardCharsets;
1924
import java.util.Base64;
2025
import java.util.HashMap;
@@ -135,9 +140,13 @@ private ContentMetadata readFromMap(Map<String, String> metadata, GetObjectRespo
135140
// Get encrypted data key encryption context
136141
final Map<String, String> encryptionContext = new HashMap<>();
137142
final String jsonEncryptionContext = metadata.get(MetadataKeyConstants.ENCRYPTED_DATA_KEY_CONTEXT);
143+
// When the encryption context contains non-US-ASCII characters,
144+
// the S3 server applies an esoteric encoding to the object metadata.
145+
// Reverse that, to allow decryption.
146+
final String decodedJsonEncryptionContext = decodeS3CustomEncoding(jsonEncryptionContext);
138147
try {
139148
JsonNodeParser parser = JsonNodeParser.create();
140-
JsonNode objectNode = parser.parse(jsonEncryptionContext);
149+
JsonNode objectNode = parser.parse(decodedJsonEncryptionContext);
141150

142151
for (Map.Entry<String, JsonNode> entry : objectNode.asObject().entrySet()) {
143152
encryptionContext.put(entry.getKey(), entry.getValue().asString());
@@ -171,6 +180,46 @@ public ContentMetadata decode(GetObjectRequest request, GetObjectResponse respon
171180
}
172181
}
173182

183+
private static String decodeS3CustomEncoding(final String s) {
184+
final String mimeDecoded;
185+
try {
186+
mimeDecoded = MimeUtility.decodeText(s);
187+
} catch (UnsupportedEncodingException ex) {
188+
throw new S3EncryptionClientException("Unable to decode S3 object metadata: " + s, ex);
189+
}
190+
// Once MIME decoded, we need to recover the correct code points from the second encoding pass
191+
// Otherwise, decryption fails
192+
try {
193+
final StringBuilder stringBuilder = new StringBuilder();
194+
final ByteArrayOutputStream baos = new ByteArrayOutputStream();
195+
final DataOutputStream out = new DataOutputStream(baos);
196+
final byte[] sInBytes = mimeDecoded.getBytes(StandardCharsets.UTF_8);
197+
final char[] sInChars = mimeDecoded.toCharArray();
198+
199+
int nonAsciiChars = 0;
200+
for (int i = 0; i < sInChars.length; i++) {
201+
if (sInChars[i] > 127) {
202+
byte[] buf = {sInBytes[i + nonAsciiChars], sInBytes[i + nonAsciiChars + 1]};
203+
// temporarily re-encode as UTF-8
204+
String wrongString = new String(buf, StandardCharsets.UTF_8);
205+
// write its code point
206+
out.write(wrongString.charAt(0));
207+
nonAsciiChars++;
208+
} else {
209+
if (baos.size() > 0) {
210+
// This is not the most efficient, but we prefer to specify UTF_8
211+
stringBuilder.append(new String(baos.toByteArray(), StandardCharsets.UTF_8));
212+
baos.reset();
213+
}
214+
stringBuilder.append(sInChars[i]);
215+
}
216+
}
217+
return stringBuilder.toString();
218+
} catch (IOException exception) {
219+
throw new S3EncryptionClientException("Unable to decode S3 object metadata: " + s, exception);
220+
}
221+
}
222+
174223
private ContentMetadata decodeFromObjectMetadata(GetObjectRequest request, GetObjectResponse response) {
175224
return readFromMap(response.metadata(), response);
176225
}

src/test/java/software/amazon/encryption/s3/S3AsyncEncryptionClientTest.java

+2-1
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
import static software.amazon.encryption.s3.utils.S3EncryptionClientTestResources.BUCKET;
6666
import static software.amazon.encryption.s3.utils.S3EncryptionClientTestResources.KMS_KEY_ID;
6767
import static software.amazon.encryption.s3.utils.S3EncryptionClientTestResources.KMS_REGION;
68+
import static software.amazon.encryption.s3.utils.S3EncryptionClientTestResources.S3_REGION;
6869
import static software.amazon.encryption.s3.utils.S3EncryptionClientTestResources.appendTestSuffix;
6970
import static software.amazon.encryption.s3.utils.S3EncryptionClientTestResources.deleteObject;
7071

@@ -89,7 +90,7 @@ public void asyncCustomConfiguration() {
8990
S3AsyncClient wrappedAsyncClient = S3AsyncClient
9091
.builder()
9192
.credentialsProvider(creds)
92-
.region(Region.of(KMS_REGION.toString()))
93+
.region(Region.of(S3_REGION.toString()))
9394
.build();
9495
KmsClient kmsClient = KmsClient
9596
.builder()

src/test/java/software/amazon/encryption/s3/S3EncryptionClientTest.java

+26
Original file line numberDiff line numberDiff line change
@@ -970,6 +970,32 @@ public void s3EncryptionClientMixedCredentialsInstructionFileFails() {
970970
s3Client.close();
971971
}
972972

973+
@Test
974+
public void NonUSASCIIMetadataFails() {
975+
final String objectKey = appendTestSuffix("non-us-ascii-metadata-fails");
976+
final String input = "This is a test.";
977+
S3Client v3Client = S3EncryptionClient.builder()
978+
.kmsKeyId(KMS_KEY_ALIAS)
979+
.build();
980+
981+
Map<String, String> ec = new HashMap<>(1);
982+
ec.put("ec-key", "我的源资源");
983+
try {
984+
v3Client.putObject(builder -> builder
985+
.bucket(BUCKET)
986+
.key(objectKey)
987+
.overrideConfiguration(withAdditionalConfiguration(ec))
988+
.build(), RequestBody.fromString(input));
989+
} catch (S3EncryptionClientException exception) {
990+
// The Java SDK does not support writing object metadata
991+
// with non-US-ASCII characters.
992+
assertTrue(exception.getCause() instanceof S3Exception);
993+
}
994+
995+
// Cleanup
996+
v3Client.close();
997+
}
998+
973999
/**
9741000
* A simple, reusable round-trip (encryption + decryption) using a given
9751001
* S3Client. Useful for testing client configuration.

0 commit comments

Comments
 (0)