Skip to content
Merged
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
88 changes: 76 additions & 12 deletions scripts/lib/mdx-active-content.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@
* A fenced code block is allowed because MDX evaluates nothing inside one --
* except for ```mdx-code-block, which Docusaurus unwraps into live MDX before
* the compiler runs (see ./mdx-code-blocks.mjs). That fence is not a code
* block at all, so it is a finding.
* block at all, so it is a finding. Because that unwrap decides for itself
* which fences pair, the scan is run over its output as well as over the text
* on disk; see `findActiveContent()`.
*
* The scan answers the second of the two questions `uri-safety.mjs` states
* both gates must answer -- does the value make a visitor's browser contact
Expand All @@ -56,7 +58,10 @@ import { mdxFromMarkdown } from 'mdast-util-mdx';
import { gfm } from 'micromark-extension-gfm';
import { gfmFromMarkdown } from 'mdast-util-gfm';
import { eachNode } from './mdast-walk.mjs';
import { isUnwrappedCodeBlock } from './mdx-code-blocks.mjs';
import {
isUnwrappedCodeBlock,
unwrapMdxCodeBlocks,
} from './mdx-code-blocks.mjs';
import {
describeTarget,
disallowedScheme,
Expand Down Expand Up @@ -183,18 +188,13 @@ function literalValue(expression) {
}

/**
* Scans a Markdown/MDX body for anything outside the allowlists, and for
* images loaded from another host.
* Scans one source text, reporting each finding at its line within that text.
*
* @param {string} markdown - Document body to scan.
* @param {{ mdastExtensions?: Array<object> }} [options] - Extra mdast
* extensions to parse with. A node kind they introduce is not on the
* allowlist, so it is reported rather than walked past.
* @returns {Array<{ line: number, reason: string, snippet: string }>} One entry
* per finding, ordered by line.
* @param {string} text - Document body to scan.
* @param {Array<object>} mdastExtensions - Extra mdast extensions.
* @returns {Array<{ line: number, reason: string, snippet: string }>}
*/
export function findActiveContent(markdown, { mdastExtensions = [] } = {}) {
const text = String(markdown ?? '');
function scanSource(text, mdastExtensions) {
const lines = text.split('\n');
const findings = [];
const report = (line, reason) =>
Expand Down Expand Up @@ -320,6 +320,70 @@ export function findActiveContent(markdown, { mdastExtensions = [] } = {}) {
}
});

return findings;
}

/**
* Maps each line of `unwrapped` back to its line number in `text`.
*
* `unwrapMdxCodeBlocks()` keeps a block's body verbatim and deletes only the
* opening and closing fence lines, so every line of the unwrapped document is
* a line of the source, in order, and a greedy forward walk recovers the
* original line numbers without restating the upstream patterns.
*
* @param {string} text - Source document.
* @param {string} unwrapped - `unwrapMdxCodeBlocks(text)`.
* @returns {number[]} Source line number for each line of `unwrapped`.
*/
function mapUnwrappedLines(text, unwrapped) {
const sourceLines = text.split('\n');
const mapping = [];
let cursor = 0;
for (const line of unwrapped.split('\n')) {
while (cursor < sourceLines.length && sourceLines[cursor] !== line) {
cursor += 1;
}
// Unreachable for a pure deletion; fall back to the last line rather than
// throw if the upstream rewrite ever changes shape.
mapping.push(Math.min(cursor + 1, sourceLines.length));
cursor += 1;
}
return mapping;
}

/**
* Scans a Markdown/MDX body for anything outside the allowlists, and for
* images loaded from another host.
*
* The body is scanned as written and again as `unwrapMdxCodeBlocks()` leaves
* it, because that rewritten text is what the MDX compiler is handed. The
* per-node `mdx-code-block` check only sees the span mdast gave the node, and
* the fence grammars disagree about where it ends: CommonMark closes a
* three-backtick fence with any run of three or more, while Docusaurus
* requires exactly three and pairs the opener with a later bare fence. A
* payload between those two closers sits inside a `code` node the scan trusts.
* Scanning the unwrapped text closes that gap for any such disagreement. This
* mirrors `hasActiveMdx()` in ./mdx-escape.mjs.
*
* @param {string} markdown - Document body to scan.
* @param {{ mdastExtensions?: Array<object> }} [options] - Extra mdast
* extensions to parse with. A node kind they introduce is not on the
* allowlist, so it is reported rather than walked past.
* @returns {Array<{ line: number, reason: string, snippet: string }>} One entry
* per finding, ordered by line.
*/
export function findActiveContent(markdown, { mdastExtensions = [] } = {}) {
const text = String(markdown ?? '');
const findings = scanSource(text, mdastExtensions);

const unwrapped = unwrapMdxCodeBlocks(text);
if (unwrapped !== text) {
const sourceLine = mapUnwrappedLines(text, unwrapped);
for (const finding of scanSource(unwrapped, mdastExtensions)) {
findings.push({ ...finding, line: sourceLine[finding.line - 1] });
}
}

const seen = new Set();
return findings
.filter((finding) => {
Expand Down
15 changes: 13 additions & 2 deletions scripts/lib/mdx-code-blocks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,19 @@ import { unwrapMdxCodeBlocks } from '@docusaurus/utils';
*
* The node's own source range is passed to `unwrapMdxCodeBlocks()`: the range
* begins at the opening fence and ends at the closing one, which is exactly
* the `^`/`$` anchoring its patterns allow, so the answer for the slice is the
* answer for the block in the whole document.
* the `^`/`$` anchoring its patterns allow, so a block that reads as unwrapped
* on its own slice is reported at its own line.
*
* The converse does not hold, so `false` here is not a clearance. mdast ends
* the node at the closing fence *CommonMark* chooses -- any run of three or
* more backticks -- while the unwrap pattern requires a closer of exactly
* three and searches on with `.*?` under `s` when it meets a longer one. An
* opener whose CommonMark closer is four backticks therefore pairs, in the
* full document, with a later bare fence outside this slice entirely. Callers
* must not read `false` as "Docusaurus leaves this page alone":
* `findActiveContent()` (./mdx-active-content.mjs) and `hasActiveMdx()`
* (./mdx-escape.mjs) both also scan `unwrapMdxCodeBlocks()` output, for this
* reason.
*
* @param {string} source Full document text.
* @param {{ position?: { start?: { offset?: number }, end?: { offset?: number } } }} node
Expand Down
1 change: 1 addition & 0 deletions tests/active-content-allowlist.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ const BYPASSES = [
),
[
'mdx-code-block fence, whose body Docusaurus compiles as MDX rather than displaying as code',
'disallowed element <img>',
],
],
[
Expand Down
49 changes: 49 additions & 0 deletions tests/mdx-active-content.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,55 @@ test('reports the four-backtick and indented forms Docusaurus also unwraps', ()
]);
});

test('reports active content exposed only when the whole document is unwrapped', () => {
// The two fence grammars disagree about which closer pairs with the opener.
// CommonMark ends the code node at the four-backtick line, so the node's own
// slice is not something unwrapMdxCodeBlocks() rewrites and the per-node
// check clears it. Docusaurus's pattern needs a closer of exactly three
// backticks, skips the four-backtick line under `.*?`/`s`, and pairs the
// opener with the bare fence at the end -- unwrapping the <img> into live
// MDX. Before the whole-document pass this body scanned clean, validated,
// and built to HTML carrying the remote <img> plus a preload link for it.
const body = [
'```mdx-code-block',
'<img src="https://beacon.invalid/px.png" />',
'````',
'',
'```',
].join('\n');
assert.deepEqual(findActiveContent(body), [
{
line: 2,
reason: 'disallowed element <img>',
snippet: '<img src="https://beacon.invalid/px.png" />',
},
]);
});

test('maps an unwrapped-pass finding back to its true source line', () => {
// Only the fence lines are deleted, so the unwrapped document's lines are a
// subsequence of the source's and the reported line must be the one a reader
// can open the file to, not the shifted line of the rewritten text.
const body = [
'## Overview',
'',
'Prose.',
'',
'```mdx-code-block',
'<iframe src="https://evil.test"></iframe>',
'````',
'',
'```',
].join('\n');
assert.deepEqual(findActiveContent(body), [
{
line: 6,
reason: 'disallowed element <iframe>',
snippet: '<iframe src="https://evil.test"></iframe>',
},
]);
});

test('leaves ordinary fenced and inline code alone', () => {
// The exemption is for the one info string Docusaurus rewrites, not for
// code blocks in general: an architecture page showing a YAML snippet with
Expand Down
Loading