diff --git a/.cspell.yml b/.cspell.yml index f50df2cf..42673f6e 100644 --- a/.cspell.yml +++ b/.cspell.yml @@ -34,6 +34,15 @@ words: - retag - srcdoc - vbscript + # Inkscape sodipodi:namedview attributes on the SVG metadata allowlist. + - bordercolor + - borderopacity + - gridtolerance + - guidetolerance + - objecttolerance + - pagecolor + - showgrid + - rescans - endusers - docusaurus - kubestellar diff --git a/CONTENT-SECURITY.md b/CONTENT-SECURITY.md index 11b40ea5..16bc9946 100644 --- a/CONTENT-SECURITY.md +++ b/CONTENT-SECURITY.md @@ -85,31 +85,98 @@ Two consequences matter when a submission is rejected: omitted: it is an effectively irreversible commitment over the apex domain and belongs to whoever administers `cncf.io`. - A parser can still disagree with a browser on exotic input. The gates fail - closed on anything the parser rejects, and every historical bypass is kept as - a regression fixture in `tests/svg-active-content.test.mjs` and - `tests/mdx-active-content.test.mjs`. A new bypass belongs in those fixtures - and, if it needs a new rule, in the parser walk, not in a new regular - expression. + closed on anything the parser rejects, and both gates are allowlists: an + element, attribute, node type or URL scheme is rejected unless it is listed + below, so a construct nobody has thought of yet fails rather than passes. + `tests/active-content-allowlist.test.mjs` generates arbitrary names and + schemes and checks that nothing outside the lists survives either gate, and + keeps the historical bypasses as named rows. Widening a list is a reviewed + change to the module that owns it and to this page, which a contract test + holds to the module. +- Inside an allowed `style` attribute or `` inside CDATA is data and `url(` is `url(`. * * @param {object[]} events - * @returns {string[]} + * @returns {Array<{ event: object, css: string }>} */ function styleBlocks(events) { const blocks = []; @@ -249,21 +531,21 @@ function styleBlocks(events) { for (const event of events) { if (event.type === 'open' && localName(event.name) === 'style') { open.push(blocks.length); - blocks.push(''); + blocks.push({ event, css: '' }); } else if (event.type === 'close' && localName(event.name) === 'style') { open.pop(); } else if ( (event.type === 'text' || event.type === 'cdata') && open.length ) { - blocks[open.at(-1)] += event.value; + blocks[open.at(-1)].css += event.value; } } return blocks; } /** - * Collect the remote targets in a fragment of CSS. + * Collect every URL a fragment of CSS makes the browser fetch. * * The fragment is read through the CSS Syntax tokenizer, which is the same * tokenization a browser performs before it interprets anything: comments are @@ -281,13 +563,13 @@ function styleBlocks(events) { * reported rather than ignored. * * @param {string} css - * @returns {{ targets: string[], malformed: boolean }} + * @returns {{ urls: string[], malformed: boolean }} */ -function cssTargets(css) { +function cssUrls(css) { // Every fetching construct needs a literal `(` or `@`, which no escape can // supply, so a value without either cannot contain one. This only avoids // tokenizing large `d` and base64 attribute values for nothing. - if (!/[(@]/.test(css)) return { targets: [], malformed: false }; + if (!/[(@]/.test(css)) return { urls: [], malformed: false }; const tokens = []; tokenize(css, (type, start, end) => { @@ -296,12 +578,9 @@ function cssTargets(css) { } }); - const targets = []; + const urls = []; let malformed = false; - const add = (value) => { - const target = remoteTarget(value); - if (target) targets.push(target); - }; + const add = (value) => urls.push(value); for (let index = 0; index < tokens.length; index += 1) { const { type, text } = tokens[index]; @@ -350,7 +629,7 @@ function cssTargets(css) { add(string.decode(tokens[index + 1].text)); } } - return { targets, malformed }; + return { urls, malformed }; } /** @@ -373,8 +652,8 @@ export function findRemoteReferences(source) { for (const error of errors.slice(0, 1)) findings.add(notWellFormed(error)); const report = (css, subject) => { - const { targets, malformed } = cssTargets(css); - for (const target of targets) { + const { urls, malformed } = cssUrls(css); + for (const target of urls.map(remoteTarget).filter(Boolean)) { findings.add( `references a remote resource in ${subject}: ${describeTarget(target)}`, ); @@ -388,14 +667,14 @@ export function findRemoteReferences(source) { if (event.type !== 'open') continue; const element = localName(event.name); - for (const [rawName, value] of event.attributes) { + for (const { name: rawName, value } of event.attributes) { const name = localName(rawName); const label = rawName.toLowerCase(); - if ( - (name === 'href' && !NAVIGATION_ELEMENTS.has(element)) || - FETCHED_ATTRIBUTES.has(name) - ) { + // `src`, `srcset`, `action` and the like are not on the attribute + // allowlist at all, so findActiveContent() rejects them whatever their + // value; only `href` is both allowed and fetched. + if (name === 'href' && !NAVIGATION_ELEMENTS.has(element)) { const target = remoteTarget(value); if (target) { findings.add( @@ -405,28 +684,6 @@ export function findRemoteReferences(source) { continue; } - if (SUBMISSION_ATTRIBUTES.has(name)) { - const target = remoteTarget(value); - if (target) { - findings.add( - `submits to a remote host in <${element}> ${label}: ${describeTarget(target)}`, - ); - } - continue; - } - - if (name === SRCSET_ATTRIBUTE) { - for (const candidate of value.split(/[\s,]+/)) { - const target = remoteTarget(candidate); - if (target) { - findings.add( - `references a remote resource in <${element}> ${label}: ${describeTarget(target)}`, - ); - } - } - continue; - } - // Every attribute value, not just `style`. SVG presentation attributes // (`fill`, `filter`, `mask`, `clip-path`, `marker-*`, ...) take the same // `url(...)` syntax as the CSS property of the same name, so @@ -441,7 +698,7 @@ export function findRemoteReferences(source) { } } - for (const block of styleBlocks(events)) report(block, 'a ` + : ''; + return `<${name}${declared}${attrs}>${css}${children.join('')}`; + }), +})); + +const SVG_OPEN = ``; +// Declares the `p:` prefix the generators use, which is itself off the list. +const SVG_OPEN_P = ``; + +function attributeAllowed(element, { uri, local, value }) { + if (uri === 'http://www.w3.org/2000/xmlns/') return true; + if (uri === XLINK_NS) return svgGate.XLINK_ATTRIBUTES.has(local); + if (uri === XML_NS) return svgGate.XML_ATTRIBUTES.has(local); + if (uri) return svgGate.METADATA_NAMESPACES.has(uri); + if (/^data-/.test(local)) return true; + if (element.uri === SVG_NS) { + return ( + svgGate.SVG_ATTRIBUTES.has(local) || + (element.local === 'svg' && svgGate.SVG_ROOT_ATTRIBUTES.has(local)) + ); + } + if (element.uri === XHTML_NS) return svgGate.XHTML_ATTRIBUTES.has(local); + return svgGate.METADATA_ATTRIBUTES.has(local) && value !== undefined; +} + +/** Everything in `source` that is outside the SVG allowlists. */ +function svgViolations(source) { + const violations = []; + const raster = { rasterData: true }; + const urlsIn = (value) => + [...value.matchAll(/url\(\s*['"]?([^'")]*)/gi)].map((match) => match[1]); + const parser = sax.parser(true, { xmlns: true }); + parser.onopentag = (tag) => { + const allowed = + (tag.uri === SVG_NS && svgGate.SVG_ELEMENTS.has(tag.local)) || + (tag.uri === XHTML_NS && svgGate.XHTML_ELEMENTS.has(tag.local)) || + svgGate.METADATA_NAMESPACES.has(tag.uri); + if (!allowed) violations.push(`<${tag.name}>`); + for (const attribute of Object.values(tag.attributes)) { + if (!attributeAllowed(tag, attribute)) violations.push(attribute.name); + const urls = urlsIn(attribute.value); + if (attribute.local === 'href') urls.push(attribute.value); + for (const value of urls) { + if (disallowedScheme(value, raster)) violations.push(value); + } + } + }; + parser.ontext = (text) => { + for (const value of urlsIn(text)) { + if (disallowedScheme(value, raster)) violations.push(value); + } + }; + parser.write(source).close(); + return violations; +} + +test('stripActiveContent output contains nothing outside the SVG allowlists', () => { + fc.assert( + fc.property( + pick(SVG_OPEN, SVG_OPEN_P), + fc.array(svgTree, { maxLength: 3 }), + (open, children) => { + const source = `${open}${children.join('')}`; + let output; + try { + output = svgGate.stripActiveContent(source).source; + } catch (error) { + // Failing closed is an allowed outcome; publishing is not. + assert.match( + error.message, + /^(Could not strip|Active content survived)/, + ); + return; + } + assert.deepEqual(svgViolations(output), []); + assert.deepEqual(svgGate.findActiveContent(output), []); + // The detector and the stripper agree: a clean input is left untouched. + if (svgGate.findActiveContent(source).length === 0) { + assert.equal(output, source); + } + }, + ), + RUNS, + ); +}); + +// --- MDX ------------------------------------------------------------------- + +const GRAMMAR = { + extensions: [mdxjs(), gfm()], + mdastExtensions: [mdxFromMarkdown(), gfmFromMarkdown()], +}; +const IMPORT = + "import CNCFProjectCard from '@site/src/components/CNCFProjectCard';"; + +const mdxName = fc.oneof( + { arbitrary: pick(mdxGate.ALLOWED_COMPONENT), weight: 3 }, + pick(...mdxGate.ALLOWED_ELEMENTS), + pick('script', 'img', 'iframe', 'B', 'div'), + word, +); +const mdxAttribute = fc.tuple( + fc.oneof( + { arbitrary: pick(...mdxGate.COMPONENT_ATTRIBUTES), weight: 4 }, + pick('onClick', 'style'), + word, + ), + fc.oneof( + url.map((value) => `"${value}"`), + url.map((value) => `{${JSON.stringify(value)}}`), + pick('{x}', '{"a" + b}', '{`t`}'), + ), +); +const mdxBlock = fc.oneof( + { + arbitrary: fc + .tuple(mdxName, fc.array(mdxAttribute, { maxLength: 2 })) + .map(([name, attributes]) => { + const attrs = attributes + .map(([key, value]) => ` ${key}=${value}`) + .join(''); + return `<${name}${attrs}>x`; + }), + weight: 5, + }, + fc + .tuple(pick('[x]', '![x]'), url) + .map(([kind, value]) => `${kind}(${value})`), + url.map((value) => `[d]: ${value}\n\n[d]`), + fc + .tuple(pick('mdx-code-block', 'js', '', 'mdx-code-blockish'), word) + .map(([info, body]) => `\`\`\`${info}\n<${body}/>\n\`\`\``), + pick(IMPORT, 'export const x = 1;', '{1 + 1}', '$x$', ''), + word, +); +const mdxBody = fc + .tuple(fc.boolean(), fc.array(mdxBlock, { minLength: 1, maxLength: 4 })) + .map(([imported, blocks]) => + (imported ? [IMPORT, ...blocks] : blocks).join('\n\n'), + ); + +/** Everything in `body` that is outside the MDX allowlists. */ +function mdxViolations(body) { + const violations = []; + const flagScheme = (value) => { + if (typeof value === 'string' && disallowedScheme(value)) { + violations.push(value); + } + }; + eachNode(fromMarkdown(body, GRAMMAR), (node) => { + if (mdxGate.MARKDOWN_NODE_TYPES.has(node.type)) { + if (node.type === 'code' && isUnwrappedCodeBlock(body, node)) { + violations.push('mdx-code-block'); + } + flagScheme(node.url); + } else if (node.type === 'mdxjsEsm' && node.value.trim() === IMPORT) { + // The one allowed statement. + } else if (node.type.startsWith('mdxJsx')) { + const allowed = + node.name === mdxGate.ALLOWED_COMPONENT + ? mdxGate.COMPONENT_ATTRIBUTES + : mdxGate.ALLOWED_ELEMENTS.has(node.name) + ? new Set() + : null; + if (!allowed) violations.push(`<${node.name}>`); + for (const attribute of node.attributes) { + if (!allowed?.has(attribute.name)) violations.push(attribute.name); + let { value } = attribute; + if (typeof value === 'object' && value) { + try { + value = JSON.parse(value.value); + } catch { + violations.push(value.value); + } + } + // Only these two are rendered as URLs; the rest are text. + if (['href', 'logo'].includes(attribute.name)) flagScheme(value); + } + } else { + violations.push(node.type); + } + }); + return violations; +} + +test('a body the publish-time MDX gate accepts is within the MDX allowlists', () => { + fc.assert( + fc.property(mdxBody, (body) => { + if (mdxGate.findActiveContent(body).length === 0) { + assert.deepEqual(mdxViolations(body), []); + } + }), + RUNS, + ); +}); + +test('escapeMdx output contains only Markdown node types', () => { + fc.assert( + fc.property(fc.oneof(mdxBody, fc.string()), (text) => { + const types = new Set(); + eachNode(fromMarkdown(escapeMdx(text), GRAMMAR), (node) => + types.add(node.type), + ); + for (const type of types) { + assert.ok(mdxGate.MARKDOWN_NODE_TYPES.has(type), type); + } + }), + RUNS, + ); +}); + +// --- Historical bypasses of the denylists ---------------------------------- + +const svgRow = (body) => () => { + const source = `${SVG_OPEN}${body}`; + const findings = svgGate.findActiveContent(source); + const output = svgGate.stripActiveContent(source).source; + return { findings, clean: svgGate.findActiveContent(output) }; +}; +const mdxRow = (body) => () => ({ + findings: mdxGate.findActiveContent(body).map(({ reason }) => reason), + clean: [], +}); + +const BYPASSES = [ + [ + 'an SVG stripped to inert markup is rescanned before it is returned', + svgRow(''), + [ + 'contains a disallowed attribute onload on ', + 'contains a disallowed element ', + '', ); const result = mirrorLandscapeLogo({ record: RECORD, @@ -147,7 +147,7 @@ test('rejects a logo that would fetch remote resources', () => { withRoots(({ sourceRoot, destinationRoot }) => { writeFileSync( join(sourceRoot, 'hosted_logos/acme.svg'), - '', + '', ); const result = mirrorLandscapeLogo({ record: RECORD, diff --git a/tests/import-architecture-issue.test.mjs b/tests/import-architecture-issue.test.mjs index 54b4d801..bb9099ce 100644 --- a/tests/import-architecture-issue.test.mjs +++ b/tests/import-architecture-issue.test.mjs @@ -217,7 +217,7 @@ test('mirrors a cncf/artwork project logo and falls back for other hosts', () => ), fetchResponses: { 'https://raw.githubusercontent.com/cncf/artwork/main/projects/kubernetes/icon/color/kubernetes-icon-color.svg': - { status: 200, body: '' }, + { status: 200, body: '' }, }, }); try { diff --git a/tests/mdx-active-content.test.mjs b/tests/mdx-active-content.test.mjs index 44b4e8c9..131afb3e 100644 --- a/tests/mdx-active-content.test.mjs +++ b/tests/mdx-active-content.test.mjs @@ -19,72 +19,163 @@ test('accepts prose, inert inline tags and the importer-generated preamble', () assert.deepEqual(findActiveContent(body), []); }); -test('flags script elements, event handlers and script-capable URLs', () => { - assert.deepEqual(reasons(''), [ - 'disallowed element ', + ['disallowed element `'), []); @@ -195,69 +284,6 @@ test('tolerates empty and nullish input', () => { assert.deepEqual(findActiveContent(undefined), []); }); -test('leaves an out-of-range hex entity literal rather than forming a scheme', () => { - // String.fromCodePoint throws RangeError above U+10FFFF, so the `code <= - // 0x10ffff` guard is what keeps a hostile body from crashing the validator - // that is supposed to reject it. The entity stays literal, and the literal - // is not itself read as a scheme. - assert.deepEqual(reasons('[click](java�cript:alert(1))'), []); - assert.deepEqual(reasons('Budget rose by � percent.'), []); - - // The true arm must still decode, so the guard cannot be "satisfied" by - // rejecting hex entities wholesale: : is a colon, and decoding it is - // what turns the payload below into a scheme the scanner reports. - assert.deepEqual(reasons('[click](javascript:alert(1))'), [ - 'script-capable URL scheme', - ]); -}); - -test('leaves an out-of-range decimal entity literal rather than forming a scheme', () => { - assert.deepEqual(reasons('[click](java�cript:alert(1))'), []); - - // As above: the decimal true arm must keep decoding in-range references. - assert.deepEqual(reasons('[click](javascript:alert(1))'), [ - 'script-capable URL scheme', - ]); -}); - -test('leaves a non-finite entity literal rather than forming a scheme', () => { - // A digit run long enough to overflow to Infinity takes the - // Number.isFinite arm of the guard rather than the range comparison, so - // both halves of `Number.isFinite(code) && code <= 0x10ffff` are load - // bearing. Number.parseInt returns Infinity here, and String.fromCodePoint - // would throw on it. - const hugeHex = 'f'.repeat(400); - const hugeDecimal = '9'.repeat(400); - assert.deepEqual(reasons(`[click](java&#x${hugeHex};cript:alert(1))`), []); - assert.deepEqual(reasons(`[click](java&#${hugeDecimal};cript:alert(1))`), []); -}); - -test('an undecodable entity does not mask a scheme elsewhere on the line', () => { - // The fallback returns the unmatched text rather than consuming it, so a - // rejected reference cannot be used as a shield in front of a live scheme. - assert.deepEqual(reasons('[click](�javascript:alert(1))'), [ - 'script-capable URL scheme', - ]); - assert.deepEqual(reasons('[click](�javascript:alert(1))'), [ - 'script-capable URL scheme', - ]); -}); - -test('leaves an unrecognized named entity literal rather than dropping it', () => { - // The named-entity table is an allowlist, and its fallback returns the - // unmatched text. Dropping an unrecognized reference instead would splice - // the characters on either side together, so `java script:` would be - // reported as a live `javascript:` scheme that no browser would parse. - assert.deepEqual(reasons('[click](java script:alert(1))'), []); - assert.deepEqual(reasons('Costs rose 5 percent this quarter.'), []); - - // The allowlisted arm must still substitute, or a genuinely hidden colon - // would stop being reported. - assert.deepEqual(reasons('[click](javascript:alert(1))'), [ - 'script-capable URL scheme', - ]); -}); - test('flags an MDX expression, which Docusaurus compiles to executable JavaScript', () => { assert.deepEqual( reasons('{(() => { document.location = "https://evil.example"; })()}'), @@ -301,15 +327,6 @@ test('flags an attribute expression that is more than a string literal', () => { ]); }); -test('still scans the value inside an allowed attribute expression', () => { - // Only the braces are neutralized, so a script URI smuggled into a prop - // value is still reported rather than hidden by the allowance. - assert.deepEqual( - reasons(''), - ['script-capable URL scheme'], - ); -}); - test('does not flag braces inside code, which MDX does not evaluate', () => { assert.deepEqual(reasons('Run `kubectl get pods -o {.items}` to list.'), []); assert.deepEqual( @@ -332,7 +349,7 @@ test('flags live content wedged between two multi-line code spans', () => { 'MDX expression', ]); assert.deepEqual(reasons('`a\nb` [x](javascript:alert(1)) `c\nd`'), [ - 'script-capable URL scheme', + 'disallowed URL scheme javascript:', ]); }); @@ -382,35 +399,11 @@ test('still pairs a code span across a lazy paragraph continuation', () => { assert.deepEqual(reasons('- item `x\n plain y` b'), []); }); -test('rejects text MDX cannot compile instead of certifying it', () => { - assert.match(reasons('
unclosed')[0], /^MDX parse error/); - assert.equal(findActiveContent('
unclosed')[0].line, 1); -}); - -test('flags script schemes in definitions, images and JSX attributes', () => { - assert.deepEqual(reasons('[x]: javascript:alert(1)\n\n[x]'), [ - 'script-capable URL scheme', - ]); - assert.deepEqual(reasons('![x](javascript:alert(1))'), [ - 'script-capable URL scheme', - ]); - assert.deepEqual(reasons('x'), [ - 'script-capable URL scheme', - ]); - assert.deepEqual(reasons('x'), [ - 'script-capable URL scheme', - ]); - assert.deepEqual(reasons('x'), ['MDX expression']); - assert.deepEqual(reasons('x'), [ - 'event handler attribute', +test('accepts a string-literal escape only when it parses as JSON', () => { + assert.deepEqual(reasons(''), []); + assert.deepEqual(reasons(''), [ + 'MDX expression', ]); - assert.deepEqual(reasons('x'), ['MDX expression']); - assert.deepEqual(reasons('x'), []); -}); - -test('reports a fragment and an invalid string-literal expression', () => { - assert.deepEqual(reasons('<>x'), ['disallowed element <>']); - assert.deepEqual(reasons('x'), ['MDX expression']); }); test('reports a parse error on the line it occurs', () => { @@ -450,21 +443,9 @@ test('resolves an image reference through its definition', () => { ]); }); -test('resolves a repeated label to its first definition, as the renderer does', () => { - // CommonMark gives a repeated label to the first definition, and - // mdast-util-to-hast -- which @mdx-js/mdx runs to compile the page -- fills - // its map under `if (!map.has(id))` to mimic exactly that. Resolving to the - // last one instead made this scan vouch for a destination the published page - // discards: the pair below reported nothing while the compiled page carried - // src="https://evil.example/p.png". - assert.deepEqual( - reasons( - '![p][d]\n\n[d]: https://evil.example/p.png\n[d]: /img/architectures/acme/p.png', - ), - ['remote image destination https://evil.example/p.png'], - ); - // The mirror case: the remote duplicate is the one the renderer discards, so - // reporting it would reject a page that loads nothing remote. +test('ignores a remote duplicate definition, which the renderer discards', () => { + // A repeated label resolves to its first definition, so only the local + // image below is ever loaded. assert.deepEqual( reasons( '![p][d]\n\n[d]: /img/architectures/acme/p.png\n[d]: https://evil.example/p.png', @@ -486,7 +467,7 @@ test('leaves links, link definitions and local images alone', () => { assert.deepEqual(reasons('[x][d]\n\n[d]: https://good.example/'), []); assert.deepEqual(reasons('![p](/img/architectures/acme/p.png)'), []); assert.deepEqual(reasons('![p](images/p.png)'), []); - assert.deepEqual(reasons('![p](data:image/png;base64,AAAA)'), []); + assert.deepEqual(reasons('[m](mailto:a@example.com) [f](#top)'), []); }); test('flags a remote logo on the allowed component but not its remote href', () => { @@ -527,44 +508,10 @@ test('matches the element allowlist case-sensitively, as MDX reads tag names', ( assert.deepEqual(reasons('
'), []); }); -test('flags a string-valued style attribute, which React refuses to render', () => { - // React requires `style` to be an object and throws "The `style` prop - // expects a mapping from style properties to values, not a string" when it - // is given a string, so both of these fail while Docusaurus prerenders. - assert.deepEqual(reasons('

x

'), [ - 'string style attribute', - ]); - // `style={"..."}` is an expression whose body is a single string literal, - // which this module otherwise reads as inert -- it reaches React as a - // string just the same. - assert.deepEqual(reasons('

x

'), [ - 'string style attribute', - ]); - // The object form needs a non-literal expression and is already reported as - // one, so no style attribute reaches a rendered page by any route. - assert.deepEqual(reasons("

x

"), [ - 'MDX expression', - ]); -}); - // Docusaurus's mdx-loader runs unwrapMdxCodeBlocks() on every page before the // MDX compiler sees it, deleting the fences of a ```mdx-code-block block and -// leaving its body behind as flow content. A scan that reads that fence as -// code vouches for a body the compiler executes: a built page carried a live -// remote and an evaluated `{...}` expression out of one. -test('reports an mdx-code-block fence, whose body Docusaurus compiles as MDX', () => { - const body = [ - '## Overview', - '', - '```mdx-code-block', - '', - '```', - ].join('\n'); - assert.deepEqual(reasons(body), [ - 'mdx-code-block fence, whose body Docusaurus compiles as MDX rather than displaying as code', - ]); -}); - +// leaving its body behind as flow content, so the fence is reported in every +// form Docusaurus unwraps. test('reports the four-backtick and indented forms Docusaurus also unwraps', () => { const fourBackticks = ['````mdx-code-block', 'x', '````'].join('\n'); assert.deepEqual(reasons(fourBackticks), [ diff --git a/tests/svg-active-content.test.mjs b/tests/svg-active-content.test.mjs index 879a4d10..4171f728 100644 --- a/tests/svg-active-content.test.mjs +++ b/tests/svg-active-content.test.mjs @@ -11,413 +11,172 @@ import { const INERT = ''; +const SVG_OPEN = + ''; +const svg = (body) => `${SVG_OPEN}${body}`; + test('reports nothing for an inert SVG', () => { assert.deepEqual(findActiveContent(INERT), []); assert.deepEqual(stripActiveContent(INERT), { source: INERT, removed: [] }); }); -test('leaves ordinary http links and styling attributes alone', () => { - const { source } = stripActiveContent(INERT); - assert.match(source, /href="https:\/\/example\.com\/docs"/); - assert.match(source, /fill="#fff"/); -}); - -test('detects and removes script elements with their contents', () => { - const svg = INERT.replace('alert(1) element']); - - const { source, removed } = stripActiveContent(svg); - assert.doesNotMatch(source, /script/i); - assert.doesNotMatch(source, /alert\(1\)/); - assert.ok(removed.includes('', + ['contains a disallowed element ' + - '' + - ''; - assert.equal(findActiveContent(svg).length, 3); - - const { source, removed } = stripActiveContent(svg); - assert.ok(removed.length >= 3); - assert.deepEqual(findActiveContent(source), []); - assert.match(source, /viewBox="0 0 10 10"/); -}); - -test('flags an element that installs an href at runtime', () => { - const svg = - '' + - '' + - ''; - assert.ok( - findActiveContent(svg).includes( - 'contains a element that animates href (can install a script URI at runtime)', - ), - ); - assert.deepEqual(findActiveContent(stripActiveContent(svg).source), []); -}); - -test('flags a paired element animating xlink:href and leaves no orphan tag', () => { - const svg = - '' + - '' + - ''; - assert.ok( - findActiveContent(svg).some((finding) => finding.includes('animates href')), - ); - const { source } = stripActiveContent(svg); - assert.doesNotMatch(source, /<\s*\/?\s*set\b/i); - assert.match(source, //); - assert.deepEqual(findActiveContent(source), []); -}); - -test('leaves an ordinary animation of a presentation attribute alone', () => { - const svg = - '' + - '' + - ''; - assert.deepEqual(findActiveContent(svg), []); - assert.equal(stripActiveContent(svg).source, svg); -}); - -// Standalone SVG under static/ is served as image/svg+xml and parsed as XML, -// where a namespace prefix is arbitrary and only the URI it binds matters: -// is a script element and runs. -// Anchoring the element patterns on the bare local name let a one-character -// edit walk an imported asset past both the CI gate and the import sanitizer. -test('detects and removes namespace-prefixed active elements', () => { - for (const [prefix, element, finding] of [ - ['svg', 'script', 'contains a '], - [' element', 'alert(1)'], - [' element', ''], - [ - '