From 43d0b6dcf913f4efb2da937233b76a5d8d0fa098 Mon Sep 17 00:00:00 2001 From: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Date: Tue, 22 Jul 2025 00:02:15 +0000 Subject: [PATCH 1/3] chore(cloudfront-signer): add newlines --- packages/cloudfront-signer/src/sign.spec.ts | 25 +++++++++++++++++++++ packages/cloudfront-signer/src/sign.ts | 13 +++++++++++ 2 files changed, 38 insertions(+) diff --git a/packages/cloudfront-signer/src/sign.spec.ts b/packages/cloudfront-signer/src/sign.spec.ts index 98c6463ff3ec..c3450eace7e0 100644 --- a/packages/cloudfront-signer/src/sign.spec.ts +++ b/packages/cloudfront-signer/src/sign.spec.ts @@ -46,17 +46,21 @@ function createSignature(data: string): string { signer.update(data); return normalizeBase64(signer.sign(privateKey, "base64")); } + function verifySignature(signature: string, data: string): boolean { const verifier = createVerify("RSA-SHA1"); verifier.update(data); return verifier.verify(privateKey, signature, "base64"); } + function encodeToBase64(str: string): string { return normalizeBase64(Buffer.from(str).toString("base64")); } + function normalizeBase64(str: string): string { return str.replace(/\+/g, "-").replace(/=/g, "_").replace(/\//g, "~"); } + function denormalizeBase64(str: string): string { return str.replace(/\-/g, "+").replace(/_/g, "=").replace(/~/g, "/"); } @@ -78,6 +82,7 @@ describe("getSignedUrl", () => { } expect(result.query["foo"]).toBe("bar &=; baz"); }); + it("should include url path in policy of signed URL", () => { const url = "https://example.com/private.jpeg?foo=bar"; const result = parseUrl( @@ -108,6 +113,7 @@ describe("getSignedUrl", () => { }); expect(verifySignature(signatureQueryParam, policyStr)).toBeTruthy(); }); + it("should sign a URL with a canned policy", () => { const result = getSignedUrl({ url, @@ -135,6 +141,7 @@ describe("getSignedUrl", () => { const signatureQueryParam = denormalizeBase64(parsedUrl.query!["Signature"] as string); expect(verifySignature(signatureQueryParam, policyStr)).toBeTruthy(); }); + it("should sign a URL with a custom policy containing a start date", () => { const result = getSignedUrl({ url, @@ -166,6 +173,7 @@ describe("getSignedUrl", () => { const signatureQueryParam = denormalizeBase64(parsedUrl.query!["Signature"] as string); expect(verifySignature(signatureQueryParam, policyStr)).toBeTruthy(); }); + it("should sign a URL with a custom policy containing an ip address", () => { const result = getSignedUrl({ url, @@ -197,6 +205,7 @@ describe("getSignedUrl", () => { const signatureQueryParam = denormalizeBase64(parsedUrl.query!["Signature"] as string); expect(verifySignature(signatureQueryParam, policyStr)).toBeTruthy(); }); + it("should sign a URL with a custom policy containing a start date and ip address", () => { const result = getSignedUrl({ url, @@ -232,6 +241,7 @@ describe("getSignedUrl", () => { const signatureQueryParam = denormalizeBase64(parsedUrl.query!["Signature"] as string); expect(verifySignature(signatureQueryParam, policyStr)).toBeTruthy(); }); + it("should allow an ip address with and without a mask", () => { const baseArgs = { url, @@ -253,6 +263,7 @@ describe("getSignedUrl", () => { }) ).toBeTruthy(); }); + it("should throw an error when the ip address is invalid", () => { const baseArgs = { url, @@ -298,6 +309,7 @@ describe("getSignedUrl", () => { }) ).toThrow('IP address "10.0.0.256" is invalid due to invalid IP octets.'); }); + it("should sign a RTMP URL", () => { const url = "rtmp://d111111abcdef8.cloudfront.net/private-content/private.jpeg"; const result = getSignedUrl({ @@ -325,6 +337,7 @@ describe("getSignedUrl", () => { ); expect(verifySignature(denormalizeBase64(signature), policyStr)).toBeTruthy(); }); + it("should sign a URL with a policy provided by the user", () => { const policy = '{"foo":"bar"}'; const result = getSignedUrl({ @@ -339,6 +352,7 @@ describe("getSignedUrl", () => { const signatureQueryParam = denormalizeBase64(signature); expect(verifySignature(signatureQueryParam, policy)).toBeTruthy(); }); + it("should sign a URL automatically extracted from a policy provided by the user", () => { const policy = JSON.stringify({ Statement: [{ Resource: url }] }); const result = getSignedUrl({ @@ -376,6 +390,7 @@ describe("getSignedCookies", () => { }) ).toBeTruthy(); }); + it("should throw an error when the ip address is invalid", () => { const baseArgs = { url, @@ -421,6 +436,7 @@ describe("getSignedCookies", () => { }) ).toThrow('IP address "10.0.0.256" is invalid due to invalid IP octets.'); }); + it("should be able sign cookies that contain a URL with wildcards", () => { const url = "https://example.com/private-content/*"; const result = getSignedCookies({ @@ -444,6 +460,7 @@ describe("getSignedCookies", () => { }); expect(verifySignature(denormalizeBase64(result["CloudFront-Signature"]), policyStr)).toBeTruthy(); }); + it("should sign cookies with a canned policy", () => { const result = getSignedCookies({ url, @@ -475,6 +492,7 @@ describe("getSignedCookies", () => { expect(result["CloudFront-Signature"]).toBe(expected["CloudFront-Signature"]); expect(verifySignature(denormalizeBase64(result["CloudFront-Signature"]), policyStr)).toBeTruthy(); }); + it("should sign cookies with a custom policy containing a start date", () => { const result = getSignedCookies({ url, @@ -510,6 +528,7 @@ describe("getSignedCookies", () => { expect(result["CloudFront-Signature"]).toBe(expected["CloudFront-Signature"]); expect(verifySignature(denormalizeBase64(result["CloudFront-Signature"]), policyStr)).toBeTruthy(); }); + it("should sign cookies with a custom policy containing an ip address", () => { const result = getSignedCookies({ url, @@ -545,6 +564,7 @@ describe("getSignedCookies", () => { expect(result["CloudFront-Signature"]).toBe(expected["CloudFront-Signature"]); expect(verifySignature(denormalizeBase64(result["CloudFront-Signature"]), policyStr)).toBeTruthy(); }); + it("should sign cookies with a custom policy containing a start date and ip address", () => { const result = getSignedCookies({ url, @@ -584,6 +604,7 @@ describe("getSignedCookies", () => { expect(result["CloudFront-Signature"]).toBe(expected["CloudFront-Signature"]); expect(verifySignature(denormalizeBase64(result["CloudFront-Signature"]), policyStr)).toBeTruthy(); }); + it("should sign cookies with a policy provided by the user without a url", () => { const policy = '{"foo":"bar"}'; const result = getSignedCookies({ @@ -612,6 +633,7 @@ describe("getSignedUrl- when signing a URL with a date range", () => { const dateGreaterThanNumber = 1716034245000; const dateObject = new Date(dateString); const dateGreaterThanObject = new Date(dateGreaterThanString); + it("allows string input compatible with Date constructor", () => { const epochDateLessThan = Math.round(new Date(dateString).getTime() / 1000); const resultUrl = getSignedUrl({ @@ -653,6 +675,7 @@ describe("getSignedUrl- when signing a URL with a date range", () => { expect(resultUrl).toContain(`Expires=${epochDateLessThan}`); expect(resultCookies["CloudFront-Expires"]).toBe(epochDateLessThan); }); + it("allows Date object input", () => { const epochDateLessThan = Math.round(dateObject.getTime() / 1000); const resultUrl = getSignedUrl({ @@ -673,6 +696,7 @@ describe("getSignedUrl- when signing a URL with a date range", () => { expect(resultUrl).toContain(`Expires=${epochDateLessThan}`); expect(resultCookies["CloudFront-Expires"]).toBe(epochDateLessThan); }); + it("allows string input for date range", () => { const result = getSignedUrl({ url, @@ -736,6 +760,7 @@ describe("getSignedUrl- when signing a URL with a date range", () => { const signatureQueryParam = denormalizeBase64(parsedUrl.query!["Signature"] as string); expect(verifySignature(signatureQueryParam, policyStr)).toBeTruthy(); }); + it("allows Date object input for date range", () => { const result = getSignedUrl({ url, diff --git a/packages/cloudfront-signer/src/sign.ts b/packages/cloudfront-signer/src/sign.ts index a61fd34ea7c3..53cbd96ef6bd 100644 --- a/packages/cloudfront-signer/src/sign.ts +++ b/packages/cloudfront-signer/src/sign.ts @@ -12,8 +12,10 @@ export type CloudfrontSignInput = CloudfrontSignInputWithParameters | Cloudfront export type CloudfrontSignerCredentials = { /** The ID of the Cloudfront key pair. */ keyPairId: string; + /** The content of the Cloudfront private key. */ privateKey: string | Buffer; + /** The passphrase of RSA-SHA1 key*/ passphrase?: string; }; @@ -24,12 +26,16 @@ export type CloudfrontSignerCredentials = { export type CloudfrontSignInputWithParameters = CloudfrontSignerCredentials & { /** The URL string to sign. */ url: string; + /** The date string for when the signed URL or cookie can no longer be accessed */ dateLessThan: string | number | Date; + /** The date string for when the signed URL or cookie can start to be accessed. */ dateGreaterThan?: string | number | Date; + /** The IP address string to restrict signed URL access to. */ ipAddress?: string; + /** * [policy] should not be provided when using separate * dateLessThan, dateGreaterThan, or ipAddress inputs. @@ -50,12 +56,16 @@ export type CloudfrontSignInputWithPolicy = CloudfrontSignerCredentials & { * This will be ignored if calling getSignedCookies with a policy. */ url?: string; + /** The JSON-encoded policy string */ policy: string; + /** When using a policy, a separate dateLessThan should not be provided. */ dateLessThan?: never; + /** When using a policy, a separate dateGreaterThan should not be provided. */ dateGreaterThan?: never; + /** When using a policy, a separate ipAddress should not be provided. */ ipAddress?: never; }; @@ -66,10 +76,13 @@ export type CloudfrontSignInputWithPolicy = CloudfrontSignerCredentials & { export interface CloudfrontSignedCookiesOutput { /** ID of the Cloudfront key pair. */ "CloudFront-Key-Pair-Id": string; + /** Hashed, signed, and base64-encoded version of the JSON policy. */ "CloudFront-Signature": string; + /** The unix date time for when the signed URL or cookie can no longer be accessed. */ "CloudFront-Expires"?: number; + /** Base64-encoded version of the JSON policy. */ "CloudFront-Policy"?: string; } From e227ea729197037b0a4b5e2029d46f9aa0897df6 Mon Sep 17 00:00:00 2001 From: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Date: Tue, 22 Jul 2025 00:02:48 +0000 Subject: [PATCH 2/3] test(cloudfront-signer): should not normalize the URL --- packages/cloudfront-signer/src/sign.spec.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/cloudfront-signer/src/sign.spec.ts b/packages/cloudfront-signer/src/sign.spec.ts index c3450eace7e0..3327827bdd74 100644 --- a/packages/cloudfront-signer/src/sign.spec.ts +++ b/packages/cloudfront-signer/src/sign.spec.ts @@ -366,6 +366,23 @@ describe("getSignedUrl", () => { const signatureQueryParam = denormalizeBase64(signature); expect(verifySignature(signatureQueryParam, policy)).toBeTruthy(); }); + + describe("should not normalize the URL", () => { + it.each([".", ".."])("with '%s'", (folderName) => { + const urlWithFolderName = `https://d111111abcdef8.cloudfront.net/public-content/${folderName}/private-content/private.jpeg`; + const policy = JSON.stringify({ Statement: [{ Resource: urlWithFolderName }] }); + const result = getSignedUrl({ + keyPairId, + privateKey, + policy, + passphrase, + }); + const signature = createSignature(policy); + expect(result.startsWith(urlWithFolderName)).toBeTruthy(); + const signatureQueryParam = denormalizeBase64(signature); + expect(verifySignature(signatureQueryParam, policy)).toBeTruthy(); + }); + }); }); describe("getSignedCookies", () => { From 55025878033de574e7dfd4a022d0251b5fe5cb12 Mon Sep 17 00:00:00 2001 From: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Date: Wed, 30 Jul 2025 19:25:08 +0000 Subject: [PATCH 3/3] chore: attempt to override url pathname --- .../src/getPathnameWithDots.spec.ts | 24 +++++++++++++++++++ .../src/getPathnameWithDots.ts | 20 ++++++++++++++++ packages/cloudfront-signer/src/sign.spec.ts | 9 ++----- packages/cloudfront-signer/src/sign.ts | 3 +++ 4 files changed, 49 insertions(+), 7 deletions(-) create mode 100644 packages/cloudfront-signer/src/getPathnameWithDots.spec.ts create mode 100644 packages/cloudfront-signer/src/getPathnameWithDots.ts diff --git a/packages/cloudfront-signer/src/getPathnameWithDots.spec.ts b/packages/cloudfront-signer/src/getPathnameWithDots.spec.ts new file mode 100644 index 000000000000..0aab8a206100 --- /dev/null +++ b/packages/cloudfront-signer/src/getPathnameWithDots.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { getPathnameWithDots } from "./getPathnameWithDots"; + +describe("getPathnameWithDots", () => { + const origin = "http://d1234.cloudfront.net"; + + it("should return if there's no pathname", () => { + expect(getPathnameWithDots(origin)).toBe("/"); + expect(getPathnameWithDots(`${origin}/`)).toBe("/"); + }); + + it("should return if there's no '/./' or '/../' in pathname", () => { + const expectedPathname = `/foo/bar/index.html`; + expect(getPathnameWithDots(`${origin}${expectedPathname}`)).toBe(expectedPathname); + }); + + it.each(["/./", "/../"])("should include '%s' in pathname if it exists", (folderName) => { + const expectedPathname = `/foo${folderName}bar/index.html`; + expect(getPathnameWithDots(`${origin}${expectedPathname}`)).toBe(expectedPathname); + expect(getPathnameWithDots(`${origin}${expectedPathname}?foo=bar`)).toBe(expectedPathname); + expect(getPathnameWithDots(`${origin}${expectedPathname}#foo=bar`)).toBe(expectedPathname); + }); +}); diff --git a/packages/cloudfront-signer/src/getPathnameWithDots.ts b/packages/cloudfront-signer/src/getPathnameWithDots.ts new file mode 100644 index 000000000000..e25885200592 --- /dev/null +++ b/packages/cloudfront-signer/src/getPathnameWithDots.ts @@ -0,0 +1,20 @@ +/** + * Returns pathname with `/./` and `/../` allowed by S3 Objects + * + * @param urlString string provided for generating the URL. + */ +export const getPathnameWithDots = (urlString: string) => { + const url = new URL(urlString); + + if (url.pathname === "/") return url.pathname; + + if (!urlString.includes("/./") && !urlString.includes("/../")) return url.pathname; + + const startIndex = url.origin.length; + const endIndex = urlString.includes("?") + ? urlString.indexOf("?") + : urlString.includes("#") + ? urlString.indexOf("#") + : urlString.length; + return urlString.substring(startIndex, endIndex); +}; diff --git a/packages/cloudfront-signer/src/sign.spec.ts b/packages/cloudfront-signer/src/sign.spec.ts index 3327827bdd74..2d7bdbfe44fa 100644 --- a/packages/cloudfront-signer/src/sign.spec.ts +++ b/packages/cloudfront-signer/src/sign.spec.ts @@ -369,14 +369,9 @@ describe("getSignedUrl", () => { describe("should not normalize the URL", () => { it.each([".", ".."])("with '%s'", (folderName) => { - const urlWithFolderName = `https://d111111abcdef8.cloudfront.net/public-content/${folderName}/private-content/private.jpeg`; + const urlWithFolderName = `https://d1234.cloudfront.net/public-content/${folderName}/private-content/private.jpeg`; const policy = JSON.stringify({ Statement: [{ Resource: urlWithFolderName }] }); - const result = getSignedUrl({ - keyPairId, - privateKey, - policy, - passphrase, - }); + const result = getSignedUrl({ keyPairId, privateKey, policy, passphrase }); const signature = createSignature(policy); expect(result.startsWith(urlWithFolderName)).toBeTruthy(); const signatureQueryParam = denormalizeBase64(signature); diff --git a/packages/cloudfront-signer/src/sign.ts b/packages/cloudfront-signer/src/sign.ts index 53cbd96ef6bd..9ce3386f6871 100644 --- a/packages/cloudfront-signer/src/sign.ts +++ b/packages/cloudfront-signer/src/sign.ts @@ -1,5 +1,7 @@ import { createSign } from "crypto"; +import { getPathnameWithDots } from "./getPathnameWithDots"; + /** * Input type to getSignedUrl and getSignedCookies. * @public @@ -137,6 +139,7 @@ export function getSignedUrl({ } const newURL = new URL(baseUrl!); + newURL.pathname = getPathnameWithDots(baseUrl!); newURL.search = Array.from(newURL.searchParams.entries()) .concat(Object.entries(cloudfrontSignBuilder.createCloudfrontAttribute())) .filter(([, value]) => value !== undefined)