diff --git a/craft/src/components/basic/HtmlBlock.security.test.ts b/craft/src/components/basic/HtmlBlock.security.test.ts index 079fd51..03c7799 100644 --- a/craft/src/components/basic/HtmlBlock.security.test.ts +++ b/craft/src/components/basic/HtmlBlock.security.test.ts @@ -227,9 +227,18 @@ describe('purifyHtml -- Task 24: security properties on newly-allowed elements', expect(out).not.toMatch(/onload/i); }); - test('style tag stays blocked even nested inside the newly-allowed inline svg', () => { + test('Task 25: style tag nested inside the newly-allowed inline svg now survives, scoped', () => { + // Was "style tag stays blocked" pre-Task-25 -- '); - expect(out).not.toMatch(/'); // rewritten, not verbatim + expect(out).toMatch(/\.whp-html-\w+ svg\{color:red\}/); expect(out).toContain(' { test('strips script tags', () => { @@ -103,3 +105,192 @@ describe('purifyHtml iframe sandboxing (M-6)', () => { expect(out).toContain('

hi

'); }); }); + +describe('purifyHtml -- Task 25: block-scoped (no CSS content) does not trigger a wrapper', () => { + const out = purifyHtml('

hi

'); + expect(out).not.toContain('
content gets wrapped in a scope-class div', () => { + const out = purifyHtml('

Hi

'); + expect(out).toMatch(/^
/); + expect(out).toContain('

Hi

'); + }); + + test('the style content is rewritten to only match inside the wrapper (the actual leak-prevention property)', () => { + const out = purifyHtml('

Hi

'); + const scopeClass = out.match(/class="(whp-html-[0-9a-z]+)"/)![1]; + expect(out).toContain(`.${scopeClass} h1 { color: red; }`); + // The bare, unscoped rule must not appear anywhere in the output -- + // that's exactly the leak this feature exists to close. + expect(out).not.toContain(''); + }); + + test('scope class is deterministic: the SAME code produces the SAME class across repeated calls', () => { + const code = '

x

'; + const out1 = purifyHtml(code); + const out2 = purifyHtml(code); + expect(out1).toBe(out2); + const class1 = out1.match(/class="(whp-html-[0-9a-z]+)"/)![1]; + const class2 = out2.match(/class="(whp-html-[0-9a-z]+)"/)![1]; + expect(class1).toBe(class2); + }); + + test('pinned scope class for a known input -- guards against silent hash-function drift', () => { + // If this ever needs to change, it means the hash function itself + // changed -- which would silently churn every stored site's HTML on + // next save and desync already-published pages from a fresh Preview. + // That should be a loud, deliberate decision, not a side-effect of an + // unrelated refactor -- hence pinning the literal output here. + const code = ''; + expect(stableHash(code)).toBe('5fwbyn'); + const out = purifyHtml(code); + expect(out).toContain('class="whp-html-5fwbyn"'); + }); + + test('scope class is a pure function of `code` -- does not depend on Craft node id or call order', () => { + // purifyHtml's signature only ever takes the code string -- there is no + // node id parameter it could even reach for. This test documents that + // invariant so a future refactor threading a node id through here (as + // html-export.ts's renderNode already does for OTHER components, see + // its `scopeId` comment) doesn't silently get wired into this path too. + const codeA = '

same content

'; + const codeB = '

same content

'; + expect(codeA).toBe(codeB); // sanity: truly identical strings + const outA = purifyHtml(codeA); + const outB = purifyHtml(codeB); + expect(outA).toBe(outB); + }); + + test('FORCE_BODY regression: a block whose ENTIRE code is a leading '); + expect(out).toContain('

Hi

'); + expect(out).toContain('

Hi

'); + expect(out).toMatch(/

x

'); + const scopeClass = out.match(/class="(whp-html-[0-9a-z]+)"/)![1]; + expect(out).toContain(`.${scopeClass} { --brand: red; }`); + expect(out).toContain(`.${scopeClass} { margin: 0; }`); + }); + + test('@import is stripped end-to-end (network-fetch/exfiltration channel)', () => { + const out = purifyHtml('

x

'); + expect(out).not.toContain('@import'); + expect(out).not.toContain('evil.example'); + expect(out).toContain('color:red'); + }); + + test('@keyframes body is not scoped (animation would otherwise break) -- end-to-end through purifyHtml', () => { + const out = purifyHtml( + '

x

', + ); + expect(out).toContain('@keyframes spin'); + expect(out).toMatch(/@keyframes spin\s*\{\s*from\s*\{\s*opacity:\s*0;?\s*\}\s*to\s*\{\s*opacity:\s*1;?\s*\}\s*\}/); + }); + + test('multiple

A

B

'); + const classes = [...out.matchAll(/class="(whp-html-[0-9a-z]+)"/g)].map((m) => m[1]); + expect(classes.length).toBeGreaterThanOrEqual(1); + expect(new Set(classes).size).toBe(1); // same block -> same scope class everywhere + }); +}); + +describe('purifyHtml -- Task 25: security properties of the newly-allowed inside a CSS comment cannot break out into executable markup', () => { + const out = purifyHtml( + ' */ h1{color:red}

hi

', + ); + expect(out).not.toContain(' inside a CSS string cannot break out into executable markup', () => { + const out = purifyHtml( + '"}

hi

', + ); + expect(out).not.toContain(' { + const out = purifyHtml( + '

x

y', + ); + expect(out).not.toMatch(/onclick/i); + expect(out).not.toContain(' { + const out = purifyHtml(''); + expect(out).toMatch(/]*\bsandbox="[^"]+"/); + }); + + test( + 'documented reality: DOMPurify does not sanitize CSS declaration values -- ' + + 'expression()/behavior/-moz-binding pass through untouched (dead in modern browsers, ' + + 'not exploitable there, but not filtered by this pipeline either)', + () => { + const out = purifyHtml( + '
x
', + ); + expect(out).toContain('expression(alert(1))'); + expect(out).toContain('behavior:url(evil.htc)'); + expect(out).toContain('-moz-binding:url(evil.xml#x)'); + }, + ); + + test('documented reality: url() to a remote host survives (legitimate for background-image, but a known CSS-exfiltration channel already accepted elsewhere in this config)', () => { + const out = purifyHtml('
x
'); + expect(out).toContain('tracker.example'); + }); +}); diff --git a/craft/src/components/basic/HtmlBlock.toHtml.test.ts b/craft/src/components/basic/HtmlBlock.toHtml.test.ts index 48e0e18..ef20675 100644 --- a/craft/src/components/basic/HtmlBlock.toHtml.test.ts +++ b/craft/src/components/basic/HtmlBlock.toHtml.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'vitest'; -import { HtmlBlock } from './HtmlBlock'; +import { HtmlBlock, purifyHtml } from './HtmlBlock'; const toHtml = (HtmlBlock as any).toHtml; @@ -46,3 +46,24 @@ describe('HtmlBlock.toHtml markup path (C1 review finding)', () => { expect(html).toBe(code); }); }); + +describe('HtmlBlock.toHtml -- Task 25: block-scoped

Hi

'; + const { html } = toHtml({ code }, ''); + expect(html).toBe(purifyHtml(code)); + }); + + test('a ` -- a very plausible + // paste, style-before-markup is a common snippet shape -- would vanish + // with no error anywhere, despite ` + * restyles their own block, never the rest of the page. + * + * Hand-rolled, dependency-free tokenise-then-transform parser -- same shape + * as `formatHtml` in `./format-html.ts` (read that file's header first; it + * documents why a naive regex approach breaks on this class of problem and + * went through three fix rounds to get right). The key primitive shared by + * both scanners is "walk forward, but treat `{`/`}`/`,`/`@` inside a CSS + * comment or a quoted string as inert text, not syntax" -- see + * `skipCommentOrString` below. + * + * What this does NOT do (by design, not oversight -- see each branch): + * - `@keyframes` bodies are copied through untouched. Their selectors + * (`from`, `to`, `50%`) are keyframe offsets, not element selectors -- + * scoping them would break the animation. The animation `name` stays + * global (a documented wart: two blocks using the same `@keyframes name` + * collide) -- fixing that would mean rewriting every `animation-name` + * declaration too, which is out of scope here. + * - `@font-face` has no selector at all; copied through untouched. + * - `@import` is stripped entirely (network fetch + exfiltration channel, + * and its remote rules would never go through this scoper anyway). + * - `@media`/`@supports`/`@container` keep their own prelude (the + * `(min-width: ...)` condition etc.) untouched; only the selectors + * *inside* the block are recursively scoped. + * - `:root`, `html`, `body` map to `scopeSelector` itself (not + * `${scopeSelector} html`, which would match nothing -- `html` is an + * ancestor of the wrapper, not a descendant). This is how a customer's + * `:root { --brand: ... }` custom-property block keeps working instead of + * silently doing nothing. + * - Any other at-rule with a `{ }` body (`@page`, `@layer`, ...) is copied + * through untouched -- deliberately conservative rather than guessing at + * a selector-scoping rule for at-rules this task doesn't enumerate. + */ + +/** + * If a CSS comment (`/* ... *\/`) or a single/double-quoted string starts at + * `css[i]`, returns the index immediately after it. Otherwise returns `i` + * unchanged (caller treats `css[i]` as ordinary syntax). An unterminated + * comment/string consumes to the end of the string rather than looping + * forever or mis-splitting the remainder. + */ +function skipCommentOrString(css: string, i: number): number { + if (css.charCodeAt(i) === 47 /* '/' */ && css[i + 1] === '*') { + const end = css.indexOf('*/', i + 2); + return end === -1 ? css.length : end + 2; + } + const ch = css[i]; + if (ch === '"' || ch === "'") { + let j = i + 1; + while (j < css.length) { + if (css[j] === '\\') { + j += 2; + continue; + } + if (css[j] === ch) return j + 1; + j += 1; + } + return css.length; + } + return i; +} + +/** Split `text` on top-level commas -- respecting comments, strings, and + * parens (so `:not(h1, h2)` doesn't get split into two selectors). */ +function splitTopLevelCommas(text: string): string[] { + const parts: string[] = []; + let start = 0; + let depth = 0; + let i = 0; + while (i < text.length) { + const skip = skipCommentOrString(text, i); + if (skip !== i) { + i = skip; + continue; + } + const ch = text[i]; + if (ch === '(') depth += 1; + else if (ch === ')') depth = Math.max(0, depth - 1); + else if (ch === ',' && depth === 0) { + parts.push(text.slice(start, i)); + i += 1; + start = i; + continue; + } + i += 1; + } + parts.push(text.slice(start)); + return parts; +} + +const ROOT_LIKE = new Set([':root', 'html', 'body']); + +/** Scope a single selector (no top-level commas left in it). */ +function scopeSingleSelector(selector: string, scopeSelector: string): string { + const trimmed = selector.trim(); + if (trimmed === '') return trimmed; + + const lower = trimmed.toLowerCase(); + // `:root`/`html`/`body` target an ancestor OUTSIDE the block -- map to the + // scope root itself rather than prefixing (`${scopeSelector} html` would + // match nothing, since html is never a descendant of the wrapper div). + if (ROOT_LIKE.has(lower)) return scopeSelector; + + // Idempotency guard (Constraint: running scopeCss twice must not + // double-prefix). Our own output only ever takes two shapes: the bare + // scope selector (from the ROOT_LIKE branch above) or + // `${scopeSelector} ${original}` (the branch below) -- recognising both + // is enough to make a second pass a no-op. + if (trimmed === scopeSelector || trimmed.startsWith(`${scopeSelector} `)) { + return trimmed; + } + + return `${scopeSelector} ${trimmed}`; +} + +/** Scope a comma-separated selector list, e.g. `h1, h2 > p`. */ +function scopeSelectorList(selectorList: string, scopeSelector: string): string { + return splitTopLevelCommas(selectorList) + .map((s) => scopeSingleSelector(s, scopeSelector)) + .join(', '); +} + +/** + * Scope a rule's raw prelude text (as sliced straight out of the source, + * still carrying whatever whitespace sat between the previous rule and the + * selector, and between the selector and the `{`). Only the trimmed core is + * run through `scopeSelectorList`; the original leading/trailing whitespace + * envelope is preserved around it so scoping doesn't visibly reformat the + * customer's CSS (e.g. `h1 {` stays `${scope} h1 {`, not `${scope} h1{`). + */ +function scopeRulePrelude(prelude: string, scopeSelector: string): string { + const leadLen = prelude.length - prelude.replace(/^\s+/, '').length; + const trailLen = prelude.length - prelude.replace(/\s+$/, '').length; + const lead = prelude.slice(0, leadLen); + const core = prelude.slice(leadLen, prelude.length - trailLen); + const trail = trailLen ? prelude.slice(prelude.length - trailLen) : ''; + return lead + scopeSelectorList(core, scopeSelector) + trail; +} + +const KEYFRAMES_RE = /^@(-\w+-)?keyframes\b/i; +const FONT_FACE_RE = /^@font-face\b/i; +const RECURSE_RE = /^@(media|supports|container)\b/i; +const IMPORT_RE = /^@import\b/i; + +/** + * Recursively transform one nesting level of CSS text: a sequence of + * `selector { declarations }` rules and/or at-rules. Used both for the + * top-level stylesheet and, recursively, for the body of a `@media`/ + * `@supports`/`@container` block (whose contents are themselves a nested + * sequence of rules). + */ +function transformBlock(css: string, scopeSelector: string): string { + let result = ''; + let i = 0; + const n = css.length; + + while (i < n) { + const start = i; + + // Scan the prelude (selector list, or an at-rule's condition/name) up to + // the next top-level `{`, `;`, or stray `}` -- comment/string aware so a + // brace or semicolon inside a comment or quoted string is inert. + let j = i; + while (j < n) { + const skip = skipCommentOrString(css, j); + if (skip !== j) { + j = skip; + continue; + } + const ch = css[j]; + if (ch === '{' || ch === ';' || ch === '}') break; + j += 1; + } + + if (j >= n) { + // Trailing content with no terminator (whitespace, or a syntax error + // in the customer's CSS) -- copy through verbatim rather than drop it. + result += css.slice(start); + break; + } + + if (css[j] === '}') { + // Stray close-brace with no matching open at this level -- shouldn't + // happen for well-formed input, but copy it through rather than + // fabricate a parse error or lose customer content. + result += css.slice(start, j + 1); + i = j + 1; + continue; + } + + const prelude = css.slice(start, j); + const trimmedPrelude = prelude.trim(); + + if (css[j] === ';') { + // Statement at-rule, e.g. `@import url(...);` or `@charset "UTF-8";`. + if (IMPORT_RE.test(trimmedPrelude)) { + // Strip @import entirely: it fetches remote CSS outside this + // function's scoping guarantee, and is a plain exfiltration + // channel (the fetch itself leaks referrer/cookies/IP to whatever + // host the customer -- or an attacker who edited their block -- + // points it at). + } else { + result += css.slice(start, j + 1); + } + i = j + 1; + continue; + } + + // css[j] === '{' : find the matching close brace for THIS block, + // comment/string aware, counting nested braces (a @media block's body + // contains further `{ }` rule blocks). + const blockContentStart = j + 1; + let depth = 1; + let k = blockContentStart; + while (k < n && depth > 0) { + const skip = skipCommentOrString(css, k); + if (skip !== k) { + k = skip; + continue; + } + const ch = css[k]; + if (ch === '{') depth += 1; + else if (ch === '}') depth -= 1; + k += 1; + } + // k is now just past the matching '}' (or end of string if unterminated). + const blockContentEnd = depth === 0 ? k - 1 : k; + const body = css.slice(blockContentStart, blockContentEnd); + + if (KEYFRAMES_RE.test(trimmedPrelude) || FONT_FACE_RE.test(trimmedPrelude)) { + // @keyframes: body selectors are keyframe offsets (from/to/50%), not + // element selectors -- scoping them would break the animation. + // @font-face: has no selector to scope at all. + // Either way: copy the whole block through untouched. + result += prelude + '{' + body + '}'; + } else if (RECURSE_RE.test(trimmedPrelude)) { + // @media/@supports/@container: keep the condition prelude as-is, + // recursively scope the selectors nested inside. + result += prelude + '{' + transformBlock(body, scopeSelector) + '}'; + } else if (trimmedPrelude.startsWith('@')) { + // Any other braced at-rule (@page, @layer, ...): conservatively leave + // untouched rather than guess at a scoping rule this task doesn't + // enumerate. + result += prelude + '{' + body + '}'; + } else { + // Ordinary rule: prelude is a selector list. + result += scopeRulePrelude(prelude, scopeSelector) + '{' + body + '}'; + } + + i = k; + } + + return result; +} + +/** + * Rewrite `css` so every rule only matches inside `scopeSelector` (expected + * to be a single class selector like `.whp-html-1a2b3c4d`, matching the + * Custom HTML block's own wrapper element). Pure function: same + * `(css, scopeSelector)` in, same string out, every time -- see the module + * doc comment above for the exact at-rule/selector rules applied. + */ +export function scopeCss(css: string, scopeSelector: string): string { + if (!css) return ''; + return transformBlock(css, scopeSelector); +}