Skip to content

cloudfront-signer: getSignedUrl returns URL whose path encoding breaks signature verification (regression in 3.1046.0) #8033

Description

@kachar

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

  • Select this option if this issue appears to be a regression.

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();
}

Metadata

Metadata

Assignees

Labels

bugThis issue is a bug.needs-triageThis issue or PR still needs to be triaged.potential-regressionMarking this issue as a potential regression to be checked by team member

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions