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('')}${name}>`;
+ }),
+}));
+
+const SVG_OPEN = ``;
+ 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](javacript: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](javacript: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${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].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(')'), [
- '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(''), []);
assert.deepEqual(reasons(''), []);
- assert.deepEqual(reasons(''), []);
+ 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', ''],
- [
- '