Skip to content

Commit 1ec70ff

Browse files
fix(s3-request-presigner): skip hoisting SSE headers (#1701)
* feat(signature-v4): presigner skips headers from hosting to query * fix(s3-request-presigner): skip hoisting SSE headers Co-authored-by: Attila Večerek <[email protected]>
1 parent 049f45e commit 1ec70ff

File tree

8 files changed

+118
-6
lines changed

8 files changed

+118
-6
lines changed

Diff for: packages/s3-request-presigner/README.md

+15-2
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ You can get signed URL for other S3 operations too, like `PutObjectCommand`.
3434
`expiresIn` config from the examples above is optional. If not set, it's default
3535
at `900`.
3636

37+
If your request contains server-side encryption(`SSE*`) configurations, because
38+
of S3 limitation, you need to send corresponding headers along with the
39+
presigned url. For more information, please go to [S3 SSE reference](https://docs.aws.amazon.com/AmazonS3/latest/dev/KMSUsingRESTAPI.html)
40+
3741
If you already have a request, you can pre-sign the request following the
3842
section bellow.
3943

@@ -51,7 +55,7 @@ const signer = new S3RequestPresigner({
5155
sha256: Hash.bind(null, "sha256"), // In Node.js
5256
//sha256: Sha256 // In browsers
5357
});
54-
const url = await signer.presign(request);
58+
const presigned = await signer.presign(request);
5559
```
5660

5761
ES6 Example:
@@ -66,7 +70,7 @@ const signer = new S3RequestPresigner({
6670
sha256: Hash.bind(null, "sha256"), // In Node.js
6771
//sha256: Sha256 // In browsers
6872
});
69-
const url = await signer.presign(request);
73+
const presigned = await signer.presign(request);
7074
```
7175

7276
To avoid redundant construction parameters when instantiating the s3 presigner,
@@ -79,3 +83,12 @@ const signer = new S3RequestPresigner({
7983
...s3.config,
8084
});
8185
```
86+
87+
If your request contains server-side encryption(`x-amz-server-side-encryption*`)
88+
headers, because of S3 limitation, you need to send these headers along
89+
with the presigned url. That is to say, the url only from calling `formatUrl()`
90+
to `presigned` is not sufficient to make a request. You need to send the
91+
server-side encryption headers along with the url. These headers remain in the
92+
`presigned.headers`
93+
94+
For more information, please go to [S3 SSE reference](https://docs.aws.amazon.com/AmazonS3/latest/dev/KMSUsingRESTAPI.html)

Diff for: packages/s3-request-presigner/src/presigner.spec.ts

+20
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,24 @@ describe("s3 presigner", () => {
8787
[EXPIRES_QUERY_PARAM]: "900",
8888
});
8989
});
90+
91+
it("should disable hoisting server-side-encryption headers to query", async () => {
92+
const signer = new S3RequestPresigner(s3ResolvedConfig);
93+
const signed = await signer.presign({
94+
...minimalRequest,
95+
headers: {
96+
...minimalRequest.headers,
97+
"x-amz-server-side-encryption": "kms",
98+
"x-amz-server-side-encryption-customer-algorithm": "AES256",
99+
},
100+
});
101+
expect(signed.headers).toMatchObject({
102+
"x-amz-server-side-encryption": "kms",
103+
});
104+
const signedHeadersHeader = signed.query?.["X-Amz-SignedHeaders"];
105+
const signedHeaders =
106+
typeof signedHeadersHeader === "string" ? signedHeadersHeader.split(";") : signedHeadersHeader;
107+
expect(signedHeaders).toContain("x-amz-server-side-encryption");
108+
expect(signedHeaders).toContain("x-amz-server-side-encryption-customer-algorithm");
109+
});
90110
});

Diff for: packages/s3-request-presigner/src/presigner.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,22 @@ export class S3RequestPresigner implements RequestPresigner {
3131

3232
public async presign(
3333
requestToSign: IHttpRequest,
34-
{ unsignableHeaders = new Set(), ...options }: RequestPresigningArguments = {}
34+
{ unsignableHeaders = new Set(), unhoistableHeaders = new Set(), ...options }: RequestPresigningArguments = {}
3535
): Promise<IHttpRequest> {
3636
unsignableHeaders.add("content-type");
37+
// S3 requires SSE headers to be signed in headers instead of query
38+
// See: https://github.com/aws/aws-sdk-js-v3/issues/1576
39+
Object.keys(requestToSign.headers)
40+
.map((header) => header.toLowerCase())
41+
.filter((header) => header.startsWith("x-amz-server-side-encryption"))
42+
.forEach((header) => {
43+
unhoistableHeaders.add(header);
44+
});
3745
requestToSign.headers[SHA256_HEADER] = UNSIGNED_PAYLOAD;
3846
return this.signer.presign(requestToSign, {
3947
expiresIn: 900,
4048
unsignableHeaders,
49+
unhoistableHeaders,
4150
...options,
4251
});
4352
}

Diff for: packages/signature-v4/src/SignatureV4.spec.ts

+24
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,30 @@ describe("SignatureV4", () => {
6666
});
6767
});
6868

69+
it("should sign request without hoisting some headers", async () => {
70+
const { query, headers } = await signer.presign(
71+
{
72+
...minimalRequest,
73+
headers: {
74+
...minimalRequest.headers,
75+
"x-amz-not-hoisted": "test",
76+
},
77+
},
78+
{ ...presigningOptions, unhoistableHeaders: new Set(["x-amz-not-hoisted"]) }
79+
);
80+
expect(query).toEqual({
81+
[ALGORITHM_QUERY_PARAM]: ALGORITHM_IDENTIFIER,
82+
[CREDENTIAL_QUERY_PARAM]: "foo/20000101/us-bar-1/foo/aws4_request",
83+
[AMZ_DATE_QUERY_PARAM]: "20000101T000000Z",
84+
[EXPIRES_QUERY_PARAM]: presigningOptions.expiresIn.toString(),
85+
[SIGNED_HEADERS_QUERY_PARAM]: `${HOST_HEADER};x-amz-not-hoisted`,
86+
[SIGNATURE_QUERY_PARAM]: "3c3ef586754b111e9528009710b797a07457d6a671058ba89041a06bab45f585",
87+
});
88+
expect(headers).toMatchObject({
89+
"x-amz-not-hoisted": "test",
90+
});
91+
});
92+
6993
it("should support overriding region and service in the signer instance", async () => {
7094
const signer = new SignatureV4({
7195
...signerInit,

Diff for: packages/signature-v4/src/SignatureV4.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ export class SignatureV4 implements RequestPresigner, RequestSigner, StringSigne
120120
signingDate = new Date(),
121121
expiresIn = 3600,
122122
unsignableHeaders,
123+
unhoistableHeaders,
123124
signableHeaders,
124125
signingRegion,
125126
signingService,
@@ -135,7 +136,7 @@ export class SignatureV4 implements RequestPresigner, RequestSigner, StringSigne
135136
}
136137

137138
const scope = createScope(shortDate, region, signingService ?? this.service);
138-
const request = moveHeadersToQuery(prepareRequest(originalRequest));
139+
const request = moveHeadersToQuery(prepareRequest(originalRequest), { unhoistableHeaders });
139140

140141
if (credentials.sessionToken) {
141142
request.query[TOKEN_QUERY_PARAM] = credentials.sessionToken;

Diff for: packages/signature-v4/src/moveHeadersToQuery.spec.ts

+31
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,35 @@ describe("moveHeadersToQuery", () => {
6868
"X-Amz-Storage-Class": "STANDARD_IA",
6969
});
7070
});
71+
72+
it("should skip hoisting headers to the querystring supplied in unhoistedHeaders", () => {
73+
const req = moveHeadersToQuery(
74+
new HttpRequest({
75+
...minimalRequest,
76+
headers: {
77+
Host: "www.example.com",
78+
"X-Amz-Website-Redirect-Location": "/index.html",
79+
Foo: "bar",
80+
fizz: "buzz",
81+
SNAP: "crackle, pop",
82+
"X-Amz-Storage-Class": "STANDARD_IA",
83+
},
84+
}),
85+
{
86+
unhoistableHeaders: new Set(["x-amz-website-redirect-location"]),
87+
}
88+
);
89+
90+
expect(req.query).toEqual({
91+
"X-Amz-Storage-Class": "STANDARD_IA",
92+
});
93+
94+
expect(req.headers).toEqual({
95+
Host: "www.example.com",
96+
"X-Amz-Website-Redirect-Location": "/index.html",
97+
Foo: "bar",
98+
fizz: "buzz",
99+
SNAP: "crackle, pop",
100+
});
101+
});
71102
});

Diff for: packages/signature-v4/src/moveHeadersToQuery.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,15 @@ import { cloneRequest } from "./cloneRequest";
55
/**
66
* @internal
77
*/
8-
export function moveHeadersToQuery(request: HttpRequest): HttpRequest & { query: QueryParameterBag } {
8+
export function moveHeadersToQuery(
9+
request: HttpRequest,
10+
options: { unhoistableHeaders?: Set<string> } = {}
11+
): HttpRequest & { query: QueryParameterBag } {
912
const { headers, query = {} as QueryParameterBag } =
1013
typeof (request as any).clone === "function" ? (request as any).clone() : cloneRequest(request);
1114
for (const name of Object.keys(headers)) {
1215
const lname = name.toLowerCase();
13-
if (lname.substr(0, 6) === "x-amz-") {
16+
if (lname.substr(0, 6) === "x-amz-" && !options.unhoistableHeaders?.has(lname)) {
1417
query[name] = headers[name];
1518
delete headers[name];
1619
}

Diff for: packages/types/src/signature.ts

+11
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,17 @@ export interface RequestPresigningArguments extends RequestSigningArguments {
5252
* The number of seconds before the presigned URL expires
5353
*/
5454
expiresIn?: number;
55+
56+
/**
57+
* A set of strings whose representing headers that should not be hoisted
58+
* to presigned request's query string. If not supplied, the presigner
59+
* moves all the AWS-specific headers (starting with `x-amz-`) to the request
60+
* query string. If supplied, these headers remain in the presigned request's
61+
* header.
62+
* All headers in the provided request will have their names converted to
63+
* lower case and then checked for existence in the unhoistableHeaders set.
64+
*/
65+
unhoistableHeaders?: Set<string>;
5566
}
5667

5768
export interface EventSigningArguments extends SigningArguments {

0 commit comments

Comments
 (0)