Skip to content

Commit bb9b4a2

Browse files
Set Content-MD5 metadata when generating pre-signed URL for uploading object (#1252)
Fixes #1161
1 parent a2da6d2 commit bb9b4a2

File tree

2 files changed

+76
-6
lines changed

2 files changed

+76
-6
lines changed

spring-cloud-aws-s3/src/main/java/io/awspring/cloud/s3/ObjectMetadata.java

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
* Container for S3 Object Metadata. For information about each field look at {@link PutObjectRequest} Javadocs.
2626
*
2727
* @author Maciej Walkowiak
28+
* @author Hardik Singh Behl
2829
* @since 3.0
2930
*/
3031
public class ObjectMetadata {
@@ -116,6 +117,9 @@ public class ObjectMetadata {
116117
@Nullable
117118
private final String checksumAlgorithm;
118119

120+
@Nullable
121+
private final String contentMD5;
122+
119123
public static Builder builder() {
120124
return new Builder();
121125
}
@@ -130,7 +134,7 @@ public static Builder builder() {
130134
@Nullable String ssekmsKeyId, @Nullable String ssekmsEncryptionContext, @Nullable Boolean bucketKeyEnabled,
131135
@Nullable String requestPayer, @Nullable String tagging, @Nullable String objectLockMode,
132136
@Nullable Instant objectLockRetainUntilDate, @Nullable String objectLockLegalHoldStatus,
133-
@Nullable String expectedBucketOwner, @Nullable String checksumAlgorithm) {
137+
@Nullable String expectedBucketOwner, @Nullable String checksumAlgorithm, @Nullable String contentMD5) {
134138
this.acl = acl;
135139
this.cacheControl = cacheControl;
136140
this.contentDisposition = contentDisposition;
@@ -160,6 +164,7 @@ public static Builder builder() {
160164
this.objectLockLegalHoldStatus = objectLockLegalHoldStatus;
161165
this.expectedBucketOwner = expectedBucketOwner;
162166
this.checksumAlgorithm = checksumAlgorithm;
167+
this.contentMD5 = contentMD5;
163168
}
164169

165170
void apply(PutObjectRequest.Builder builder) {
@@ -250,6 +255,9 @@ void apply(PutObjectRequest.Builder builder) {
250255
if (checksumAlgorithm != null) {
251256
builder.checksumAlgorithm(checksumAlgorithm);
252257
}
258+
if (contentMD5 != null) {
259+
builder.contentMD5(contentMD5);
260+
}
253261
}
254262

255263
void apply(CreateMultipartUploadRequest.Builder builder) {
@@ -523,6 +531,11 @@ public String getChecksumAlgorithm() {
523531
return checksumAlgorithm;
524532
}
525533

534+
@Nullable
535+
public String getContentMD5() {
536+
return contentMD5;
537+
}
538+
526539
public static class Builder {
527540

528541
private final Map<String, String> metadata = new HashMap<>();
@@ -611,6 +624,9 @@ public static class Builder {
611624
@Nullable
612625
private String checksumAlgorithm;
613626

627+
@Nullable
628+
private String contentMD5;
629+
614630
public Builder acl(@Nullable String acl) {
615631
this.acl = acl;
616632
return this;
@@ -785,13 +801,18 @@ public Builder checksumAlgorithm(@Nullable ChecksumAlgorithm checksumAlgorithm)
785801
return checksumAlgorithm(checksumAlgorithm != null ? checksumAlgorithm.toString() : null);
786802
}
787803

804+
public Builder contentMD5(@Nullable String contentMD5) {
805+
this.contentMD5 = contentMD5;
806+
return this;
807+
}
808+
788809
public ObjectMetadata build() {
789810
return new ObjectMetadata(acl, cacheControl, contentDisposition, contentEncoding, contentLanguage,
790811
contentType, contentLength, expires, grantFullControl, grantRead, grantReadACP, grantWriteACP,
791812
metadata, serverSideEncryption, storageClass, websiteRedirectLocation, sseCustomerAlgorithm,
792813
sseCustomerKey, sseCustomerKeyMD5, ssekmsKeyId, ssekmsEncryptionContext, bucketKeyEnabled,
793814
requestPayer, tagging, objectLockMode, objectLockRetainUntilDate, objectLockLegalHoldStatus,
794-
expectedBucketOwner, checksumAlgorithm);
815+
expectedBucketOwner, checksumAlgorithm, contentMD5);
795816
}
796817

797818
}

spring-cloud-aws-s3/src/test/java/io/awspring/cloud/s3/S3TemplateIntegrationTests.java

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,17 @@
2020
import static org.assertj.core.api.Assertions.assertThatNoException;
2121

2222
import com.fasterxml.jackson.databind.ObjectMapper;
23+
24+
import net.bytebuddy.utility.RandomString;
25+
2326
import java.io.ByteArrayInputStream;
2427
import java.io.IOException;
2528
import java.io.InputStream;
2629
import java.net.URL;
2730
import java.nio.charset.StandardCharsets;
31+
import java.security.MessageDigest;
2832
import java.time.Duration;
33+
import java.util.Base64;
2934
import java.util.List;
3035
import org.apache.http.HttpEntity;
3136
import org.apache.http.HttpResponse;
@@ -47,6 +52,7 @@
4752
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
4853
import software.amazon.awssdk.core.ResponseInputStream;
4954
import software.amazon.awssdk.core.sync.RequestBody;
55+
import software.amazon.awssdk.http.HttpStatusCode;
5056
import software.amazon.awssdk.regions.Region;
5157
import software.amazon.awssdk.services.s3.S3Client;
5258
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
@@ -62,6 +68,7 @@
6268
* @author Maciej Walkowiak
6369
* @author Yuki Yoshida
6470
* @author Ziemowit Stolarczyk
71+
* @author Hardik Singh Behl
6572
*/
6673
@Testcontainers
6774
class S3TemplateIntegrationTests {
@@ -70,7 +77,7 @@ class S3TemplateIntegrationTests {
7077

7178
@Container
7279
static LocalStackContainer localstack = new LocalStackContainer(
73-
DockerImageName.parse("localstack/localstack:3.8.1"));
80+
DockerImageName.parse("localstack/localstack:3.8.1")).withEnv("S3_SKIP_SIGNATURE_VALIDATION", "0");
7481

7582
private static S3Client client;
7683

@@ -268,15 +275,21 @@ void createsWorkingSignedGetURL() throws IOException {
268275

269276
@Test
270277
void createsWorkingSignedPutURL() throws IOException {
271-
ObjectMetadata metadata = ObjectMetadata.builder().metadata("testkey", "testvalue").build();
278+
String fileContent = RandomString.make();
279+
long contentLength = fileContent.length();
280+
String contentMD5 = calculateContentMD5(fileContent);
281+
282+
ObjectMetadata metadata = ObjectMetadata.builder().metadata("testkey", "testvalue").contentLength(contentLength)
283+
.contentMD5(contentMD5).build();
272284
URL signedPutUrl = s3Template.createSignedPutURL(BUCKET_NAME, "file.txt", Duration.ofMinutes(1), metadata,
273285
"text/plain");
274286

275287
CloseableHttpClient httpClient = HttpClients.createDefault();
276288
HttpPut httpPut = new HttpPut(signedPutUrl.toString());
277289
httpPut.setHeader("x-amz-meta-testkey", "testvalue");
278290
httpPut.setHeader("Content-Type", "text/plain");
279-
HttpEntity body = new StringEntity("hello");
291+
httpPut.setHeader("Content-MD5", contentMD5);
292+
HttpEntity body = new StringEntity(fileContent);
280293
httpPut.setEntity(body);
281294

282295
HttpResponse response = httpClient.execute(httpPut);
@@ -285,11 +298,36 @@ void createsWorkingSignedPutURL() throws IOException {
285298
HeadObjectResponse headObjectResponse = client
286299
.headObject(HeadObjectRequest.builder().bucket(BUCKET_NAME).key("file.txt").build());
287300

288-
assertThat(headObjectResponse.contentLength()).isEqualTo(5);
301+
assertThat(response.getStatusLine().getStatusCode()).isEqualTo(HttpStatusCode.OK);
302+
assertThat(headObjectResponse.contentLength()).isEqualTo(contentLength);
289303
assertThat(headObjectResponse.metadata().containsKey("testkey")).isTrue();
290304
assertThat(headObjectResponse.metadata().get("testkey")).isEqualTo("testvalue");
291305
}
292306

307+
@Test
308+
void signedPutURLFailsForNonMatchingSignature() throws IOException {
309+
String fileContent = RandomString.make();
310+
long contentLength = fileContent.length();
311+
String contentMD5 = calculateContentMD5(fileContent);
312+
String maliciousContent = RandomString.make();
313+
314+
ObjectMetadata metadata = ObjectMetadata.builder().contentLength(contentLength).contentMD5(contentMD5).build();
315+
URL signedPutUrl = s3Template.createSignedPutURL(BUCKET_NAME, "file.txt", Duration.ofMinutes(1), metadata,
316+
"text/plain");
317+
318+
CloseableHttpClient httpClient = HttpClients.createDefault();
319+
HttpPut httpPut = new HttpPut(signedPutUrl.toString());
320+
httpPut.setHeader("Content-Type", "text/plain");
321+
httpPut.setHeader("Content-MD5", contentMD5);
322+
HttpEntity body = new StringEntity(fileContent + maliciousContent);
323+
httpPut.setEntity(body);
324+
325+
HttpResponse response = httpClient.execute(httpPut);
326+
httpClient.close();
327+
328+
assertThat(response.getStatusLine().getStatusCode()).isEqualTo(HttpStatusCode.FORBIDDEN);
329+
}
330+
293331
private void bucketDoesNotExist(ListBucketsResponse r, String bucketName) {
294332
assertThat(r.buckets().stream().filter(b -> b.name().equals(bucketName)).findAny()).isEmpty();
295333
}
@@ -298,6 +336,17 @@ private void bucketExists(ListBucketsResponse r, String bucketName) {
298336
assertThat(r.buckets().stream().filter(b -> b.name().equals(bucketName)).findAny()).isPresent();
299337
}
300338

339+
private String calculateContentMD5(String content) {
340+
try {
341+
MessageDigest md = MessageDigest.getInstance("MD5");
342+
byte[] contentBytes = content.getBytes(StandardCharsets.UTF_8);
343+
byte[] mdBytes = md.digest(contentBytes);
344+
return Base64.getEncoder().encodeToString(mdBytes);
345+
} catch (Exception exception) {
346+
throw new RuntimeException("Failed to calculate Content-MD5", exception);
347+
}
348+
}
349+
301350
static class Person {
302351
private String firstName;
303352
private String lastName;

0 commit comments

Comments
 (0)