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 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-01 16:42:49 -08:00
1 parent 01a78c9b3d
commit 4c1c8db97e
5 files changed
+149 -3

No files matched your search

+2
View File
@@ -18,6 +18,8 @@
"test:browser:legacy-grid": "node scripts/e2e-legacy-grid.mjs", "test:browser:legacy-grid": "node scripts/e2e-legacy-grid.mjs",
"test:browser:colorpick": "node scripts/e2e-color-pick.mjs", "test:browser:colorpick": "node scripts/e2e-color-pick.mjs",
"test:browser:colors": "node scripts/e2e-color-system.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": "node scripts/e2e-sitesmith.mjs",
"test:browser:sitesmith-cleaned": "node scripts/e2e-sitesmith-cleaned.mjs", "test:browser:sitesmith-cleaned": "node scripts/e2e-sitesmith-cleaned.mjs",
"test:unit:watch": "vitest" "test:unit:watch": "vitest"
@@ -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<void>((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))) };
};
+78
View File
@@ -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 = `<!doctype html><html><head><style>
:root{--wsb-primary:#1f3a5f;--wsb-secondary:#101a2b;--wsb-border:#d4372c;--wsb-accent:#d9a24a;--pad:40px}
body{margin:0;background:#ffffff}
.sheet-bg{background:var(--wsb-secondary);height:100px}
.sheet-border{border:12px solid var(--wsb-border);height:76px;background-color:#ffffff}
</style></head><body>
<div style="background: var(--wsb-secondary); height:100px"></div>
<div style="background-color: var(--wsb-primary); height:100px"></div>
<div style="border:12px solid var(--wsb-border); height:76px; background-color:#ffffff"></div>
<div class="sheet-bg"></div>
<div class="sheet-border"></div>
<div style="background: linear-gradient(var(--wsb-accent), var(--wsb-accent)); height:100px"></div>
<div style="padding: var(--pad) 0 0 0; background-color:#ffffff"><div style="height:60px;background-color:#00aa00"></div></div>
</body></html>`;
// [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('<!doctype html><title>host</title><body>host</body>'); }).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), '<div style="background: var(--missing, url(/beacon?x)) center; border: 1px solid var(--b)">x</div><style>.a{background:var(--q, url(https://evil.example/a.png)) no-repeat}</style>');
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);
+2 -1
View File
@@ -44,8 +44,9 @@ const EVIL = `<!doctype html><html><head>
.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)} .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,<b>1</b>")} .b14{-moz-binding:url(/beacon?b-moz)} .b11{background:url(javascript:1)} .b12{background:url(blob:http://x/y)} .b13{background:url("data:text/html,<b>1</b>")} .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)}} @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}
</style> </style>
<div class="b1 b2 b3 b4 b5 b6 b7 b8 b9 b10 b11 b12 b13 b14 b15" style="background:u\\72l(/beacon?i-escape);background-image:image-set('/beacon?i-set' 1x)">css bypass probes</div> <div class="b1 b2 b3 b4 b5 b6 b7 b8 b9 b10 b11 b12 b13 b14 b15 b16 b17" style="background:u\\72l(/beacon?i-escape);background-image:image-set('/beacon?i-set' 1x)">css bypass probes</div>
<img src="assets/%2e%2e/beacon?p1"><img src="assets/%252e%252e/beacon?p2"><img src="assets/..%2fbeacon?p3"><img src="assets/../beacon?p4"><img src="//localhost/beacon?p5"><img src="data:text/html,x"><img src="blob:http://x/y"><img src="assets\\..\\beacon?p6"> <img src="assets/%2e%2e/beacon?p1"><img src="assets/%252e%252e/beacon?p2"><img src="assets/..%2fbeacon?p3"><img src="assets/../beacon?p4"><img src="//localhost/beacon?p5"><img src="data:text/html,x"><img src="blob:http://x/y"><img src="assets\\..\\beacon?p6">
<p style="color:#444">Visible paragraph so the capture has real content ${'lorem ipsum '.repeat(40)}</p> <p style="color:#444">Visible paragraph so the capture has real content ${'lorem ipsum '.repeat(40)}</p>
</body></html>`; </body></html>`;
+48 -2
View File
@@ -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<string, RegExp> = {
'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 { function declarations(style: CSSStyleDeclaration, pol: Policy, font = false): string {
const out: string[] = []; const out: string[] = [];
const seenShorthand = new Set<string>();
for (let i = 0; i < style.length; i++) { 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; 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; if (!value || !valueOk(value, pol, font || prop === 'src')) continue;
const pri = style.getPropertyPriority(prop) ? ' !important' : ''; const pri = style.getPropertyPriority(prop) ? ' !important' : '';
out.push(`${prop}:${canonicalize(value, pol, font || prop === 'src')}${pri}`); out.push(`${prop}:${canonicalize(value, pol, font || prop === 'src')}${pri}`);