Skip to content

Commit

Permalink
chore: [DX-3334] Add the Subresource Integrity attribute to Checkout …
Browse files Browse the repository at this point in the history
…files (#2314)

Co-authored-by: Ubuntu <runner@runner.z3atkystfzyuthwl1ys0jzl12d.cx.internal.cloudapp.net>
  • Loading branch information
zaidarain1 and Ubuntu authored Oct 17, 2024
1 parent c84a52e commit aa44226
Show file tree
Hide file tree
Showing 10 changed files with 137 additions and 18 deletions.
12 changes: 12 additions & 0 deletions .github/workflows/pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/checkout/sdk/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions packages/checkout/sdk/src/widgets/hashUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/* eslint-disable max-len */
export async function generateSHA512Hash(url: string): Promise<string> {
// 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<string> {
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<string> {
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`;
}
36 changes: 28 additions & 8 deletions packages/checkout/sdk/src/widgets/load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,55 @@
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(() => {
jest.spyOn(console, 'warn').mockImplementation(() => { });
});

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(
'<script id="immutable-checkout-widgets-bundle" '
'<script '
+ 'integrity="sha512-abc123" '
+ 'crossorigin="anonymous" '
+ 'id="immutable-checkout-widgets-bundle" '
+ 'data-version="__SDK_VERSION__" '
+ `src="https://cdn.jsdelivr.net/npm/@imtbl/sdk@${SDK_VERSION}/dist/browser/checkout/widgets.js"></script>`,
);
});
});

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/[email protected]/dist/browser/checkout/widgets-esm.js',
);
});
Expand Down
34 changes: 29 additions & 5 deletions packages/checkout/sdk/src/widgets/load.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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);
Expand All @@ -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<Promise<string>> {
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;
}
2 changes: 1 addition & 1 deletion packages/checkout/widgets-lib/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/checkout/widgets-lib/hashes.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"dist/widgets.js": "sha512-WMrmg+Xs0GRr3rQ7dOxLMHXw1Co+WH9+LHzhVcV1mh7MIgacX5jmFtWM0DbXszQGBj6KlOe+3W5HiyPvGylGiQ==",
"dist/index.js": "sha512-dQi34NMUtZhRBuxK3a36z3abkfbfX6+BWvmvILVMRzTQRdxq1w+X+DvkNdiFfRyAAAuAvCDGhk1XETGfE0cKKQ=="
}
5 changes: 3 additions & 2 deletions packages/checkout/widgets-lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
24 changes: 24 additions & 0 deletions packages/checkout/widgets-lib/updateHashes.js
Original file line number Diff line number Diff line change
@@ -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))
2 changes: 1 addition & 1 deletion sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,4 @@
},
"type": "module",
"types": "./dist/index.d.ts"
}
}

0 comments on commit aa44226

Please sign in to comment.