Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: [DX-3334] Add the Subresource Integrity attribute to Checkout files #2314

Merged
merged 11 commits into from
Oct 17, 2024
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"
}
}