Checkboxes for prior research
Describe the bug
Starting in @aws-sdk/cloudfront-signer@3.1046.0, getSignedUrl() returns a URL that CloudFront rejects with 403 AccessDenied whenever the input path contains characters that encodeURIComponent percent-encodes (e.g. =, ,, space).
PR #7763 (which restored path encoding to fix #7571) introduced an encodeUrlPath step that runs after the policy signature has been computed. The signature in the query string is therefore computed over the canonical (unencoded) path, but the wire URL contains the encoded path. When CloudFront reconstructs the canonical resource from the request line and verifies the signature, the two forms don't match.
Source pointer in packages/cloudfront-signer/src/sign.ts:
const params = Object.entries(cloudfrontSignBuilder.createCloudfrontAttribute()); // signs over baseUrl
// ...
const urlString = encodeUrlPath(baseUrl!) + startFlag + params; // encodes after
The signature in params covers baseUrl; the URL on the wire is encodeUrlPath(baseUrl).
Regression Issue
SDK version number
@aws-sdk/cloudfront-signer@3.1048.0 (regression introduced in 3.1046.0)
Which JavaScript Runtime is this issue in?
Node.js
Details of the browser/Node.js/ReactNative version
Node.js v22.x
Reproduction Steps
import { getSignedUrl } from "@aws-sdk/cloudfront-signer";
const url = "https://d123.cloudfront.net/i/foo/bar.png/format=webp,height=2000,width=2000";
const signed = getSignedUrl({
url,
keyPairId: "K1XXXXXXXXXXXXX",
privateKey: process.env.PRIVATE_KEY!,
dateLessThan: new Date(Date.now() + 3600_000).toISOString(),
});
console.log(signed);
// → https://d123.cloudfront.net/i/foo/bar.png/format%3Dwebp%2Cheight%3D2000%2Cwidth%3D2000?Expires=…&Signature=…
Fetch with curl:
$ curl -i '<signed URL above>'
HTTP/2 403
server: CloudFront
x-cache: Error from cloudfront
<?xml version="1.0" encoding="UTF-8"?>
<Error><Code>AccessDenied</Code><Message>Access denied</Message></Error>
Diagnostic: if you take the same signed URL and replace format%3Dwebp%2Cheight%3D2000%2Cwidth%3D2000 with the literal format=webp,height=2000,width=2000 (keeping the query string with the signature unchanged), CloudFront accepts it. That confirms the signature was computed over the unencoded path while the wire URL contains the encoded path.
Observed Behavior
getSignedUrl() returns a URL whose path is percent-encoded but whose Signature query parameter was computed over the unencoded form. CloudFront rejects the request with 403 AccessDenied before forwarding to any origin (x-cache: Error from cloudfront).
Reproduces for any input URL containing characters that encodeURIComponent percent-encodes — e.g. =, ,, space.
Expected Behavior
getSignedUrl() returns a URL that CloudFront accepts (200, or whatever the origin returns). The signature and the wire URL must reference the same canonical resource.
Possible Solution
Move encodeUrlPath(baseUrl) so it runs before the signature is computed, and pass the encoded form to the policy builder. Then the policy Resource matches what's on the wire and CloudFront verifies successfully.
Alternatively, drop the post-sign encodeUrlPath call and document that callers are responsible for encoding the input URL — but that brings back the regression from #7571.
Additional Information/Context
import { getSignedUrl as awsGetSignedUrl } from "@aws-sdk/cloudfront-signer";
type Input = Parameters<typeof awsGetSignedUrl>[0];
export function getSignedUrl(input: Input): string {
const signed = awsGetSignedUrl(input);
const out = new URL(signed);
out.pathname = new URL(input.url).pathname;
return out.toString();
}
Checkboxes for prior research
Describe the bug
Starting in
@aws-sdk/cloudfront-signer@3.1046.0,getSignedUrl()returns a URL that CloudFront rejects with403 AccessDeniedwhenever the input path contains characters thatencodeURIComponentpercent-encodes (e.g.=,,, space).PR #7763 (which restored path encoding to fix #7571) introduced an
encodeUrlPathstep that runs after the policy signature has been computed. The signature in the query string is therefore computed over the canonical (unencoded) path, but the wire URL contains the encoded path. When CloudFront reconstructs the canonical resource from the request line and verifies the signature, the two forms don't match.Source pointer in
packages/cloudfront-signer/src/sign.ts:The signature in
paramscoversbaseUrl; the URL on the wire isencodeUrlPath(baseUrl).Regression Issue
SDK version number
@aws-sdk/cloudfront-signer@3.1048.0 (regression introduced in 3.1046.0)
Which JavaScript Runtime is this issue in?
Node.js
Details of the browser/Node.js/ReactNative version
Node.js v22.x
Reproduction Steps
Fetch with curl:
Diagnostic: if you take the same signed URL and replace
format%3Dwebp%2Cheight%3D2000%2Cwidth%3D2000with the literalformat=webp,height=2000,width=2000(keeping the query string with the signature unchanged), CloudFront accepts it. That confirms the signature was computed over the unencoded path while the wire URL contains the encoded path.Observed Behavior
getSignedUrl()returns a URL whose path is percent-encoded but whoseSignaturequery parameter was computed over the unencoded form. CloudFront rejects the request with403 AccessDeniedbefore forwarding to any origin (x-cache: Error from cloudfront).Reproduces for any input URL containing characters that
encodeURIComponentpercent-encodes — e.g.=,,, space.Expected Behavior
getSignedUrl()returns a URL that CloudFront accepts (200, or whatever the origin returns). The signature and the wire URL must reference the same canonical resource.Possible Solution
Move
encodeUrlPath(baseUrl)so it runs before the signature is computed, and pass the encoded form to the policy builder. Then the policyResourcematches what's on the wire and CloudFront verifies successfully.Alternatively, drop the post-sign
encodeUrlPathcall and document that callers are responsible for encoding the input URL — but that brings back the regression from #7571.Additional Information/Context
@aws-sdk/cloudfront-signer@3.1036.0, or wrapgetSignedUrland overwritepathnameon the returned URL with the unencoded pathname from the input: