$ cd ..

Making Cloudfront Signed URLs work with response-content-disposition

πŸ“… 2022-11-28

βŒ› 50 days ago

πŸ•‘ 2 min read

As of v3.219.0 of the @aws-sdk/cloudfront-signer package, adding a response-content-disposition query parameter to the URL being sent to the getSignedUrl function will result in an Access Denied error on the output Cloudfront URL:

const disposition = 'attachment;filename="test.txt"';

const signedUrl = getSignedUrl({
	keyPairId: config.cloudfrontKeyPairId,
	privateKey: config.cloudfrontSigningKey,
	url: `https://d1x2y3z4.cloudfront.net/test.txt?response-content-disposition=${encodeURIComponent(
		contentDisposition
	)}"`,
	dateLessThan: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
});

console.log(signedUrl);
// https://d1x2y3z4.cloudfront.net/test.txt?response-content-disposition=attachment;filename=newFilename.txt&Policy=XXXXXX&Key-Pair-Id=YYYYYY&Signature=ZZZZZZ

The above code will result in an Access Denied error when the URL is accessed:

<Error>
  <Code>AccessDenied</Code>
  <Message>Access denied</Message>
</Error>

Evidently, the issue happens when the encoded URL (using encodeURIComponent) is reverted by the getSignedUrl function.

From the source, it seems that the input url parameter is fed into the @aws-sdk/url-parser package, which is presumably where the encoded URL is being lost in translation.

import { parseUrl } from "@aws-sdk/url-parser";

...

/**
 * Creates a signed URL string using a canned or custom policy.
 * @returns the input URL with signature attached as query parameters.
 */
export function getSignedUrl({
  dateLessThan,
  dateGreaterThan,
  url,
  keyPairId,
  privateKey,
  ipAddress,
  policy,
}: CloudfrontSignInput): string {
  const parsedUrl = parseUrl(url);
	...
}

The Workaround

The workaround is to manually set the query parameter after parsing the signed URL:

const disposition = `attachment;filename=newFilename.txt;`;

const url = new URL(`test.txt`, `https://d1x2y3z4.cloudfront.net`);
url.searchParams.append('response-content-disposition', disposition);

const signedUrl = getSignedUrl({
	url: url.href,
	keyPairId: config.cloudfrontKeyPairId,
	privateKey: config.cloudfrontSigningKey,
	dateLessThan: expires.toISOString(),
	dateLessThan: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
});

const fixedSignedUrl = new URL(signedUrl);
fixedSignedUrl.searchParams.set('response-content-disposition', disposition);
return fixedSignedUrl.href;

Which works out just fine! πŸŽ‰

From the comments on this GitHub issue, a β€œfix” doesn’t seem to be in the works. I ended up losing many a few hours trying to figure out what was going on. Hoping that this helps out someone else.