[harness eval #34712] Fix XSS in Brand: sanitize dangerouslySetInnerHTML with DOMPurify#31
[harness eval #34712] Fix XSS in Brand: sanitize dangerouslySetInnerHTML with DOMPurify#31valentinpalkovic wants to merge 2 commits into
Conversation
|
Verify HarnessVerdict: PR-added unit tests: ❌ failed — vitest exited 1 without writing a JSON report (likely setup error); see Action log Files: vitest output (last 4KB)Replay: Screenshots
|
fe2f521 to
e537022
Compare
Verify HarnessVerdict: Reason: Evidence (vision-check, Vision reasoningThe diff contains user-visible changes to the Brand component (DOMPurify sanitization and rel attribute addition), but the Playwright recipe is designed to verify module resolution and XSS prevention rather than visible UI changes. The screenshots show the sidebar renders without errors, which evidences the DOMPurify dependency loads correctly, but the sanitization and rel attribute changes are not visually observable—they are runtime contract validations that don't produce detectable UI differences from the screenshots. PR-added unit tests: ❌ failed — vitest exited 1 without writing a JSON report (likely setup error); see Action log Files: vitest output (last 4KB)Replay: Screenshots
|
Verify HarnessVerdict: Evidence (vision-check, Vision reasoningThe diff adds DOMPurify sanitization to the Brand component and security-related attributes (rel='noopener noreferrer'), but these are primarily defensive security changes that are not visually observable in screenshots. The sanitization prevents XSS execution but doesn't produce a visible UI difference—the safe text renders identically to unsanitized safe HTML. The screenshots show the sidebar rendering without errors, confirming the code works, but cannot demonstrate the sanitization itself visually. PR-added unit tests: ✅ passed — 6716 passed, 0 failed across 2120 suite(s) Files: Replay: Screenshots
|
80ccd7d to
745162d
Compare
Verify HarnessVerdict: Evidence (vision-check, Vision reasoningThe diff modifies Brand.tsx to add DOMPurify sanitization of the theme.brand.title, but the screenshots show the sidebar with the default Storybook logo (image branch), not the dangerouslySetInnerHTML branch that would visibly render the sanitized title. The recipe exercises the Brand component's mount path and verifies no errors occurred, but the visual change (sanitized HTML rendering) is not observable in these screenshots since the default brand uses an image, not HTML content. PR-added unit tests: ✅ passed — 6716 passed, 0 failed across 2120 suite(s) Files: Replay: Screenshots
|
a11176d to
9de9d5b
Compare
Verify HarnessNo verdict produced — the workflow failed before the harness ran (likely recipe-author dispatch, deny-regex, or lint). See run log for details. |
Verify HarnessVerdict: Reason: PR-added unit tests: ✅ passed — 6764 passed, 0 failed across 2130 suite(s) Files: How Playwright validated this//
//
//
test('Brand title sanitization via DOMPurify strips script and inline handlers', async ({ page }, testInfo) => {
const pageErrors: string[] = [];
const consoleErrors: string[] = [];
page.on('pageerror', (err) => {
pageErrors.push(err.stack ?? err.message ?? String(err));
});
page.on('console', (msg) => {
if (msg.type() === 'error') consoleErrors.push(msg.text());
});
const dialogs: string[] = [];
page.on('dialog', async (dialog) => {
dialogs.push(`${dialog.type()}:${dialog.message()}`);
await dialog.dismiss().catch(() => {});
});
const baseURL =
process.env.STORYBOOK_URL ?? testInfo.project.use.baseURL ?? 'http://localhost:6006';
try {
await page.goto(`${baseURL}/?path=/story/example-button--primary`);
const sb = new RecipePage(page, expect);
await sb.waitUntilLoaded();
const brandLink = page.locator('.sidebar-container a[href="./"]').first();
await expect(brandLink).toBeVisible({ timeout: 10000 });
const brandImg = brandLink.locator('img').first();
await expect(brandImg).toBeVisible();
const sanitizerProbe = await page.evaluate(async () => {
const payloads = [
'Hello<script>window.__xss1=true;</script>',
'<img src=x onerror="window.__xss2=true">',
'<strong>Safe</strong>',
];
const w = window as unknown as Record<string, unknown>;
const candidates: unknown[] = [
w.DOMPurify,
(w.dompurify as Record<string, unknown> | undefined)?.default,
];
let purify: { sanitize: (s: string) => string } | undefined;
for (const c of candidates) {
if (c && typeof (c as { sanitize?: unknown }).sanitize === 'function') {
purify = c as { sanitize: (s: string) => string };
break;
}
}
const baselineDiv = document.createElement('div');
baselineDiv.innerHTML = payloads[0] + payloads[1] + payloads[2];
const baselineHtml = baselineDiv.innerHTML;
let sanitiReplay: Screenshots
|
Verify HarnessNo verdict produced — the workflow failed before the harness ran (likely recipe-author dispatch, deny-regex, or lint). See run log for details. |
…rity no-verdict cause Wave finding (#31 try-pr-34712 XSS): recipe-author correctly chose behavioral mode and drove the change via the public manager-api `setOptions` path (no module import), but needed `(window as any).__STORYBOOK_ADDONS_MANAGER` to reach the runtime singleton. `@typescript-eslint/recommended` makes `no-explicit-any` an error, so the scoped lint gate failed twice → no verdict. - .verify-recipes/.eslintrc.cjs: `@typescript-eslint/no-explicit-any: 'off'`. Code-quality rule, NOT a security control — deny-regex and no-restricted-{globals,imports,syntax} remain the load-bearing gates. `as any` for window/manager-api globals is correct and unavoidable. - _recipe-authoring-guide.md §12.5: note that `as any` for runtime globals is allowed; don't waste retries trying to type them. Verified: behavioral recipe using `(window as any).__STORYBOOK_ADDONS_MANAGER` now lints clean (exit 0). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Verify HarnessVerdict: Reason: PR-added unit tests: ✅ passed — 6764 passed, 0 failed across 2130 suite(s) Files: How Playwright validated this//
//
test('Brand renders cleanly after DOMPurify wiring; no boot/runtime errors from sanitizer change', async ({
page,
}, testInfo) => {
const pageErrors: string[] = [];
const consoleErrors: string[] = [];
page.on('pageerror', (err) => {
pageErrors.push(err.stack ?? err.message ?? String(err));
});
page.on('console', (msg) => {
if (msg.type() === 'error') consoleErrors.push(msg.text());
});
const baseURL =
process.env.STORYBOOK_URL ?? testInfo.project.use.baseURL ?? 'http://localhost:6006';
try {
await page.goto(`${baseURL}/?path=/story/example-button--primary`);
const sb = new RecipePage(page, expect);
await sb.waitUntilLoaded();
const sidebar = page.locator('.sidebar-container');
await expect(sidebar).toBeVisible();
const brandLink = page.getByRole('banner').getByRole('link', { name: 'Storybook' });
await expect(brandLink).toBeVisible();
await expect(brandLink).toHaveAttribute('href', './');
const errorDisplay = page.locator('#sb-errordisplay');
await expect(errorDisplay).toBeHidden();
const previewRoot = sb.previewIframe().locator('#storybook-root, #root');
await expect(previewRoot).toBeVisible();
} finally {
await testInfo.attach('pageErrors', {
body: JSON.stringify(pageErrors),
contentType: 'application/json',
});
await testInfo.attach('consoleErrors', {
body: JSON.stringify(consoleErrors),
contentType: 'application/json',
});
}
expect(filterPageErrors(pageErrors)).toEqual([]);
expect(consoleErrors).toEqual([]);
});Replay: Screenshots
|
…ActionBar scope Wave findings (#28/#29/#31 stuck at regression despite passing PR unit tests — recipe-author mis-targeted the DOM): - ActionBar/Canvas rule was conflating the docs-Canvas Zoom/Show-code toolbar with the generic `ActionBar` component. Scope-tagged it to the docs-Canvas surface only. - New HARD GATE "additive-only API changes with no story/consumer" — the #1 false-regression cause. #28/#29 add `ActionItem.ariaLabel` but no story or in-diff consumer passes it, so the attribute is never in the DOM; asserting it always fails. Rule: detect additive-no-consumer, fall back to `@verify-mode: visual` smoke on the component's existing story (`components-actionbar--many-items`), never `getByRole('toolbar')` (the component renders plain <button>s) nor `.docs-story`. - New HARD GATE for `Brand` / `theme.brand.title`: the sanitized dangerouslySetInnerHTML path runs ONLY when `theme.brand.image === null`. Target the existing `manager-sidebar-heading--only-text` / `--link-and-text` stories (already `{title, image:null}`); never runtime `api.setOptions({theme})` (#31 false regression — never reaches the path). XSS-inert proof is the PR's unit test; recipe is a render/boot smoke. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Verify HarnessVerdict: Reason: PR-added unit tests: ✅ passed — 6764 passed, 0 failed across 2130 suite(s) Files: How Playwright validated thistest('Brand sanitizes theme.brand.title via DOMPurify (image===null path)', async ({
page,
}, testInfo) => {
const pageErrors: string[] = [];
const consoleErrors: string[] = [];
page.on('pageerror', (err) => {
pageErrors.push(err.stack ?? err.message ?? String(err));
});
page.on('console', (msg) => {
if (msg.type() === 'error') consoleErrors.push(msg.text());
});
const baseURL =
process.env.STORYBOOK_URL ?? testInfo.project.use.baseURL ?? 'http://localhost:6006';
try {
await page.goto(
`${baseURL}/?path=/story/manager-sidebar-heading--only-text&viewMode=story`
);
const sb = new RecipePage(page, expect);
await page.locator('.sb-preparing-story').waitFor({ state: 'hidden', timeout: 30_000 });
await page.locator('.sb-preparing-docs').waitFor({ state: 'hidden', timeout: 30_000 });
const errorDisplay = page.locator('#sb-errordisplay');
await expect(errorDisplay).toBeHidden();
const previewIframe = sb.previewIframe();
const brand = previewIframe.locator('body');
await expect(brand).toContainText(/Only text/i);
const scriptCount = await previewIframe.locator('script:not([src])').count();
expect(scriptCount).toBe(0);
await page.goto(
`${baseURL}/?path=/story/manager-sidebar-heading--link-and-text&viewMode=story`
);
await page.locator('.sb-preparing-story').waitFor({ state: 'hidden', timeout: 30_000 });
await page.locator('.sb-preparing-docs').waitFor({ state: 'hidden', timeout: 30_000 });
const linkBrand = sb.previewIframe().locator('body');
await expect(linkBrand.locator('a').first()).toBeVisible();
} finally {
await testInfo.attach('pageErrors', {
body: JSON.stringify(pageErrors),
contentType: 'application/json',
});
await testInfo.attach('consoleErrors', {
body: JSON.stringify(consoleErrors),
contentType: 'application/json',
});
}
expect(filterPageErrors(pageErrors)).toEqual([]);
});Replay: Screenshots
|
… TMPDIR pinned Two distinct wave-#31/#36 root causes, both false regressions: (a) _util.ts previewRoot() filtered `#storybook-root:visible`. Stories with `parameters.layout:'fullscreen'` + the internal-ui side-by-side/stacked theme decorator wrap the story so #storybook-root has a zero-size (Playwright-"not visible") box though it rendered — locator matched nothing, waitForStoryLoaded timed out (#31 manager-sidebar-heading--*). Use `:has(> *)` instead: selects whichever container actually has children, keeps story-vs-docs disambiguation, drops the bounding-box requirement. (b) verify-pr.yml unit-test step runs `env -i … srt … yarn vitest`. `env -i` strips TMPDIR, so Yarn's run-temp realpaths a nonexistent srt path (`lstat '/tmp/claude'` ENOENT) and aborts before vitest starts → false "vitest exited without JSON report" regression (#36 a11yRunner). Pin TMPDIR to an existing allowWrite dir ($PR_HEAD_DIR/.verify-output/vitest-tmp), same rationale REPORT/LOG already live there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Verify HarnessVerdict: Reason: PR-added unit tests: ✅ passed — 6764 passed, 0 failed across 2130 suite(s) Files: How Playwright validated thistest('Brand sanitizes dangerouslySetInnerHTML title on image:null branch', async ({ page }, testInfo) => {
const pageErrors: string[] = [];
const consoleErrors: string[] = [];
page.on('pageerror', (err) => {
pageErrors.push(err.stack ?? err.message ?? String(err));
});
page.on('console', (msg) => {
if (msg.type() === 'error') consoleErrors.push(msg.text());
});
const baseURL =
process.env.STORYBOOK_URL ?? testInfo.project.use.baseURL ?? 'http://localhost:6006';
try {
await page.goto(`${baseURL}/?path=/story/manager-sidebar-heading--link-and-text`);
const sb = new RecipePage(page, expect);
await sb.waitUntilLoaded();
const previewIframe = sb.previewIframe();
const brandLink = previewIframe.locator('a[href]').first();
await expect(brandLink).toBeVisible({ timeout: 15000 });
const linkText = await brandLink.innerText();
expect(linkText.trim().length).toBeGreaterThan(0);
const scriptCount = await previewIframe
.locator('script:not([src])')
.evaluateAll((nodes) =>
nodes.filter((n) => /alert\s*\(/i.test(n.textContent ?? '')).length
);
expect(scriptCount).toBe(0);
await page.goto(`${baseURL}/?path=/story/manager-sidebar-heading--only-text`);
await sb.waitUntilLoaded();
const previewIframe2 = sb.previewIframe();
const previewRoot = previewIframe2.locator('#storybook-root');
await expect(previewRoot).toBeAttached({ timeout: 15000 });
const renderedText = await previewRoot.innerText();
expect(renderedText.trim().length).toBeGreaterThan(0);
const errorDisplay = page.locator('#sb-errordisplay');
await expect(errorDisplay).toBeHidden();
} finally {
await testInfo.attach('pageErrors', {
body: JSON.stringify(pageErrors),
contentType: 'application/json',
});
await testInfo.attach('consoleErrors', {
body: JSON.stringify(consoleErrors),
contentType: 'application/json',
});
}
expect(filterPageErrors(pReplay: Screenshots
|
…bans root-visible assert Re-run of #36/#31 showed both prior fixes missed the real cause: - #36: TMPDIR pin had zero effect — Yarn's mktempPromise still ENOENT `/tmp/claude`. Root cause: srt derives its sandbox tmp from CLAUDE_CODE_TMPDIR, NOT TMPDIR. The main recipe run inherits it via $GITHUB_ENV ($SANDBOX_TMPDIR); the unit-test step's `env -i` strips it, so srt falls back to its hardcoded `/tmp/claude` (never created). Pass CLAUDE_CODE_TMPDIR=$VITEST_TMPDIR (existing allowWrite dir) in env -i. - #31: previewRoot `:has(> *)` fix removed the _util.ts:66 timeout, but the recipe-author hand-rolled `expect('#storybook-root').toBeVisible()` which is "hidden" for `Sidebar/Heading` (layout:fullscreen + side-by-side = zero-box root). Brand triage rule now explicitly bans root-visibility asserts and prescribes a child `toBeAttached()` content assertion. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Verify HarnessVerdict: Reason: PR-added unit tests: ❌ failed — 6763 passed, 1 failed across 2130 suite(s) Files: vitest output (last 4KB)How Playwright validated thistest('Brand sanitizes title HTML on image:null branch without breaking render', async ({ page }, testInfo) => {
const pageErrors: string[] = [];
const consoleErrors: string[] = [];
page.on('pageerror', (err) => {
pageErrors.push(err.stack ?? err.message ?? String(err));
});
page.on('console', (msg) => {
if (msg.type() === 'error') consoleErrors.push(msg.text());
});
const baseURL =
process.env.STORYBOOK_URL ?? testInfo.project.use.baseURL ?? 'http://localhost:6006';
try {
await page.goto(`${baseURL}/?path=/story/manager-sidebar-heading--only-text`);
const sb = new RecipePage(page, expect);
await sb.waitUntilLoaded();
await expect(page.locator('#sb-errordisplay')).toBeHidden();
const previewIframe = sb.previewIframe();
const brandTitle = previewIframe
.locator('#storybook-root')
.locator('div, a')
.filter({ hasText: /My title/i })
.first();
await expect(brandTitle).toBeAttached();
const scriptCount = await previewIframe
.locator('#storybook-root script')
.count();
expect(scriptCount).toBe(0);
await page.goto(`${baseURL}/?path=/story/manager-sidebar-heading--link-and-text`);
await sb.waitUntilLoaded();
await expect(page.locator('#sb-errordisplay')).toBeHidden();
const linkBrand = previewIframe
.locator('#storybook-root a')
.filter({ hasText: /./ })
.first();
await expect(linkBrand).toBeAttached();
const linkScriptCount = await previewIframe
.locator('#storybook-root script')
.count();
expect(linkScriptCount).toBe(0);
} finally {
await testInfo.attach('pageErrors', {
body: JSON.stringify(pageErrors),
contentType: 'application/json',
});
await testInfo.attach('consoleErrors', {
body: JSON.stringify(consoleErrors),
contentType: 'application/json',
});
}
expect(filterPageErrors(pageErrors)).toEqual([]);
});Replay: Screenshots
|
Prevents opener access and referrer leakage for external brand links.
9cd6657 to
ab83e49
Compare
Verify HarnessVerdict: Reason: PR-added unit tests: ❌ failed — 6944 passed, 1 failed across 2180 suite(s) Files: vitest output (last 4KB)How Playwright validated thistest('Brand sanitizes title innerHTML on the image:null branch', async ({ page }, testInfo) => {
const pageErrors: string[] = [];
const consoleErrors: string[] = [];
page.on('pageerror', (err) => {
pageErrors.push(err.stack ?? err.message ?? String(err));
});
page.on('console', (msg) => {
if (msg.type() === 'error') consoleErrors.push(msg.text());
});
const baseURL =
process.env.STORYBOOK_URL ?? testInfo.project.use.baseURL ?? 'http://localhost:6006';
try {
await page.goto(`${baseURL}/?path=/story/manager-sidebar-heading--only-text`);
const sb = new RecipePage(page, expect);
await sb.waitUntilLoaded();
await expect(page.locator('#sb-errordisplay')).toBeHidden();
const previewIframe = sb.previewIframe();
const brandTitle = previewIframe
.locator('#storybook-root, #storybook-docs')
.locator('a, div')
.filter({ hasText: /My title|Storybook/i })
.first();
await expect(brandTitle).toBeAttached();
const scriptCount = await previewIframe
.locator('#storybook-root script, #storybook-docs script')
.count();
expect(scriptCount).toBe(0);
} finally {
await testInfo.attach('pageErrors', {
body: JSON.stringify(pageErrors),
contentType: 'application/json',
});
await testInfo.attach('consoleErrors', {
body: JSON.stringify(consoleErrors),
contentType: 'application/json',
});
}
expect(filterPageErrors(pageErrors)).toEqual([]);
expect(filterConsoleErrors(consoleErrors)).toEqual([]);
});Replay: Screenshots
|














Synthetic fork PR for agentic harness eval against storybookjs#34712.