diff --git a/apps/wallet/build/plugins/with-ic-assets.plugin.spec.ts b/apps/wallet/build/plugins/with-ic-assets.plugin.spec.ts new file mode 100644 index 000000000..723021864 --- /dev/null +++ b/apps/wallet/build/plugins/with-ic-assets.plugin.spec.ts @@ -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 }; + +const CANISTER_IDS_PATH = resolve(__dirname, '../../../../canister_ids.json'); + +const INDEX_HTML = ''; +// 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 => { + 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`); + } + }); +}); diff --git a/apps/wallet/build/plugins/with-ic-assets.plugin.ts b/apps/wallet/build/plugins/with-ic-assets.plugin.ts index f2b54e188..75fb09c07 100644 --- a/apps/wallet/build/plugins/with-ic-assets.plugin.ts +++ b/apps/wallet/build/plugins/with-ic-assets.plugin.ts @@ -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 = {}, @@ -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'"], @@ -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); const indexHtml = resolve(dir!, 'index.html'); const indexContent = readFileSync(indexHtml, { encoding: 'utf-8' }); const virtualDOM = load(indexContent); diff --git a/apps/wallet/build/plugins/with-versioned-entrypoint.plugin.ts b/apps/wallet/build/plugins/with-versioned-entrypoint.plugin.ts index a0559285e..2dd7f0bb7 100644 --- a/apps/wallet/build/plugins/with-versioned-entrypoint.plugin.ts +++ b/apps/wallet/build/plugins/with-versioned-entrypoint.plugin.ts @@ -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) => {