Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions apps/wallet/build/plugins/with-ic-assets.plugin.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join, resolve } from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { withIcAssetsFile } from './with-ic-assets.plugin';

type IcAssetsRule = { match: string; headers?: Record<string, string> };

const CANISTER_IDS_PATH = resolve(__dirname, '../../../../canister_ids.json');

const INDEX_HTML = '<script>window.inline = true;</script><script src="/main.js"></script>';
// sha256 of the inline script above, base64 encoded.
const INLINE_SCRIPT_HASH = "'sha256-5n2DSRDGaWy2SggN68iONM84vDSa6opmut5FktFmjLE='";

const runWriteBundle = (dir: string): void => {
const plugin = withIcAssetsFile({ isProduction: true });
const writeBundle = plugin.writeBundle as unknown as (options: { dir: string }) => void;

writeBundle.call({}, { dir });
};

const readHeadersForAllAssets = (dir: string): Record<string, string> => {
const rules: IcAssetsRule[] = JSON.parse(readFileSync(join(dir, '.ic-assets.json'), 'utf-8'));

return rules.find(rule => rule.match === '**/*')?.headers ?? {};
};

describe('with-ic-assets plugin', () => {
let outDir: string;

beforeEach(() => {
outDir = mkdtempSync(join(tmpdir(), 'orbit-wallet-dist-'));
writeFileSync(join(outDir, 'index.html'), INDEX_HTML);
});

afterEach(() => {
rmSync(outDir, { recursive: true, force: true });
});

it('writes .ic-assets.json into the build output', () => {
runWriteBundle(outDir);

expect(existsSync(join(outDir, '.ic-assets.json'))).toBe(true);
});

it('sets the security headers on every asset', () => {
runWriteBundle(outDir);
const headers = readHeadersForAllAssets(outDir);

expect(headers['X-Frame-Options']).toBe('DENY');
expect(headers['X-Content-Type-Options']).toBe('nosniff');
expect(headers['Content-Security-Policy']).toContain("default-src 'none'");
expect(headers['Content-Security-Policy']).toContain("frame-ancestors 'none'");
});

it('allows the inline scripts of index.html by their hash', () => {
runWriteBundle(outDir);

expect(readHeadersForAllAssets(outDir)['Content-Security-Policy']).toContain(
INLINE_SCRIPT_HASH,
);
});

it('allows downloads from every wasm chunk store in canister_ids.json', () => {
runWriteBundle(outDir);
const csp = readHeadersForAllAssets(outDir)['Content-Security-Policy'] ?? '';
const connectSrc = csp.split('; ').find(directive => directive.startsWith('connect-src '));
const canisterIds = JSON.parse(readFileSync(CANISTER_IDS_PATH, 'utf-8'));
const stores = Object.values(canisterIds.wasm_chunk_store ?? {}) as string[];

expect(stores.length).toBeGreaterThan(0);
for (const store of stores) {
expect(connectSrc?.split(' ')).toContain(`https://${store}.icp0.io`);
}
});
});
16 changes: 13 additions & 3 deletions apps/wallet/build/plugins/with-ic-assets.plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import { Plugin } from 'vite';
import { load } from 'cheerio';
import { createHash } from 'node:crypto';

// The wasm chunk stores of the control-panel registry, `wasm_chunk_store` in canister_ids.json.
// Disaster recovery downloads registry modules from their HTTP interface.
const WASM_CHUNK_STORE_ORIGINS = [
'https://f2mu6-kiaaa-aaaam-advhq-cai.icp0.io', // production
'https://lwcm6-3aaaa-aaaam-qb7ba-cai.icp0.io', // playground
];

const getContentSecurityPolicy = (
isProduction: boolean,
dynamicCspHeaders: Record<string, string[]> = {},
Expand All @@ -18,6 +25,7 @@ const getContentSecurityPolicy = (
'https://ic0.app',
'https://icp0.io',
'https://3r4gx-wqaaa-aaaaq-aaaia-cai.icp0.io', // SNS aggregator
...WASM_CHUNK_STORE_ORIGINS,
],
'img-src': ["'self'", 'data:'],
'font-src': ["'self'"],
Expand Down Expand Up @@ -96,16 +104,18 @@ const createICAssetsJson = (
};

export const withIcAssetsFile = (
opts: { isProduction?: boolean; publicDir?: string; fileName?: string } = {},
opts: { isProduction?: boolean; fileName?: string } = {},
): Plugin => {
const isProduction = opts.isProduction ?? true;
const publicDir = opts.publicDir ?? 'public';
const fileName = opts.fileName ?? '.ic-assets.json';

return {
name: 'with-ic-assets',
writeBundle({ dir }) {
const icAssetsPath = resolve(__dirname, '../..', publicDir, fileName);
// Written into the build output, so that the release artifact carries the headers. Release
// deploys upload that artifact as is. Vite copies public/ before this hook runs, so a file
// written there would only reach the output of a later build, and never a clean one.
const icAssetsPath = resolve(dir!, fileName);
Comment thread
Copilot marked this conversation as resolved.
Resolved
const indexHtml = resolve(dir!, 'index.html');
const indexContent = readFileSync(indexHtml, { encoding: 'utf-8' });
const virtualDOM = load(indexContent);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,10 @@ export const withVersionedEntrypoint = (
encoding: 'utf-8',
});

// copy everything from the public directory to the versioned directory
copyFolderRecursiveSync(publicDir, versionDir, [/.DS_Store/]);
// copy everything from the public directory to the versioned directory, except a leftover
// .ic-assets.json from older builds: nested under the versioned directory it would override
// the one with-ic-assets writes at the root of the output.
copyFolderRecursiveSync(publicDir, versionDir, [/.DS_Store/, /\.ic-assets\.json5?$/]);
},
configureServer(server) {
server.middlewares.use((req, res, next) => {
Expand Down
Loading