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:
1 parent
01a78c9b3d
commit
4c1c8db97e
5 files changed
+149
-3
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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))) };
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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)}
|
||||
.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)}}
|
||||
: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>
|
||||
<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">
|
||||
<p style="color:#444">Visible paragraph so the capture has real content ${'lorem ipsum '.repeat(40)}</p>
|
||||
</body></html>`;
|
||||
|
||||
@@ -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 {
|
||||
const out: string[] = [];
|
||||
const seenShorthand = new Set<string>();
|
||||
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}`);
|
||||
|
||||
Reference in new issue
Block a user