From 4c1c8db97e7925bc3ee9fd5c3f670b169393ce9d Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 16:07:19 -0800 Subject: [PATCH] sitesmith self-review: keep var() shorthands (background/border) in the capture Measured in Chromium and Firefox: the CSSOM exposes `background: var(--x)` and `border: 1px solid var(--x)` only as pending-substitution longhands whose values read back as "", so the capture sanitiser (which re-serialises the parsed declarations) dropped them. The self-review screenshot then showed dark heroes, image placeholders and card borders as white, and the model "fixed" things that were fine on the real page. The sanitiser now emits the declared shorthand once, at its first longhand, through the same value/URL gate as every other declaration. Sandbox, CSP and the single URL allow-list are unchanged. - scripts/e2e-capture-fidelity.mjs: real-browser test through the whole capture path (sanitise -> inline -> sandboxed frame -> html-to-image), decodes the JPEG and samples pixels. Failed 9/12 checks per browser before, passes after. - e2e-capture-security: hostile var() shorthand / var() fallback url() probes. Co-Authored-By: Claude Opus 5.5 --- craft/package.json | 2 + craft/scripts/e2e-capture-fidelity-entry.ts | 19 +++++ craft/scripts/e2e-capture-fidelity.mjs | 78 +++++++++++++++++++++ craft/scripts/e2e-capture-security.mjs | 3 +- craft/src/utils/sanitize-capture-html.ts | 50 ++++++++++++- 5 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 craft/scripts/e2e-capture-fidelity-entry.ts create mode 100644 craft/scripts/e2e-capture-fidelity.mjs 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}`);