From aa442266463a6974b61a7d05541bb00cf73dfc2d Mon Sep 17 00:00:00 2001 From: zaidarain1 Date: Thu, 17 Oct 2024 16:57:01 +1100 Subject: [PATCH] chore: [DX-3334] Add the Subresource Integrity attribute to Checkout files (#2314) Co-authored-by: Ubuntu --- .github/workflows/pr.yaml | 12 +++++++ packages/checkout/sdk/src/sdk.ts | 2 +- .../checkout/sdk/src/widgets/hashUtils.ts | 34 ++++++++++++++++++ .../checkout/sdk/src/widgets/load.test.ts | 36 ++++++++++++++----- packages/checkout/sdk/src/widgets/load.ts | 34 +++++++++++++++--- packages/checkout/widgets-lib/.eslintrc.cjs | 2 +- packages/checkout/widgets-lib/hashes.json | 4 +++ packages/checkout/widgets-lib/package.json | 5 +-- packages/checkout/widgets-lib/updateHashes.js | 24 +++++++++++++ sdk/package.json | 2 +- 10 files changed, 137 insertions(+), 18 deletions(-) create mode 100644 packages/checkout/sdk/src/widgets/hashUtils.ts create mode 100644 packages/checkout/widgets-lib/hashes.json create mode 100755 packages/checkout/widgets-lib/updateHashes.js diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 31d4d55a3e..9283546fdf 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -44,6 +44,18 @@ jobs: - name: Build, Lint, Test & Typecheck run: yarn nx affected -t build,lint,test,typecheck + - name: Validate Checkout Widgets Hashes + run: | + cd packages/checkout/widgets-lib + mv hashes.json hashes.json.old + yarn updateHashes + if [ -n "$(git diff --exit-code hashes.json)" ]; then + echo "Hashes.json has changed. Please update the hashes.json file and commit the changes." + echo "You can use the following command in the root of the repo to update the hashes.json file:" + echo "yarn workspace @imtbl/checkout-widgets updateHashes" + exit 1 + fi + build-lint-test-examples: name: Build, Lint & Test Examples runs-on: ubuntu-latest-8-cores diff --git a/packages/checkout/sdk/src/sdk.ts b/packages/checkout/sdk/src/sdk.ts index 212c4db457..6f3c379050 100644 --- a/packages/checkout/sdk/src/sdk.ts +++ b/packages/checkout/sdk/src/sdk.ts @@ -247,7 +247,7 @@ export class Checkout { ) { const checkout = this; try { - const cdnUrl = getWidgetsEsmUrl(validVersion); + const cdnUrl = await getWidgetsEsmUrl(validVersion); // WebpackIgnore comment required to prevent webpack modifying the import statement and // breaking the dynamic import in certain applications integrating checkout diff --git a/packages/checkout/sdk/src/widgets/hashUtils.ts b/packages/checkout/sdk/src/widgets/hashUtils.ts new file mode 100644 index 0000000000..f54223ff0c --- /dev/null +++ b/packages/checkout/sdk/src/widgets/hashUtils.ts @@ -0,0 +1,34 @@ +/* eslint-disable max-len */ +export async function generateSHA512Hash(url: string): Promise { + // Fetch the content of the remote JavaScript file + const response = await fetch(url); + const content = await response.text(); + + // Convert the content to an ArrayBuffer + const encoder = new TextEncoder(); + const data = encoder.encode(content); + + // Use the Browser WebCrypto SubtleCrypto API to generate a SHA-512 hash + const hashBuffer = await window.crypto.subtle.digest('SHA-512', data); + + // Convert the hash to a Base64 string + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashBase64 = btoa(String.fromCharCode(...hashArray)); + + return `sha512-${hashBase64}`; +} + +async function getLatestGitTag(): Promise { + const response = await fetch('https://api.github.com/repos/immutable/ts-immutable-sdk/tags'); + const tags = await response.json(); + return tags[0].name; +} + +export async function validatedHashesUrl(version: string): Promise { + if (version !== 'latest') { + return `https://raw.githubusercontent.com/immutable/ts-immutable-sdk/refs/tags/${version}/packages/checkout/widgets-lib/hashes.json`; + } + + const latestGitTag = await getLatestGitTag(); + return `https://raw.githubusercontent.com/immutable/ts-immutable-sdk/refs/tags/${latestGitTag}/packages/checkout/widgets-lib/hashes.json`; +} diff --git a/packages/checkout/sdk/src/widgets/load.test.ts b/packages/checkout/sdk/src/widgets/load.test.ts index afcaede08c..da7e70ad4e 100644 --- a/packages/checkout/sdk/src/widgets/load.test.ts +++ b/packages/checkout/sdk/src/widgets/load.test.ts @@ -2,8 +2,15 @@ import { SDK_VERSION_MARKER } from '../env'; import { getWidgetsEsmUrl, loadUnresolvedBundle } from './load'; +const SDK_VERSION = SDK_VERSION_MARKER; + +jest.mock('./hashUtils', () => ({ + generateSHA512Hash: jest.fn(async () => 'sha512-abc123'), + // eslint-disable-next-line max-len + validatedHashesUrl: jest.fn(async () => `https://raw.githubusercontent.com/immutable/ts-immutable-sdk/refs/tags/${SDK_VERSION}/packages/checkout/widgets-lib/hashes.json`), +})); + describe('load', () => { - const SDK_VERSION = SDK_VERSION_MARKER; const scriptId = 'immutable-checkout-widgets-bundle'; beforeEach(() => { @@ -11,11 +18,15 @@ describe('load', () => { }); describe('load unresolved bundle', () => { - it('should validate the versioning', () => { + it('should validate the versioning', async () => { const tag = document.createElement('script'); - loadUnresolvedBundle(tag, scriptId, SDK_VERSION); + await loadUnresolvedBundle(tag, scriptId, SDK_VERSION); + expect(document.head.innerHTML).toBe( - '`, ); @@ -23,14 +34,23 @@ describe('load', () => { }); describe('get widgets esm url', () => { - it('should validate the versioning', () => { - expect(getWidgetsEsmUrl(SDK_VERSION)).toEqual( + beforeEach(() => { + // @ts-expect-error mocking only json value of fetch response + global.fetch = jest.fn(async () => ({ + json: async () => ({ 'dist/index.js': 'sha512-abc123' }), + })); + }); + + it('should validate the versioning', async () => { + const widgetsEsmUrl = await getWidgetsEsmUrl(SDK_VERSION); + expect(widgetsEsmUrl).toEqual( `https://cdn.jsdelivr.net/npm/@imtbl/sdk@${SDK_VERSION}/dist/browser/checkout/widgets-esm.js`, ); }); - it('should change version', () => { - expect(getWidgetsEsmUrl('1.2.3')).toEqual( + it('should change version', async () => { + const widgetsEsmUrl = await getWidgetsEsmUrl('1.2.3'); + expect(widgetsEsmUrl).toEqual( 'https://cdn.jsdelivr.net/npm/@imtbl/sdk@1.2.3/dist/browser/checkout/widgets-esm.js', ); }); diff --git a/packages/checkout/sdk/src/widgets/load.ts b/packages/checkout/sdk/src/widgets/load.ts index 328a07ef5f..b008dad878 100644 --- a/packages/checkout/sdk/src/widgets/load.ts +++ b/packages/checkout/sdk/src/widgets/load.ts @@ -1,7 +1,8 @@ import { useLocalBundle } from '../env'; +import { generateSHA512Hash, validatedHashesUrl } from './hashUtils'; // Loads the checkout widgets bundle from the CDN and appends the script to the document head -export function loadUnresolvedBundle( +export async function loadUnresolvedBundle( tag: HTMLScriptElement, scriptId: string, validVersion: string, @@ -17,6 +18,12 @@ export function loadUnresolvedBundle( let cdnUrl = `https://cdn.jsdelivr.net/npm/@imtbl/sdk@${validVersion}/dist/browser/checkout/widgets.js`; if (useLocalBundle()) cdnUrl = `http://${window.location.host}/lib/js/widgets.js`; + if (!useLocalBundle()) { + const integrityHash = await generateSHA512Hash(cdnUrl); + tag.setAttribute('integrity', integrityHash); + tag.setAttribute('crossorigin', 'anonymous'); + } + tag.setAttribute('id', scriptId); tag.setAttribute('data-version', validVersion); tag.setAttribute('src', cdnUrl); @@ -25,10 +32,27 @@ export function loadUnresolvedBundle( } // Gets the CDN url for the split checkout widgets bundle -export function getWidgetsEsmUrl( +export async function getWidgetsEsmUrl( validVersion: string, -): string { - let cdnUrl = `https://cdn.jsdelivr.net/npm/@imtbl/sdk@${validVersion}/dist/browser/checkout/widgets-esm.js`; - if (useLocalBundle()) cdnUrl = `http://${window.location.host}/lib/js/index.js`; +): Promise> { + if (useLocalBundle()) return `http://${window.location.host}/lib/js/index.js`; + + const cdnUrl = `https://cdn.jsdelivr.net/npm/@imtbl/sdk@${validVersion}/dist/browser/checkout/widgets-esm.js`; + + const validHashesUrl = await validatedHashesUrl(validVersion); + + const hash = await generateSHA512Hash(cdnUrl); + + const widgetsEsmHash: string = await fetch(validHashesUrl) + .then((response) => response.json()) + .then((hashes) => hashes['dist/index.js']) + .catch(() => { + throw new Error('Security Error: could not fetch widgets-esm.js hash'); + }); + + if (hash !== widgetsEsmHash) { + throw new Error('Security Error: widgets-esm.js hash mismatch'); + } + return cdnUrl; } diff --git a/packages/checkout/widgets-lib/.eslintrc.cjs b/packages/checkout/widgets-lib/.eslintrc.cjs index 96bdd2ff19..2639456e54 100644 --- a/packages/checkout/widgets-lib/.eslintrc.cjs +++ b/packages/checkout/widgets-lib/.eslintrc.cjs @@ -1,6 +1,6 @@ module.exports = { "extends": ["../../../.eslintrc"], - "ignorePatterns": ["jest.config.*", "rollup.config.*"], + "ignorePatterns": ["jest.config.*", "rollup.config.*", "*.js"], "parser": "@typescript-eslint/parser", "parserOptions": { "project": "./tsconfig.json", diff --git a/packages/checkout/widgets-lib/hashes.json b/packages/checkout/widgets-lib/hashes.json new file mode 100644 index 0000000000..b12e1c1f19 --- /dev/null +++ b/packages/checkout/widgets-lib/hashes.json @@ -0,0 +1,4 @@ +{ + "dist/widgets.js": "sha512-WMrmg+Xs0GRr3rQ7dOxLMHXw1Co+WH9+LHzhVcV1mh7MIgacX5jmFtWM0DbXszQGBj6KlOe+3W5HiyPvGylGiQ==", + "dist/index.js": "sha512-dQi34NMUtZhRBuxK3a36z3abkfbfX6+BWvmvILVMRzTQRdxq1w+X+DvkNdiFfRyAAAuAvCDGhk1XETGfE0cKKQ==" +} \ No newline at end of file diff --git a/packages/checkout/widgets-lib/package.json b/packages/checkout/widgets-lib/package.json index 435af8d09e..be00c6d0cf 100644 --- a/packages/checkout/widgets-lib/package.json +++ b/packages/checkout/widgets-lib/package.json @@ -104,15 +104,16 @@ "build": "yarn clean && NODE_ENV=production rollup --config rollup.config.js", "build:analyse": "yarn build --plugin visualizer", "build:local": "yarn clean && yarn build && mkdir -p ../widgets-sample-app/public/lib/js && cp dist/*.js ../widgets-sample-app/public/lib/js/", - "prepare:examplewidgets": "yarn workspace @examples/sdk-load-widgets-with-nextjs exec mkdir -p tests/utils/local-widgets-js/ && cp $(yarn workspace @imtbl/sdk exec pwd)/dist/browser/checkout/*.js $(yarn workspace @examples/sdk-load-widgets-with-nextjs exec pwd)/tests/utils/local-widgets-js/", "clean": "rimraf ./dist", "d": "rollup --config rollup.config.js", "lint": "eslint ./src --ext .ts,.jsx,.tsx --max-warnings=0", "lint:fix": "eslint ./src --ext .ts,.jsx,.tsx --max-warnings=0 --fix", + "prepare:examplewidgets": "yarn workspace @examples/sdk-load-widgets-with-nextjs exec mkdir -p tests/utils/local-widgets-js/ && cp $(yarn workspace @imtbl/sdk exec pwd)/dist/browser/checkout/*.js $(yarn workspace @examples/sdk-load-widgets-with-nextjs exec pwd)/tests/utils/local-widgets-js/", "start": "yarn clean && NODE_ENV=development rollup --config rollup.config.js --watch", "test": "jest test --passWithNoTests", "test:watch": "jest test --passWithNoTests --watch", - "typecheck": "tsc --customConditions \"default\" --noEmit" + "typecheck": "tsc --customConditions \"default\" --noEmit", + "updateHashes": "yarn run --top-level nx run @imtbl/checkout-widgets:build && node ./updateHashes.js" }, "type": "module", "types": "./dist/index.d.ts" diff --git a/packages/checkout/widgets-lib/updateHashes.js b/packages/checkout/widgets-lib/updateHashes.js new file mode 100755 index 0000000000..22ca342504 --- /dev/null +++ b/packages/checkout/widgets-lib/updateHashes.js @@ -0,0 +1,24 @@ +// @ts-check +import { readFileSync, writeFileSync } from "fs" +import { createHash } from "crypto" + +const filesToHash = ["dist/widgets.js", "dist/index.js"] + +filesToHash.forEach(file => { + try { + readFileSync(file) + } catch (e) { + console.error(`File ${file} not found`) + console.error('Please build the Checkout Widgets package') + process.exit(1) + } +}) + +const hashes = filesToHash.reduce((acc, file) => { + const hash = `sha512-${createHash("sha512").update(readFileSync(file + )).digest("base64")}` + acc[file] = hash + return acc +}, {}) + +writeFileSync("hashes.json", JSON.stringify(hashes, null, 2)) \ No newline at end of file diff --git a/sdk/package.json b/sdk/package.json index 7158067697..28aa1f670d 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -178,4 +178,4 @@ }, "type": "module", "types": "./dist/index.d.ts" -} +} \ No newline at end of file