diff --git a/craft/package.json b/craft/package.json index 8179347..759a3e2 100644 --- a/craft/package.json +++ b/craft/package.json @@ -18,6 +18,8 @@ "test:browser:legacy-grid": "node scripts/e2e-legacy-grid.mjs", "test:browser:colorpick": "node scripts/e2e-color-pick.mjs", "test:browser:colors": "node scripts/e2e-color-system.mjs", + "test:browser:capture-fidelity": "node scripts/e2e-capture-fidelity.mjs", + "test:browser:capture-security": "node scripts/e2e-capture-security.mjs", "test:browser:sitesmith": "node scripts/e2e-sitesmith.mjs", "test:browser:sitesmith-cleaned": "node scripts/e2e-sitesmith-cleaned.mjs", "test:unit:watch": "vitest" diff --git a/craft/scripts/e2e-capture-fidelity-entry.ts b/craft/scripts/e2e-capture-fidelity-entry.ts new file mode 100644 index 0000000..a9f7c75 --- /dev/null +++ b/craft/scripts/e2e-capture-fidelity-entry.ts @@ -0,0 +1,19 @@ +// Bundled by scripts/e2e-capture-fidelity.mjs and run in real Firefox + Chromium. +import { captureHtml } from '../src/utils/self-review-capture'; +import { sanitizeCaptureHtml } from '../src/utils/sanitize-capture-html'; + +(window as any).sanitizeCaptureHtml = sanitizeCaptureHtml; +/** Capture through the real path, then decode the JPEG and return RGB samples at the given points. */ +(window as any).captureAndSample = async (html: string, points: Array<[number, number]>) => { + const r = await captureHtml(html, { settleMs: 200, injectFontAwesome: false }); + if (!r.ok) return { ok: false, reason: r.reason }; + const url = URL.createObjectURL(r.blob); + const img = new Image(); + await new Promise((res, rej) => { img.onload = () => res(); img.onerror = () => rej(new Error('decode')); img.src = url; }); + const c = document.createElement('canvas'); + c.width = img.naturalWidth; c.height = img.naturalHeight; + const ctx = c.getContext('2d')!; + ctx.drawImage(img, 0, 0); + URL.revokeObjectURL(url); + return { ok: true, w: c.width, h: c.height, samples: points.map(([x, y]) => Array.from(ctx.getImageData(x, y, 1, 1).data.slice(0, 3))) }; +}; diff --git a/craft/scripts/e2e-capture-fidelity.mjs b/craft/scripts/e2e-capture-fidelity.mjs new file mode 100644 index 0000000..2404e3a --- /dev/null +++ b/craft/scripts/e2e-capture-fidelity.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +/** + * Real-browser proof (Chromium + Firefox) that the self-review capture keeps what the page shows: + * backgrounds and borders written as CSS SHORTHAND containing var() (e.g. `background: var(--wsb-secondary)`, + * `border: 1px solid var(--wsb-border)`). The CSSOM exposes such a declaration only as pending-substitution + * longhands whose values read back as "", so a sanitiser that re-serialises longhands drops them and the + * hero came out white in the screenshot the model reviewed (eval 2026-11-01). Goes through the WHOLE capture + * path (sanitiser -> inlining -> sandboxed frame -> html-to-image), decodes the JPEG and samples pixels. + * Run: node scripts/e2e-capture-fidelity.mjs (CHROMIUM_PATH / FIREFOX_PATH override the binaries) + */ +import { build } from 'esbuild'; +import { chromium, firefox } from '@playwright/test'; +import http from 'node:http'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const out = await build({ + entryPoints: [path.join(root, 'scripts/e2e-capture-fidelity-entry.ts')], + bundle: true, write: false, format: 'iife', platform: 'browser', logLevel: 'error', + define: { 'process.env.NODE_ENV': '"production"' }, +}); +const js = out.outputFiles[0].text; + +// Token CSS the way the exporter emits it (:root custom properties), bands written the four ways that occur. +const PAGE = ` +
+
+
+
+
+
+
+`; +// [label, x, y, expected rgb] +const POINTS = [ + ['inline `background: var(--wsb-secondary)` band', 640, 50, [0x10, 0x1a, 0x2b]], + ['inline `background-color: var(--wsb-primary)` band (control)', 640, 150, [0x1f, 0x3a, 0x5f]], + ['inline `border: 12px solid var(--wsb-border)` left edge', 5, 240, [0xd4, 0x37, 0x2c]], + ['inline `border` box interior stays white', 640, 250, [0xff, 0xff, 0xff]], + ['stylesheet `.sheet-bg{background: var(--wsb-secondary)}`', 640, 350, [0x10, 0x1a, 0x2b]], + ['stylesheet `.sheet-border{border: ... var(--wsb-border)}` top edge', 640, 405, [0xd4, 0x37, 0x2c]], + ['inline `background: linear-gradient(var(--wsb-accent), ...)`', 640, 550, [0xd9, 0xa2, 0x4a]], + ['inline `padding: var(--pad) 0 0 0` keeps the 40px gap (white above the green box)', 640, 620, [0xff, 0xff, 0xff]], + ['... and the green box starts below the padding', 640, 670, [0x00, 0xaa, 0x00]], +]; + +const srv = http.createServer((q, r) => { r.setHeader('content-type', 'text/html'); r.end('hosthost'); }).listen(0); +const port = srv.address().port; +let failed = 0; +const check = (name, label, ok, extra = '') => { if (!ok) failed++; console.log(`[${name}] ${ok ? 'PASS' : 'FAIL'} ${label}${extra ? ' ' + extra : ''}`); }; +const near = (a, b, tol = 24) => a.every((v, i) => Math.abs(v - b[i]) <= tol); + +async function run(name, launcher, exe) { + const browser = await launcher.launch({ executablePath: exe || undefined, ...(name === 'chromium' ? { args: ['--no-sandbox'] } : {}) }); + const page = await browser.newPage(); + await page.goto(`http://localhost:${port}/`); + await page.addScriptTag({ content: js }); + const clean = await page.evaluate((h) => window.sanitizeCaptureHtml(h), PAGE); + check(name, 'sanitiser keeps `background: var(--wsb-secondary)` as written', clean.ok && /background:\s*var\(--wsb-secondary\)/.test(clean.html)); + check(name, 'sanitiser keeps `border: 12px solid var(--wsb-border)` as written', clean.ok && /border:\s*12px solid var\(--wsb-border\)/.test(clean.html)); + // The recovered shorthand goes through the same URL gate: a var() fallback url() to a non-allowed target is dropped. + const hostile = await page.evaluate((h) => window.sanitizeCaptureHtml(h), '
x
'); + check(name, 'recovered var() shorthand with a disallowed url() fallback is dropped (inline + sheet)', hostile.ok && !/beacon|evil\.example/.test(hostile.html) && /border:\s*1px solid var\(--b\)/.test(hostile.html), hostile.ok ? hostile.html.slice(0, 300) : hostile.reason); + const res = await page.evaluate(([h, pts]) => window.captureAndSample(h, pts), [PAGE, POINTS.map((p) => [p[1], p[2]])]); + check(name, 'capture succeeded', res.ok === true, res.ok ? '' : res.reason); + if (res.ok) POINTS.forEach(([label, , , want], i) => check(name, label, near(res.samples[i], want), `got rgb(${res.samples[i].join(',')}) want rgb(${want.join(',')})`)); + await browser.close(); +} +await run('chromium', chromium, process.env.CHROMIUM_PATH); +await run('firefox', firefox, process.env.FIREFOX_PATH); +srv.close(); +process.exit(failed ? 1 : 0); diff --git a/craft/scripts/e2e-capture-security.mjs b/craft/scripts/e2e-capture-security.mjs index 1f54196..4f81052 100644 --- a/craft/scripts/e2e-capture-security.mjs +++ b/craft/scripts/e2e-capture-security.mjs @@ -44,8 +44,9 @@ const EVIL = ` .b8{background:url(assets/%2e%2e/beacon?b-pct)} .b9{background:url(assets/%252e%252e/beacon?b-pct2)} .b10{background:url(assets/..%2fbeacon?b-pct3)} .b11{background:url(javascript:1)} .b12{background:url(blob:http://x/y)} .b13{background:url("data:text/html,1")} .b14{-moz-binding:url(/beacon?b-moz)} @keyframes bk{from{background:url(/beacon?b-kf)}to{color:red}} @media (min-width:1px){.b15{background:url(/beacon?b-media)}} +:root{--vu:url(/beacon?b-var-sh);--vs:url(/beacon?b-var-src)} .b16{background:var(--vu) no-repeat;border:1px solid var(--vu);border-image:var(--vu) 30;list-style:var(--vu) inside} .b17{background:var(--missing, url(/beacon?b-var-fallback)) center} -
css bypass probes
+
css bypass probes

Visible paragraph so the capture has real content ${'lorem ipsum '.repeat(40)}

`; diff --git a/craft/src/utils/sanitize-capture-html.ts b/craft/src/utils/sanitize-capture-html.ts index 9f8e275..417835e 100644 --- a/craft/src/utils/sanitize-capture-html.ts +++ b/craft/src/utils/sanitize-capture-html.ts @@ -140,12 +140,58 @@ function canonicalize(value: string, pol: Policy, font: boolean): string { }); } +/** + * Shorthands whose value the CSSOM keeps only on the shorthand when it contains var(): the indexed + * longhands then read back as "" (pending substitution, measured in Chromium and Firefox), so walking + * longhands alone silently dropped `background: var(--wsb-secondary)` and `border: 1px solid var(--x)` + * (the self-review screenshot showed a dark hero as white). More specific names first. + */ +const SHORTHANDS = [ + 'background-position', 'border-top', 'border-right', 'border-bottom', 'border-left', 'border-color', 'border-style', + 'border-width', 'border-radius', 'border-image', 'border-block', 'border-inline', 'border', 'background', 'padding', + 'margin', 'inset', 'font', 'outline', 'text-decoration', 'list-style', 'flex-flow', 'flex', 'gap', 'grid-template', + 'grid-area', 'grid-row', 'grid-column', 'grid', 'place-items', 'place-content', 'place-self', 'columns', 'column-rule', + 'overflow', 'transition', 'animation', 'mask', 'text-emphasis', 'scroll-margin', 'scroll-padding', +] as const; +const COVER_EXTRA: Record = { + 'border-color': /^border-(top|right|bottom|left)-color$/, + 'border-style': /^border-(top|right|bottom|left)-style$/, + 'border-width': /^border-(top|right|bottom|left)-width$/, + 'border-radius': /^border-[a-z-]+-radius$/, + inset: /^(top|right|bottom|left)$/, + font: /^line-height$/, + gap: /^(row|column)-gap$/, + 'flex-flow': /^flex-(direction|wrap)$/, + 'place-items': /^(align|justify)-items$/, + 'place-content': /^(align|justify)-content$/, + 'place-self': /^(align|justify)-self$/, + columns: /^column-(width|count)$/, +}; +function shorthandFor(style: CSSStyleDeclaration, prop: string): string | null { + for (const sh of SHORTHANDS) { + if (!(prop.startsWith(sh + '-') || COVER_EXTRA[sh]?.test(prop))) continue; + const v = style.getPropertyValue(sh); + if (v && /var\(/i.test(v)) return sh; + } + return null; +} + function declarations(style: CSSStyleDeclaration, pol: Policy, font = false): string { const out: string[] = []; + const seenShorthand = new Set(); for (let i = 0; i < style.length; i++) { - const prop = style[i]; + let prop = style[i]; if (!prop || /[^A-Za-z0-9_-]/.test(prop) || BAD_PROP.test(prop)) continue; - const value = style.getPropertyValue(prop); + let value = style.getPropertyValue(prop); + if (!value) { + // A var() shorthand: emit the shorthand once, where its first longhand stood (cascade order kept), + // judged by exactly the same value gate as any other declaration. + const sh = shorthandFor(style, prop); + if (!sh || seenShorthand.has(sh)) continue; + seenShorthand.add(sh); + prop = sh; + value = style.getPropertyValue(sh); + } if (!value || !valueOk(value, pol, font || prop === 'src')) continue; const pri = style.getPropertyPriority(prop) ? ' !important' : ''; out.push(`${prop}:${canonicalize(value, pol, font || prop === 'src')}${pri}`);