|
| 1 | +import { WebView } from 'bun' |
| 2 | +import { expect, test } from 'bun:test' |
| 3 | +import { createHash } from 'node:crypto' |
| 4 | +import { rm } from 'node:fs/promises' |
| 5 | +import { join, relative, sep } from 'node:path' |
| 6 | +import { buildComponentLibrary } from '../../src/component-library' |
| 7 | +import { createTempDir } from '../../src/temp-dir' |
| 8 | +import { createBrowserFixture } from '../../test-utils/browser-fixture' |
| 9 | + |
| 10 | +test('build-result style hashes authorize exact runtime styles under a browser hash-only CSP', async () => { |
| 11 | + const root = createTempDir('stx-library-style-csp-') |
| 12 | + let fixture: ReturnType<typeof createBrowserFixture> | undefined |
| 13 | + try { |
| 14 | + const input = join(root, 'source'), output = join(root, 'dist') |
| 15 | + await Bun.write(join(input, 'browser-leaf.ts'), 'export const title = document.documentElement.tagName') |
| 16 | + await Bun.write(join(input, 'browser.ts'), "export { title } from './browser-leaf'") |
| 17 | + const modes = [false, 'open', 'closed'] as const |
| 18 | + for (const [index, mode] of modes.entries()) { |
| 19 | + await Bun.write(join(input, `Card${index}.stx`), `<script component type="application/json">${JSON.stringify({ tag: `csp-card-${index}`, shadowDOM: mode, properties: { count: { type: 'number', default: 0 } } })}</script> |
| 20 | +<script>import { title as browserTitle } from './browser' |
| 21 | +const title = browserTitle</script> |
| 22 | +<template><p>{{ title }} {{ count }}</p></template> |
| 23 | +<style>/* Preserve café, spacing and \`quotes\` ${index} */ |
| 24 | +p { color: rgb(12, 34, 56); }</style>`) |
| 25 | + } |
| 26 | + const result = await buildComponentLibrary({ inputDir: input, outputDir: output, css: false, minify: true }) |
| 27 | + const hashes = result.components.filter(component => component.styles).map(component => `'sha256-${createHash('sha256').update(component.styles, 'utf8').digest('base64')}'`) |
| 28 | + const csp = `default-src 'none'; script-src 'self'; img-src 'self'; style-src ${hashes.join(' ')}; base-uri 'none'` |
| 29 | + const emitted = new Map(result.files.filter(file => file.endsWith('.js')).map(file => [`/${relative(output, file).split(sep).join('/')}`, file])) |
| 30 | + fixture = createBrowserFixture({ |
| 31 | + createServer: () => Bun.serve({ |
| 32 | + hostname: '127.0.0.1', |
| 33 | + port: 0, |
| 34 | + fetch(request) { |
| 35 | + const pathname = new URL(request.url).pathname |
| 36 | + if (pathname === '/') return new Response(`<!doctype html><link rel="icon" href="/favicon.ico"><script src="/probe.js"></script><script type="module" src="/bundle.js"></script>${result.components.map(component => `<${component.tag}></${component.tag}>`).join('')}`, { headers: { 'Content-Type': 'text/html', 'Content-Security-Policy': csp } }) |
| 37 | + if (pathname === '/favicon.ico') return new Response(null, { status: 204 }) |
| 38 | + if (pathname === '/probe.js') return new Response("globalThis.violations=[];globalThis.errors=[];addEventListener('error',event=>errors.push(event.message));addEventListener('unhandledrejection',event=>errors.push(String(event.reason)));addEventListener('securitypolicyviolation',event=>violations.push(event.effectiveDirective))", { headers: { 'Content-Type': 'text/javascript' } }) |
| 39 | + const file = emitted.get(pathname) |
| 40 | + return file ? new Response(Bun.file(file), { headers: { 'Content-Type': 'text/javascript' } }) : new Response('Not found', { status: 404 }) |
| 41 | + }, |
| 42 | + }), |
| 43 | + createView: () => new WebView({ headless: true, backend: { type: 'chrome', url: false } }), |
| 44 | + }) |
| 45 | + await fixture.view.navigate(fixture.url) |
| 46 | + const hydrated = await fixture.waitFor(`Array.from(document.querySelectorAll('csp-card-0,csp-card-1,csp-card-2')).every(element=>element.hasAttribute('hydrated'))`, value => value === true) |
| 47 | + expect(await fixture.view.evaluate('JSON.stringify(errors)')).toBe('[]') |
| 48 | + expect(await fixture.view.evaluate('JSON.stringify(violations)')).toBe('[]') |
| 49 | + expect(hydrated).toBe(true) |
| 50 | + const inspect = `(async()=>{ |
| 51 | + const results=[]; |
| 52 | + for(const tag of ${JSON.stringify(result.components.map(component => component.tag))}){ |
| 53 | + const element=document.querySelector(tag); |
| 54 | + element.count=1;await element.updateComplete; |
| 55 | + results.push({styles:element.renderRoot.querySelector('style')?.textContent??null,color:getComputedStyle(element.renderRoot.querySelector('p')).color,text:element.renderRoot.querySelector('p').textContent}); |
| 56 | + } |
| 57 | + return JSON.stringify(results); |
| 58 | + })()` |
| 59 | + const observed = JSON.parse(await fixture.view.evaluate<string>(inspect)) |
| 60 | + expect(observed).toEqual(result.components.map(component => ({ styles: component.shadowMode ? component.styles : null, color: component.shadowMode ? 'rgb(12, 34, 56)' : 'rgb(0, 0, 0)', text: 'HTML 1' }))) |
| 61 | + expect(await fixture.view.evaluate('JSON.stringify(violations)')).toBe('[]') |
| 62 | + // A different inline style proves the response really enforces the hashes. |
| 63 | + await fixture.view.evaluate("(()=>{const blocked=document.createElement('style');blocked.textContent='p { color: rgb(200, 0, 0); }';document.querySelector('csp-card-1').renderRoot.appendChild(blocked);return true})()") |
| 64 | + expect(await fixture.waitFor('violations.includes("style-src-elem")', value => value === true)).toBe(true) |
| 65 | + expect(await fixture.view.evaluate("getComputedStyle(document.querySelector('csp-card-1').renderRoot.querySelector('p')).color")).toBe('rgb(12, 34, 56)') |
| 66 | + } |
| 67 | + finally { |
| 68 | + fixture?.close() |
| 69 | + await rm(root, { recursive: true, force: true }) |
| 70 | + } |
| 71 | +}, 30_000) |
0 commit comments