> entry : headers.entrySet()) {
+ copy.put(entry.getKey(), new ArrayList<>(entry.getValue()));
+ }
+ }
+ this.headers = copy;
return this;
}
diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/presignedurl/model/PresignedUrlDownloadRequest.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/presignedurl/model/PresignedUrlDownloadRequest.java
index 6c19159251f..cae8337fdac 100644
--- a/services/s3/src/main/java/software/amazon/awssdk/services/s3/presignedurl/model/PresignedUrlDownloadRequest.java
+++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/presignedurl/model/PresignedUrlDownloadRequest.java
@@ -16,8 +16,14 @@
package software.amazon.awssdk.services.s3.presignedurl.model;
import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
import java.util.Objects;
+import java.util.TreeMap;
import software.amazon.awssdk.annotations.SdkPublicApi;
+import software.amazon.awssdk.utils.CollectionUtils;
import software.amazon.awssdk.utils.ToString;
import software.amazon.awssdk.utils.Validate;
import software.amazon.awssdk.utils.builder.CopyableBuilder;
@@ -25,24 +31,40 @@
/**
* Request object for performing download operations using a presigned URL.
+ *
+ * If any fields from the {@code GetObjectRequest} that are marshalled as HTTP headers were signed when
+ * generating the presigned URL using {@link software.amazon.awssdk.services.s3.presigner.S3Presigner#presignGetObject},
+ * their values are not stored in the URL — only their names appear in {@code X-Amz-SignedHeaders}. If any
+ * signed headers are missing from the download request, S3 will return a signature mismatch error.
+ *
+ *
Use {@link Builder#putHeader(String, String)} (or {@link Builder#headers(Map)}) to supply the signed
+ * header values at download time, for example:
+ * {@code
+ * PresignedUrlDownloadRequest.builder()
+ * .presignedUrl(url)
+ * .putHeader("Range", "bytes=0-1023")
+ * .putHeader("If-Match", eTag)
+ * .build();
+ * }
+ *
*/
@SdkPublicApi
public final class PresignedUrlDownloadRequest implements ToCopyableBuilder {
+
private final URL presignedUrl;
- private final String range;
- private final String ifMatch;
+ private final Map> headers;
private PresignedUrlDownloadRequest(BuilderImpl builder) {
this.presignedUrl = builder.presignedUrl;
- this.range = builder.range;
- this.ifMatch = builder.ifMatch;
+ this.headers = CollectionUtils.deepUnmodifiableMap(builder.headers,
+ () -> new TreeMap<>(String.CASE_INSENSITIVE_ORDER));
}
/**
*
- * The presigned URL for the S3 object. This URL contains all necessary authentication information and can be used to download
- * the object without additional credentials.
+ * The presigned URL for the S3 object. This URL contains all necessary authentication information and can be used
+ * to download the object without additional credentials.
*
* Note: Presigned URLs have a limited lifetime and will expire after the
* specified duration. Ensure the URL is used before expiration.
@@ -54,29 +76,16 @@ public URL presignedUrl() {
}
/**
- *
- * Specifies the byte range of an object. For more information about the HTTP Range header, see
- *
- * https://www.rfc-editor.org/rfc/rfc9110.html#name-range.
- *
- * Note: Amazon S3 doesn't support retrieving multiple ranges of data per GET request.
+ * Returns the headers to be sent with the download request. These are the headers that were signed when
+ * generating the presigned URL and must be included at download time for the signature to match (for example
+ * {@code Range}, {@code If-Match}, {@code If-None-Match}, or the SSE-C headers).
*
- * @return The HTTP Range header value, or null if not specified.
- */
- public String range() {
- return range;
- }
-
- /**
- *
- * Return the object only if its entity tag (ETag) is the same as the one specified in this header,
- * otherwise return a 412 (precondition failed) error.
- *
+ * The returned map uses case-insensitive header-name comparison.
*
- * @return The If-Match header value, or null if not specified.
+ * @return An unmodifiable map of header name to values, or an empty map if none set.
*/
- public String ifMatch() {
- return ifMatch;
+ public Map> headers() {
+ return headers;
}
@Override
@@ -96,8 +105,7 @@ public static Class extends Builder> serializableBuilderClass() {
public int hashCode() {
int hashCode = 1;
hashCode = 31 * hashCode + Objects.hashCode(presignedUrl());
- hashCode = 31 * hashCode + Objects.hashCode(range());
- hashCode = 31 * hashCode + Objects.hashCode(ifMatch());
+ hashCode = 31 * hashCode + Objects.hashCode(headers());
return hashCode;
}
@@ -111,54 +119,72 @@ public boolean equals(Object obj) {
}
PresignedUrlDownloadRequest other = (PresignedUrlDownloadRequest) obj;
return Objects.equals(presignedUrl(), other.presignedUrl()) &&
- Objects.equals(range(), other.range()) &&
- Objects.equals(ifMatch(), other.ifMatch());
+ Objects.equals(headers(), other.headers());
}
@Override
public String toString() {
return ToString.builder("PresignedUrlDownloadRequest")
.add("PresignedUrl", presignedUrl())
- .add("Range", range())
- .add("IfMatch", ifMatch())
+ .add("Headers", headers())
.build();
}
public interface Builder extends CopyableBuilder {
/**
* Sets the presigned URL for the S3 object.
- * @param presignedUrl
+ * @param presignedUrl the presigned URL
* @return Returns a reference to this object so that method calls can be chained together.
*/
Builder presignedUrl(URL presignedUrl);
/**
- * Specifies the byte range of an object.
- * @param range The HTTP Range header value (e.g., "bytes=0-1023")
+ * Adds a single header to be sent with the download request. Use this to supply any header value that was
+ * signed when generating the presigned URL (for example {@code Range}, {@code If-Match},
+ * {@code If-None-Match}, or the SSE-C headers) and therefore must be present at download time.
+ *
+ * Header names are treated case-insensitively. This overrides any value already configured for the same
+ * header name.
+ *
+ * @param name The header name (e.g., {@code "Range"}, {@code "If-Match"})
+ * @param value The header value (e.g., {@code "bytes=0-1023"})
+ * @return Returns a reference to this object so that method calls can be chained together.
+ */
+ default Builder putHeader(String name, String value) {
+ return putHeader(name, Collections.singletonList(value));
+ }
+
+ /**
+ * Adds a single header with multiple values to be sent with the download request.
+ *
+ * Header names are treated case-insensitively. This overrides any values already configured for the same
+ * header name.
+ *
+ * @param name The header name
+ * @param values The header values
* @return Returns a reference to this object so that method calls can be chained together.
*/
- Builder range(String range);
+ Builder putHeader(String name, List values);
/**
- * Return the object only if its entity tag (ETag) is the same as the one specified in this header.
- * @param ifMatch The If-Match header value (ETag)
+ * Sets all headers to be sent with the download request, replacing any previously configured headers.
+ *
+ * @param headers A map of header name to values
* @return Returns a reference to this object so that method calls can be chained together.
*/
- Builder ifMatch(String ifMatch);
+ Builder headers(Map> headers);
}
static final class BuilderImpl implements Builder {
private URL presignedUrl;
- private String range;
- private String ifMatch;
+ private Map> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
private BuilderImpl() {
}
- private BuilderImpl(PresignedUrlDownloadRequest presignedUrlDownloadRequest) {
- presignedUrl(presignedUrlDownloadRequest.presignedUrl());
- range(presignedUrlDownloadRequest.range());
- ifMatch(presignedUrlDownloadRequest.ifMatch());
+ private BuilderImpl(PresignedUrlDownloadRequest request) {
+ presignedUrl(request.presignedUrl());
+ headers(request.headers());
}
@Override
@@ -168,14 +194,19 @@ public Builder presignedUrl(URL presignedUrl) {
}
@Override
- public Builder range(String range) {
- this.range = range;
+ public Builder putHeader(String name, List values) {
+ Validate.paramNotNull(name, "name");
+ Validate.paramNotNull(values, "values");
+ this.headers.put(name, new ArrayList<>(values));
return this;
}
@Override
- public Builder ifMatch(String ifMatch) {
- this.ifMatch = ifMatch;
+ public Builder headers(Map> headers) {
+ Validate.paramNotNull(headers, "headers");
+ Map> copy = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+ copy.putAll(CollectionUtils.deepCopyMap(headers));
+ this.headers = copy;
return this;
}
diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/MultipartS3AsyncClientTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/MultipartS3AsyncClientTest.java
index d92fd69ac3c..bd3dfe874ac 100644
--- a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/MultipartS3AsyncClientTest.java
+++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/MultipartS3AsyncClientTest.java
@@ -78,7 +78,7 @@ void presignedUrlExtension_rangeSpecified_shouldBypassMultipart() throws Malform
AsyncResponseTransformer mockTransformer = mock(AsyncResponseTransformer.class);
PresignedUrlDownloadRequest req = PresignedUrlDownloadRequest.builder()
.presignedUrl(new URL("https://s3.amazonaws.com/bucket/key?signature=abc"))
- .range("bytes=0-1023")
+ .putHeader("Range", "bytes=0-1023")
.build();
when(mockDelegate.presignedUrlExtension()).thenReturn(mockDelegateExtension);
S3AsyncClient s3AsyncClient = MultipartS3AsyncClient.create(mockDelegate, MultipartConfiguration.builder().build(), true);
diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlDownloadHelperTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlDownloadHelperTest.java
index 7902916abf9..cc78f6e92df 100644
--- a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlDownloadHelperTest.java
+++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlDownloadHelperTest.java
@@ -133,8 +133,8 @@ void createRangedGetRequest_firstPart_shouldNotIncludeIfMatch() throws Malformed
PresignedUrlDownloadRequest result = PresignedUrlDownloadHelper.createRangedGetRequest(
original, 0, 16L, 32L, "\"etag\"");
- assertThat(result.range()).isEqualTo("bytes=0-15");
- assertThat(result.ifMatch()).isNull();
+ assertThat(result.headers().get("Range")).isEqualTo(java.util.Collections.singletonList("bytes=0-15"));
+ assertThat(result.headers().containsKey("If-Match")).isFalse();
assertThat(result.presignedUrl()).isEqualTo(url);
}
@@ -148,8 +148,8 @@ void createRangedGetRequest_secondPart_shouldIncludeIfMatch() throws MalformedUR
PresignedUrlDownloadRequest result = PresignedUrlDownloadHelper.createRangedGetRequest(
original, 1, 16L, 32L, "\"etag\"");
- assertThat(result.range()).isEqualTo("bytes=16-31");
- assertThat(result.ifMatch()).isEqualTo("\"etag\"");
+ assertThat(result.headers().get("Range")).isEqualTo(java.util.Collections.singletonList("bytes=16-31"));
+ assertThat(result.headers().get("If-Match")).isEqualTo(java.util.Collections.singletonList("\"etag\""));
}
@Test
@@ -163,7 +163,7 @@ void createRangedGetRequest_lastPartClamped_shouldNotExceedTotalSize() throws Ma
PresignedUrlDownloadRequest result = PresignedUrlDownloadHelper.createRangedGetRequest(
original, 1, 16L, 30L, "\"etag\"");
- assertThat(result.range()).isEqualTo("bytes=16-29");
+ assertThat(result.headers().get("Range")).isEqualTo(java.util.Collections.singletonList("bytes=16-29"));
}
@Test
@@ -177,6 +177,6 @@ void createRangedGetRequest_nullTotalContentLength_shouldUseFullPartSize() throw
PresignedUrlDownloadRequest result = PresignedUrlDownloadHelper.createRangedGetRequest(
original, 0, 16L, null, null);
- assertThat(result.range()).isEqualTo("bytes=0-15");
+ assertThat(result.headers().get("Range")).isEqualTo(java.util.Collections.singletonList("bytes=0-15"));
}
}
diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlMultipartDownloaderSubscriberWiremockTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlMultipartDownloaderSubscriberWiremockTest.java
index c71534842bb..d73c998a98d 100644
--- a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlMultipartDownloaderSubscriberWiremockTest.java
+++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/multipart/PresignedUrlMultipartDownloaderSubscriberWiremockTest.java
@@ -149,7 +149,7 @@ void presignedUrlDownload_withRangeHeader_shouldReceivePartialContent(String tra
stubSuccessfulRangeResponse();
PresignedUrlDownloadRequest request = PresignedUrlDownloadRequest.builder()
.presignedUrl(presignedUrl)
- .range("bytes=0-10")
+ .putHeader("Range", "bytes=0-10")
.build();
Object result = executeDownload(request, transformerType).join();
byte[] expectedPartial = Arrays.copyOfRange(TEST_DATA, 0, 11);
@@ -333,7 +333,7 @@ void presignedUrlDownload_withRangeHeader_emptyObject_shouldThrow416(String tran
PresignedUrlDownloadRequest request = PresignedUrlDownloadRequest.builder()
.presignedUrl(presignedUrl)
- .range("bytes=0-1024")
+ .putHeader("Range", "bytes=0-1024")
.build();
assertThatThrownBy(() -> executeDownload(request, transformerType).join())
.hasRootCauseInstanceOf(S3Exception.class);
diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/DefaultAsyncPresignedUrlExtensionTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/DefaultAsyncPresignedUrlExtensionTest.java
index 9fed4335aa5..0ccd2fd2696 100644
--- a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/DefaultAsyncPresignedUrlExtensionTest.java
+++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/DefaultAsyncPresignedUrlExtensionTest.java
@@ -152,7 +152,7 @@ private static Stream requestConfigurationTestCases() {
Arguments.of(
"Request with range header",
(Consumer) builder ->
- builder.presignedUrl(createTestUrl()).range("bytes=0-1024"),
+ builder.presignedUrl(createTestUrl()).putHeader("Range", "bytes=0-1024"),
"bytes=0-1024"
)
);
diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/PresignedUrlDownloadRequestMarshallerTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/PresignedUrlDownloadRequestMarshallerTest.java
index 04c047cb4cf..2b6d188bb2b 100644
--- a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/PresignedUrlDownloadRequestMarshallerTest.java
+++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/PresignedUrlDownloadRequestMarshallerTest.java
@@ -119,7 +119,9 @@ void marshall_withValidRangeFormats_shouldAddRangeHeader(String rangeValue) thro
PresignedUrlDownloadRequestWrapper request = PresignedUrlDownloadRequestWrapper.builder()
.url(testUrl)
- .range(rangeValue)
+ .headers(java.util.Collections.singletonMap(
+ "Range",
+ java.util.Collections.singletonList(rangeValue)))
.build();
SdkHttpFullRequest result = marshaller.marshall(request);
@@ -144,7 +146,6 @@ void marshall_withNullOrEmptyRange_shouldNotAddRangeHeader(String rangeValue) th
PresignedUrlDownloadRequestWrapper request = PresignedUrlDownloadRequestWrapper.builder()
.url(testUrl)
- .range(rangeValue)
.build();
SdkHttpFullRequest result = marshaller.marshall(request);
diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/PresignedUrlSignedHeadersWiremockTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/PresignedUrlSignedHeadersWiremockTest.java
new file mode 100644
index 00000000000..7c165bb814b
--- /dev/null
+++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/PresignedUrlSignedHeadersWiremockTest.java
@@ -0,0 +1,251 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.services.s3.internal.presignedurl;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.verify;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.github.tomakehurst.wiremock.client.WireMock;
+import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
+import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import java.net.URL;
+import java.util.concurrent.CompletableFuture;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.auth.credentials.AnonymousCredentialsProvider;
+import software.amazon.awssdk.core.ResponseBytes;
+import software.amazon.awssdk.core.async.AsyncResponseTransformer;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.s3.S3AsyncClient;
+import software.amazon.awssdk.services.s3.model.GetObjectResponse;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+import software.amazon.awssdk.services.s3.presignedurl.model.PresignedUrlDownloadRequest;
+
+/**
+ * WireMock tests verifying that signed headers supplied via {@link PresignedUrlDownloadRequest#putHeader}
+ * are sent in the HTTP request and that missing signed headers result in a signature mismatch (403).
+ */
+@WireMockTest
+class PresignedUrlSignedHeadersWiremockTest {
+
+ private static final String BODY = "hello-presigned";
+ private static final String KEY_PATH = "/test-key";
+
+ private S3AsyncClient s3Client;
+
+ @BeforeEach
+ void setUp(WireMockRuntimeInfo wmInfo) {
+ s3Client = S3AsyncClient.builder()
+ .endpointOverride(java.net.URI.create(wmInfo.getHttpBaseUrl()))
+ .region(Region.US_EAST_1)
+ .credentialsProvider(AnonymousCredentialsProvider.create())
+ .forcePathStyle(true)
+ .build();
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (s3Client != null) {
+ s3Client.close();
+ }
+ }
+
+ @Test
+ void getObject_withRangeHeader_shouldSendRangeInHttpRequest(WireMockRuntimeInfo wmInfo) throws Exception {
+ stubFor(get(urlPathEqualTo(KEY_PATH))
+ .withHeader("Range", equalTo("bytes=0-1023"))
+ .willReturn(aResponse()
+ .withStatus(206)
+ .withHeader("Content-Length", String.valueOf(BODY.length()))
+ .withHeader("Content-Range", "bytes 0-1023/2048")
+ .withBody(BODY)));
+
+ URL presignedUrl = presignedUrlWithSignedHeaders(wmInfo, "host%3Brange");
+
+ ResponseBytes result = s3Client.presignedUrlExtension()
+ .getObject(PresignedUrlDownloadRequest.builder()
+ .presignedUrl(presignedUrl)
+ .putHeader("Range", "bytes=0-1023")
+ .build(),
+ AsyncResponseTransformer.toBytes())
+ .join();
+
+ assertThat(result.asUtf8String()).isEqualTo(BODY);
+ verify(getRequestedFor(urlPathEqualTo(KEY_PATH))
+ .withHeader("Range", equalTo("bytes=0-1023")));
+ }
+
+ @Test
+ void getObject_withIfMatchHeader_shouldSendIfMatchInHttpRequest(WireMockRuntimeInfo wmInfo) throws Exception {
+ stubFor(get(urlPathEqualTo(KEY_PATH))
+ .withHeader("If-Match", equalTo("\"abc123\""))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Length", String.valueOf(BODY.length()))
+ .withBody(BODY)));
+
+ URL presignedUrl = presignedUrlWithSignedHeaders(wmInfo, "host%3Bif-match");
+
+ ResponseBytes result = s3Client.presignedUrlExtension()
+ .getObject(PresignedUrlDownloadRequest.builder()
+ .presignedUrl(presignedUrl)
+ .putHeader("If-Match", "\"abc123\"")
+ .build(),
+ AsyncResponseTransformer.toBytes())
+ .join();
+
+ assertThat(result.asUtf8String()).isEqualTo(BODY);
+ verify(getRequestedFor(urlPathEqualTo(KEY_PATH))
+ .withHeader("If-Match", equalTo("\"abc123\"")));
+ }
+
+ @Test
+ void getObject_withMultipleSignedHeaders_shouldSendAllInHttpRequest(WireMockRuntimeInfo wmInfo) throws Exception {
+ stubFor(get(urlPathEqualTo(KEY_PATH))
+ .withHeader("Range", equalTo("bytes=0-99"))
+ .withHeader("If-None-Match", equalTo("\"old-etag\""))
+ .withHeader("x-amz-server-side-encryption-customer-algorithm", equalTo("AES256"))
+ .willReturn(aResponse()
+ .withStatus(206)
+ .withHeader("Content-Length", String.valueOf(BODY.length()))
+ .withHeader("Content-Range", "bytes 0-99/200")
+ .withBody(BODY)));
+
+ URL presignedUrl = presignedUrlWithSignedHeaders(wmInfo,
+ "host%3Bif-none-match%3Brange%3Bx-amz-server-side-encryption-customer-algorithm");
+
+ ResponseBytes result = s3Client.presignedUrlExtension()
+ .getObject(PresignedUrlDownloadRequest.builder()
+ .presignedUrl(presignedUrl)
+ .putHeader("Range", "bytes=0-99")
+ .putHeader("If-None-Match", "\"old-etag\"")
+ .putHeader("x-amz-server-side-encryption-customer-algorithm", "AES256")
+ .build(),
+ AsyncResponseTransformer.toBytes())
+ .join();
+
+ assertThat(result.asUtf8String()).isEqualTo(BODY);
+ verify(getRequestedFor(urlPathEqualTo(KEY_PATH))
+ .withHeader("Range", equalTo("bytes=0-99"))
+ .withHeader("If-None-Match", equalTo("\"old-etag\""))
+ .withHeader("x-amz-server-side-encryption-customer-algorithm", equalTo("AES256")));
+ }
+
+ @Test
+ void getObject_withMissingSignedHeader_serverReturns403(WireMockRuntimeInfo wmInfo) throws Exception {
+ // Simulate S3 returning 403 SignatureDoesNotMatch when a signed header is missing
+ stubFor(get(urlPathEqualTo(KEY_PATH))
+ .withHeader("Range", WireMock.absent())
+ .willReturn(aResponse()
+ .withStatus(403)
+ .withHeader("Content-Type", "application/xml")
+ .withBody("\n"
+ + ""
+ + "SignatureDoesNotMatch"
+ + "The request signature we calculated does not match "
+ + "the signature you provided."
+ + "")));
+
+ URL presignedUrl = presignedUrlWithSignedHeaders(wmInfo, "host%3Brange");
+
+ // Caller omits the Range header that was signed into the URL
+ CompletableFuture> future = s3Client.presignedUrlExtension()
+ .getObject(PresignedUrlDownloadRequest.builder()
+ .presignedUrl(presignedUrl)
+ .build(),
+ AsyncResponseTransformer.toBytes());
+
+ assertThatThrownBy(future::join)
+ .hasCauseInstanceOf(S3Exception.class)
+ .hasMessageContaining("signature");
+ }
+
+ @Test
+ void getObject_withWrongHeaderValue_serverReturns403(WireMockRuntimeInfo wmInfo) throws Exception {
+ // S3 rejects if the header value doesn't match what was signed
+ stubFor(get(urlPathEqualTo(KEY_PATH))
+ .withHeader("Range", equalTo("bytes=0-1023"))
+ .willReturn(aResponse()
+ .withStatus(206)
+ .withHeader("Content-Length", "1024")
+ .withHeader("Content-Range", "bytes 0-1023/2048")
+ .withBody(BODY)));
+
+ // Any request with a different Range gets 403
+ stubFor(get(urlPathEqualTo(KEY_PATH))
+ .withHeader("Range", WireMock.notMatching("bytes=0-1023"))
+ .willReturn(aResponse()
+ .withStatus(403)
+ .withHeader("Content-Type", "application/xml")
+ .withBody("\n"
+ + ""
+ + "SignatureDoesNotMatch"
+ + "The request signature we calculated does not match "
+ + "the signature you provided."
+ + "")));
+
+ URL presignedUrl = presignedUrlWithSignedHeaders(wmInfo, "host%3Brange");
+
+ // Caller sends a different Range value than what was signed
+ CompletableFuture> future = s3Client.presignedUrlExtension()
+ .getObject(PresignedUrlDownloadRequest.builder()
+ .presignedUrl(presignedUrl)
+ .putHeader("Range", "bytes=500-999")
+ .build(),
+ AsyncResponseTransformer.toBytes());
+
+ assertThatThrownBy(future::join)
+ .hasCauseInstanceOf(S3Exception.class)
+ .hasMessageContaining("signature");
+ }
+
+ @Test
+ void getObject_withNoSignedHeaders_shouldSucceedWithoutExtraHeaders(WireMockRuntimeInfo wmInfo) throws Exception {
+ stubFor(get(urlPathEqualTo(KEY_PATH))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Length", String.valueOf(BODY.length()))
+ .withBody(BODY)));
+
+ // URL signed with only "host" — no additional headers needed
+ URL presignedUrl = presignedUrlWithSignedHeaders(wmInfo, "host");
+
+ ResponseBytes result = s3Client.presignedUrlExtension()
+ .getObject(PresignedUrlDownloadRequest.builder()
+ .presignedUrl(presignedUrl)
+ .build(),
+ AsyncResponseTransformer.toBytes())
+ .join();
+
+ assertThat(result.asUtf8String()).isEqualTo(BODY);
+ }
+
+ private static URL presignedUrlWithSignedHeaders(WireMockRuntimeInfo wmInfo, String signedHeaders) throws Exception {
+ return new URL(wmInfo.getHttpBaseUrl() + KEY_PATH + "?"
+ + "X-Amz-Algorithm=AWS4-HMAC-SHA256&"
+ + "X-Amz-SignedHeaders=" + signedHeaders + "&"
+ + "X-Amz-Signature=fakesig&"
+ + "X-Amz-Expires=600");
+ }
+}
diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/model/PresignedUrlDownloadRequestWrapperTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/model/PresignedUrlDownloadRequestWrapperTest.java
index 8b301a33219..bdd6fecbe21 100644
--- a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/model/PresignedUrlDownloadRequestWrapperTest.java
+++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/presignedurl/model/PresignedUrlDownloadRequestWrapperTest.java
@@ -18,12 +18,13 @@
import static org.assertj.core.api.Assertions.assertThat;
import java.net.URL;
+import java.util.Collections;
import java.util.List;
import java.util.Map;
+import java.util.TreeMap;
import nl.jqno.equalsverifier.EqualsVerifier;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.core.SdkField;
-import software.amazon.awssdk.core.protocol.MarshallLocation;
class PresignedUrlDownloadRequestWrapperTest {
@@ -36,83 +37,69 @@ void equalsAndHashCode_shouldFollowContract() {
@Test
void basicProperties_shouldWork() throws Exception {
+ Map> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+ headers.put("Range", Collections.singletonList("bytes=0-100"));
+ headers.put("If-Match", Collections.singletonList("\"etag-123\""));
+
PresignedUrlDownloadRequestWrapper request = PresignedUrlDownloadRequestWrapper.builder()
.url(new URL("https://example.com"))
- .range("bytes=0-100")
- .ifMatch("\"etag-123\"")
+ .headers(headers)
.build();
assertThat(request.url()).isEqualTo(new URL("https://example.com"));
- assertThat(request.range()).isEqualTo("bytes=0-100");
- assertThat(request.ifMatch()).isEqualTo("\"etag-123\"");
+ assertThat(request.headers().get("Range")).isEqualTo(Collections.singletonList("bytes=0-100"));
+ assertThat(request.headers().get("If-Match")).isEqualTo(Collections.singletonList("\"etag-123\""));
}
@Test
- void sdkFields_shouldReturnExpectedFields() throws Exception {
+ void sdkFields_shouldReturnEmptyList() throws Exception {
PresignedUrlDownloadRequestWrapper request = PresignedUrlDownloadRequestWrapper.builder()
.url(new URL("https://example.com"))
- .range("bytes=0-100")
+ .headers(Collections.emptyMap())
.build();
List> fields = request.sdkFields();
- assertThat(fields).hasSize(2);
- assertThat(fields).extracting(SdkField::memberName)
- .containsExactlyInAnyOrder("Range", "IfMatch");
- assertThat(fields).allMatch(field -> field.location() == MarshallLocation.HEADER);
- SdkField> rangeField = fields.stream()
- .filter(f -> "Range".equals(f.memberName()))
- .findFirst()
- .orElseThrow(() -> new AssertionError("Range field not found"));
- Object rangeValue = rangeField.getValueOrDefault(request);
- assertThat(rangeValue).isNotNull();
- assertThat(rangeValue).isEqualTo("bytes=0-100");
+ assertThat(fields).isEmpty();
}
@Test
- void sdkFieldNameToField_shouldReturnExpectedMapping() throws Exception {
+ void sdkFieldNameToField_shouldReturnEmptyMapping() throws Exception {
PresignedUrlDownloadRequestWrapper request = PresignedUrlDownloadRequestWrapper.builder()
.url(new URL("https://example.com"))
.build();
Map> fieldMap = request.sdkFieldNameToField();
- assertThat(fieldMap)
- .hasSize(2)
- .containsKeys("Range", "IfMatch");
- assertThat(fieldMap.get("Range").memberName()).isEqualTo("Range");
- assertThat(fieldMap.get("IfMatch").memberName()).isEqualTo("IfMatch");
+ assertThat(fieldMap).isEmpty();
}
@Test
- void rangeField_shouldMarshalCorrectly() throws Exception {
+ void headers_shouldBeCaseInsensitive() throws Exception {
+ Map> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+ headers.put("Range", Collections.singletonList("bytes=0-1023"));
+
PresignedUrlDownloadRequestWrapper request = PresignedUrlDownloadRequestWrapper.builder()
.url(new URL("https://example.com"))
- .range("bytes=0-1023")
+ .headers(headers)
.build();
- SdkField> rangeField = request.sdkFields().stream()
- .filter(f -> "Range".equals(f.memberName()))
- .findFirst()
- .orElseThrow(() -> new AssertionError("Range field not found"));
- Object extractedValue = rangeField.getValueOrDefault(request);
-
- assertThat(extractedValue).isEqualTo("bytes=0-1023");
+ assertThat(request.headers().get("range")).isEqualTo(Collections.singletonList("bytes=0-1023"));
+ assertThat(request.headers().get("RANGE")).isEqualTo(Collections.singletonList("bytes=0-1023"));
}
@Test
- void ifMatchField_shouldMarshalCorrectly() throws Exception {
+ void toBuilder_shouldPreserveHeaders() throws Exception {
+ Map> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+ headers.put("If-Match", Collections.singletonList("\"etag-value\""));
+
PresignedUrlDownloadRequestWrapper request = PresignedUrlDownloadRequestWrapper.builder()
.url(new URL("https://example.com"))
- .ifMatch("\"etag-value\"")
+ .headers(headers)
.build();
- SdkField> ifMatchField = request.sdkFields().stream()
- .filter(f -> "IfMatch".equals(f.memberName()))
- .findFirst()
- .orElseThrow(() -> new AssertionError("IfMatch field not found"));
- Object extractedValue = ifMatchField.getValueOrDefault(request);
+ PresignedUrlDownloadRequestWrapper copy = request.toBuilder().build();
- assertThat(extractedValue).isEqualTo("\"etag-value\"");
+ assertThat(copy.headers().get("If-Match")).isEqualTo(Collections.singletonList("\"etag-value\""));
}
}
diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/presignedurl/AsyncPresignedUrlExtensionTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/presignedurl/AsyncPresignedUrlExtensionTest.java
index 1b99c9bde49..49c07119e65 100644
--- a/services/s3/src/test/java/software/amazon/awssdk/services/s3/presignedurl/AsyncPresignedUrlExtensionTest.java
+++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/presignedurl/AsyncPresignedUrlExtensionTest.java
@@ -168,7 +168,7 @@ void givenRangeRequest_whenGetObjectCalled_thenReturnsPartialContent(WireMockRun
URL presignedUrl = createPresignedUrl(wireMockRuntimeInfo, presignedUrlPath);
PresignedUrlDownloadRequest request = PresignedUrlDownloadRequest.builder()
.presignedUrl(presignedUrl)
- .range(range)
+ .putHeader("Range", range)
.build();
CompletableFuture> result =
diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/presignedurl/model/PresignedUrlDownloadRequestTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/presignedurl/model/PresignedUrlDownloadRequestTest.java
index 1cc7b34364f..06e7f20cab8 100644
--- a/services/s3/src/test/java/software/amazon/awssdk/services/s3/presignedurl/model/PresignedUrlDownloadRequestTest.java
+++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/presignedurl/model/PresignedUrlDownloadRequestTest.java
@@ -18,6 +18,7 @@
import static org.assertj.core.api.Assertions.assertThat;
import java.net.URL;
+import java.util.Collections;
import nl.jqno.equalsverifier.EqualsVerifier;
import org.junit.jupiter.api.Test;
@@ -34,11 +35,11 @@ void builder_shouldCreateRequestWithAllFields() throws Exception {
URL url = new URL("https://example.com");
PresignedUrlDownloadRequest request = PresignedUrlDownloadRequest.builder()
.presignedUrl(url)
- .range("bytes=0-100")
+ .putHeader("Range", "bytes=0-100")
.build();
assertThat(request.presignedUrl()).isEqualTo(url);
- assertThat(request.range()).isEqualTo("bytes=0-100");
+ assertThat(request.headers().get("Range")).isEqualTo(Collections.singletonList("bytes=0-100"));
}
@Test
@@ -49,7 +50,7 @@ void builder_shouldCreateRequestWithOnlyRequiredFields() throws Exception {
.build();
assertThat(request.presignedUrl()).isEqualTo(url);
- assertThat(request.range()).isNull();
+ assertThat(request.headers()).isEmpty();
}
@Test
@@ -57,13 +58,13 @@ void toBuilder_shouldCreateBuilderFromExistingRequest() throws Exception {
URL url = new URL("https://example.com");
PresignedUrlDownloadRequest original = PresignedUrlDownloadRequest.builder()
.presignedUrl(url)
- .range("bytes=0-100")
+ .putHeader("Range", "bytes=0-100")
.build();
PresignedUrlDownloadRequest copy = original.toBuilder().build();
assertThat(copy.presignedUrl()).isEqualTo(original.presignedUrl());
- assertThat(copy.range()).isEqualTo(original.range());
+ assertThat(copy.headers()).isEqualTo(original.headers());
}
@Test
@@ -72,29 +73,28 @@ void toBuilder_shouldAllowModification() throws Exception {
URL url2 = new URL("https://other.com");
PresignedUrlDownloadRequest original = PresignedUrlDownloadRequest.builder()
.presignedUrl(url1)
- .range("bytes=0-100")
+ .putHeader("Range", "bytes=0-100")
.build();
PresignedUrlDownloadRequest modified = original.toBuilder()
.presignedUrl(url2)
- .range("bytes=200-300")
+ .putHeader("Range", "bytes=200-300")
.build();
assertThat(modified.presignedUrl()).isEqualTo(url2);
- assertThat(modified.range()).isEqualTo("bytes=200-300");
+ assertThat(modified.headers().get("Range")).isEqualTo(Collections.singletonList("bytes=200-300"));
// Original unchanged
assertThat(original.presignedUrl()).isEqualTo(url1);
- assertThat(original.range()).isEqualTo("bytes=0-100");
+ assertThat(original.headers().get("Range")).isEqualTo(Collections.singletonList("bytes=0-100"));
}
@Test
void toString_shouldContainActualFieldValues() throws Exception {
URL url = new URL("https://example.com");
- String range = "bytes=0-100";
PresignedUrlDownloadRequest request = PresignedUrlDownloadRequest.builder()
.presignedUrl(url)
- .range(range)
+ .putHeader("Range", "bytes=0-100")
.build();
String result = request.toString();
@@ -102,9 +102,7 @@ void toString_shouldContainActualFieldValues() throws Exception {
assertThat(result)
.isNotNull()
.isNotEmpty()
- .contains(request.presignedUrl().toString())
- .contains(request.range());
-
+ .contains(request.presignedUrl().toString());
}
@Test
From 9d9144073e62456de92cc69c9dbb18634729bb3a Mon Sep 17 00:00:00 2001
From: jencymaryjoseph <35571282+jencymaryjoseph@users.noreply.github.com>
Date: Sun, 21 Jun 2026 21:52:17 -0700
Subject: [PATCH 3/3] Fix in transfer manager
---
.../S3TransferManagerPresignedUrlDownloadIntegrationTest.java | 4 ++--
.../awssdk/transfer/s3/internal/GenericS3TransferManager.java | 4 ++--
.../S3TransferManagerPresignedUrlListenerWiremockTest.java | 2 +-
3 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/services-custom/s3-transfer-manager/src/it/java/software/amazon/awssdk/transfer/s3/S3TransferManagerPresignedUrlDownloadIntegrationTest.java b/services-custom/s3-transfer-manager/src/it/java/software/amazon/awssdk/transfer/s3/S3TransferManagerPresignedUrlDownloadIntegrationTest.java
index 003fddb5d5b..dc0e1bf0284 100644
--- a/services-custom/s3-transfer-manager/src/it/java/software/amazon/awssdk/transfer/s3/S3TransferManagerPresignedUrlDownloadIntegrationTest.java
+++ b/services-custom/s3-transfer-manager/src/it/java/software/amazon/awssdk/transfer/s3/S3TransferManagerPresignedUrlDownloadIntegrationTest.java
@@ -128,7 +128,7 @@ void downloadFileWithPresignedUrl_progressTracking(String tmType, S3TransferMana
PresignedUrlDownloadRequest.Builder requestBuilder = PresignedUrlDownloadRequest.builder()
.presignedUrl(createPresignedRequest(key).url());
if (range != null) {
- requestBuilder.range(range);
+ requestBuilder.putHeader("Range", range);
}
PresignedFileDownload download = tm.downloadFileWithPresignedUrl(
@@ -159,7 +159,7 @@ void downloadWithPresignedUrl_toBytes_progressTracking(String tmType, S3Transfer
PresignedUrlDownloadRequest.Builder requestBuilder = PresignedUrlDownloadRequest.builder()
.presignedUrl(createPresignedRequest(key).url());
if (range != null) {
- requestBuilder.range(range);
+ requestBuilder.putHeader("Range", range);
}
Download> download = tm.downloadWithPresignedUrl(
diff --git a/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/internal/GenericS3TransferManager.java b/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/internal/GenericS3TransferManager.java
index c86a3fea12f..f913f1f2606 100644
--- a/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/internal/GenericS3TransferManager.java
+++ b/services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/internal/GenericS3TransferManager.java
@@ -615,7 +615,7 @@ public final PresignedFileDownload downloadFileWithPresignedUrl(PresignedDownloa
progressUpdater.transferInitiated();
responseTransformer = isS3ClientMultipartEnabled()
- && presignedDownloadFileRequest.presignedUrlDownloadRequest().range() == null
+ && !presignedDownloadFileRequest.presignedUrlDownloadRequest().headers().containsKey("Range")
? progressUpdater.wrapForNonSerialFileDownload(
responseTransformer, GetObjectRequest.builder().build())
: progressUpdater.wrapResponseTransformer(responseTransformer);
@@ -651,7 +651,7 @@ public final Download downloadWithPresignedUrl(
progressUpdater.transferInitiated();
responseTransformer = isS3ClientMultipartEnabled()
- && presignedDownloadRequest.presignedUrlDownloadRequest().range() == null
+ && !presignedDownloadRequest.presignedUrlDownloadRequest().headers().containsKey("Range")
? progressUpdater.wrapForNonSerialFileDownload(
responseTransformer, GetObjectRequest.builder().build())
: progressUpdater.wrapResponseTransformer(responseTransformer);
diff --git a/services-custom/s3-transfer-manager/src/test/java/software/amazon/awssdk/transfer/s3/internal/S3TransferManagerPresignedUrlListenerWiremockTest.java b/services-custom/s3-transfer-manager/src/test/java/software/amazon/awssdk/transfer/s3/internal/S3TransferManagerPresignedUrlListenerWiremockTest.java
index 2e9e5dbecca..27a6d95c54c 100644
--- a/services-custom/s3-transfer-manager/src/test/java/software/amazon/awssdk/transfer/s3/internal/S3TransferManagerPresignedUrlListenerWiremockTest.java
+++ b/services-custom/s3-transfer-manager/src/test/java/software/amazon/awssdk/transfer/s3/internal/S3TransferManagerPresignedUrlListenerWiremockTest.java
@@ -119,7 +119,7 @@ void presignedUrlDownload_shouldInvokeListener(boolean multipartEnabled, String
PresignedUrlDownloadRequest.Builder requestBuilder = PresignedUrlDownloadRequest.builder()
.presignedUrl(presignedUrl);
if (range != null) {
- requestBuilder.range(range);
+ requestBuilder.putHeader("Range", range);
}
if ("toFile".equals(type)) {