Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
317df59
add strip-scripts rehype plugin
RAYMOND-LUO Jul 30, 2026
ad17127
strip script tags in mdx compile pipeline
RAYMOND-LUO Jul 30, 2026
80cf2f7
strip script tags in mdxish pipeline
RAYMOND-LUO Jul 30, 2026
c290b69
update raw-content test for script stripping
RAYMOND-LUO Jul 30, 2026
e03cac7
add script stripping tests for both engines
RAYMOND-LUO Jul 30, 2026
d9d746b
Merge branch 'next' into raymond/cx-3169-strip-script-tags
RAYMOND-LUO Aug 5, 2026
e27977b
use canonical node guards and visit predicate in strip-scripts
RAYMOND-LUO Aug 5, 2026
2505b64
add md format and HTMLBlock coverage for script stripping
RAYMOND-LUO Aug 5, 2026
0b7f046
Merge remote-tracking branch 'origin/raymond/cx-3169-strip-script-tag…
RAYMOND-LUO Aug 5, 2026
40fac50
narrow mdx jsx nodes by type
RAYMOND-LUO Aug 5, 2026
734dac9
append caller plugins instead of letting them replace the pipeline
RAYMOND-LUO Aug 6, 2026
9e90b45
skip traversal into removed script subtrees
RAYMOND-LUO Aug 6, 2026
d959491
cover foreign-content wrappers and caller plugin overrides
RAYMOND-LUO Aug 6, 2026
1efea9b
assert script removal with a nonempty caller plugin
RAYMOND-LUO Aug 6, 2026
a2fa2bb
gate tag stripping behind an opt-in sanitize option
RAYMOND-LUO Aug 6, 2026
77a6cb0
cover the sanitize option and the tag set across engines
RAYMOND-LUO Aug 6, 2026
2455601
group strip tag tests and cover tables, comments, and components
RAYMOND-LUO Aug 6, 2026
6df79cf
Merge remote-tracking branch 'origin/next' into raymond/cx-3169-strip…
RAYMOND-LUO Sep 17, 2026
27122fe
strip script tags authored as JSX in expressions and exports
RAYMOND-LUO Sep 17, 2026
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
262 changes: 262 additions & 0 deletions __tests__/plugins/strip-tags.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import type { Root } from 'hast';
import type { MDXContent } from 'mdx/types';

import React from 'react';
import { renderToString } from 'react-dom/server';
import { unified } from 'unified';
import { afterEach, describe, expect, it } from 'vitest';

import { compile, mdxish, renderMdxish } from '../../lib';
import { rehypeStripTags, STRIPPED_TAG_NAMES } from '../../processor/plugin/strip-tags';
import { execute, findElementByTagName } from '../helpers';

// Stripping is opt-in, so every engine here passes `sanitize: true`. The default
// (off) is covered by the `sanitize option` block below.
const primaryEngines = [
['mdx', (md: string) => execute(md, { sanitize: true }) as MDXContent] as const,
['mdxish', (md: string) => renderMdxish(mdxish(md, { sanitize: true })).default as MDXContent] as const,
];

const engines = [
...primaryEngines,
['md', (md: string) => execute(md, { format: 'md', sanitize: true }) as MDXContent] as const,
[
'mdxish newEditorTypes',
(md: string) => renderMdxish(mdxish(md, { newEditorTypes: true, sanitize: true })).default as MDXContent,
] as const,
[
'mdxish safeMode',
(md: string) => renderMdxish(mdxish(md, { safeMode: true, sanitize: true })).default as MDXContent,
] as const,
];

describe('strip tags', () => {
describe.each(engines)('%s engine', (_, render) => {
it('strips a bare <script> tag from page content', () => {
const Content = render('<script>alert(1)</script>');
const html = renderToString(<Content />);

expect(html).not.toContain('<script');
expect(html).not.toContain('alert(1)');
});

it('strips a <script> tag while preserving surrounding content', () => {
const Content = render('hello\n\n<script>alert(1)</script>\n\nworld');
const html = renderToString(<Content />);

expect(html).not.toContain('<script');
expect(html).toContain('hello');
expect(html).toContain('world');
});

it('strips a <script> tag nested inside other HTML', () => {
const Content = render('<div><script>alert(1)</script><span>safe</span></div>');
const html = renderToString(<Content />);

expect(html).not.toContain('<script');
expect(html).toContain('safe');
});

it('strips a <script> tag with a src attribute', () => {
const Content = render('<script src="https://example.com/evil.js"></script>');
const html = renderToString(<Content />);

expect(html).not.toContain('<script');
expect(html).not.toContain('evil.js');
});

it('leaves script tags inside code blocks alone', () => {
const Content = render('```html\n<script>alert(1)</script>\n```');
const html = renderToString(<Content />);

expect(html).toContain('alert(1)');
});
});

// `<math><mtext>` is a text-integration point that switches the parser back to
// HTML, which is the namespace-confusion bypass.
describe.each(primaryEngines)('%s engine, nested in a wrapper tag', (_, render) => {
it.each([
['math/mtext', '<math><mtext><script>alert(1)</script></mtext></math>'],
['svg', '<svg><script>alert(1)</script></svg>'],
['noscript', '<noscript><script>alert(1)</script></noscript>'],
['template', '<template><script>alert(1)</script></template>'],
])('strips a <script> nested inside %s', (_wrapper, md) => {
const Content = render(md);
const html = renderToString(<Content />);

expect(html).not.toContain('<script');
expect(html).not.toContain('alert(1)');
});
});

describe.each(primaryEngines)('%s engine, nested in a container', (_, render) => {
it('strips a <script> in a table cell', () => {
const Content = render('| a | b |\n| --- | --- |\n| <script>alert(1)</script> | safe |');
const html = renderToString(<Content />);

expect(html).not.toContain('<script');
expect(html).not.toContain('alert(1)');
expect(html).toContain('safe');
});

it('strips a <script> in a custom component body', () => {
const Content = render('<Callout icon="ℹ️">\n <script>alert(1)</script>\n</Callout>');
const html = renderToString(<Content />);

expect(html).not.toContain('<script');
expect(html).not.toContain('alert(1)');
expect(html).toContain('callout');
});
});

describe.each(primaryEngines)('%s engine, JSX authored in code', (_, render) => {
it.each([
['a flow expression', '{<script>alert(1)</script>}'],
['an inline expression', 'hello {<script>alert(1)</script>} world'],
['an exported component', 'export const X = () => <script>alert(1)</script>;\n\n<X />'],
['an exported fragment', 'export const X = () => <><script>alert(1)</script></>;\n\n<X />'],
])('strips a <script> in %s', (_label, doc) => {
const html = renderToString(React.createElement(render(doc)));

expect(html).not.toContain('<script');
expect(html).not.toContain('alert(1)');
});

it('strips a <script> nested in an exported component while keeping its siblings', () => {
const doc = 'export const X = () => <div><script>alert(1)</script><span>safe</span></div>;\n\n<X />';
const html = renderToString(React.createElement(render(doc)));

expect(html).not.toContain('<script');
expect(html).toContain('<span>safe</span>');
});
});

// MDX rejects `<!-- -->` at parse time (it wants `{/* */}`), so only mdxish can reach this case.
it('mdxish: does not render a <script> inside an HTML comment', () => {
const Content = renderMdxish(mdxish('<!-- <script>alert(1)</script> -->', { sanitize: true }))
.default as MDXContent;
const html = renderToString(<Content />);

expect(html).not.toContain('<script');
expect(html).not.toContain('alert(1)');
});

describe('sanitize option', () => {
const doc = '<script>alert(1)</script>';

const sanitizeEngines = [
['mdx', (sanitize?: boolean) => execute(doc, { sanitize }) as MDXContent] as const,
['mdxish', (sanitize?: boolean) => renderMdxish(mdxish(doc, { sanitize })).default as MDXContent] as const,
];

it.each(sanitizeEngines)('%s: keeps the script by default', (_engine, render) => {
expect(renderToString(React.createElement(render(undefined)))).toContain('alert(1)');
});

it.each(sanitizeEngines)('%s: keeps the script when explicitly disabled', (_engine, render) => {
expect(renderToString(React.createElement(render(false)))).toContain('alert(1)');
});

it.each(sanitizeEngines)('%s: strips the script when enabled', (_engine, render) => {
const html = renderToString(React.createElement(render(true)));

expect(html).not.toContain('<script');
expect(html).not.toContain('alert(1)');
});
});

// Caller plugins must extend the pipeline, not replace it, or the stripper disappears.
describe('caller-supplied rehype plugins', () => {
it.each([['empty array', []] as const, ['null', null] as const])(
'cannot displace the script stripper (%s)',
(_label, rehypePlugins) => {
const Content = execute('<script>alert(1)</script>', { rehypePlugins, sanitize: true }) as MDXContent;
const html = renderToString(<Content />);

expect(html).not.toContain('<script');
expect(html).not.toContain('alert(1)');
},
);

it('extends the pipeline rather than replacing it', () => {
const appendMarker = () => (tree: Root) => {
tree.children.push({
type: 'element',
tagName: 'p',
properties: {},
children: [{ type: 'text', value: 'caller-plugin-ran' }],
});
};

const Content = execute('# Heading\n\n<script>alert(1)</script>', {
rehypePlugins: [appendMarker],
sanitize: true,
}) as MDXContent;
const html = renderToString(<Content />);

expect(html).not.toContain('<script');
expect(html).not.toContain('alert(1)');
expect(html).toContain('caller-plugin-ran');
// `rehypeSlug` ships in the default rehype plugins; its id proves they survived.
expect(html).toContain('id="heading"');
});
});

// `iframe` stands in for a future vector; only `script` ships in the set today.
describe('STRIPPED_TAG_NAMES drives what gets stripped', () => {
const doc = '<iframe src="https://example.com/evil"></iframe>';

afterEach(() => {
STRIPPED_TAG_NAMES.delete('iframe');
});

it.each(primaryEngines)('%s: leaves a tag that is not in the set', (_engine, render) => {
expect(renderToString(React.createElement(render(doc)))).toContain('<iframe');
});

it.each(primaryEngines)('%s: strips a tag once it is added to the set', (_engine, render) => {
STRIPPED_TAG_NAMES.add('iframe');

expect(renderToString(React.createElement(render(doc)))).not.toContain('<iframe');
});
});

describe('rehypeStripTags plugin', () => {
it('strips case-variant literal script nodes but not Script component references', () => {
const tree: Root = {
type: 'root',
children: [
{ type: 'element', tagName: 'script', properties: {}, children: [] },
{ type: 'element', tagName: 'sCrIpT', properties: {}, children: [] },
{ type: 'mdxJsxFlowElement', name: 'script', attributes: [], children: [] },
{ type: 'mdxJsxFlowElement', name: 'sCrIpT', attributes: [], children: [] },
{ type: 'mdxJsxTextElement', name: 'script', attributes: [], children: [] },
{ type: 'mdxJsxFlowElement', name: 'Script', attributes: [], children: [] },
{ type: 'element', tagName: 'p', properties: {}, children: [] },
],
};

const result = unified().use(rehypeStripTags).runSync(tree);

expect(result.children).toMatchObject([{ name: 'Script' }, { tagName: 'p' }]);
});
});

// HTMLBlock scripts live in a string prop, so the plugin must leave them intact.
describe('HTMLBlock exemption', () => {
const doc = '<HTMLBlock>{`<script>alert(1)</script>`}</HTMLBlock>';

it('keeps HTMLBlock scripts in the mdxish html property', () => {
const htmlBlock = findElementByTagName(mdxish(doc, { sanitize: true }), 'html-block');

expect(htmlBlock).toMatchObject({
properties: { html: '<script>alert(1)</script>' },
});
});

it('keeps HTMLBlock scripts in the compiled mdx output', () => {
expect(compile(doc, { sanitize: true })).toContain('<script>alert(1)</script>');
});
});
});
44 changes: 42 additions & 2 deletions lib/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import remarkGfm from 'remark-gfm';

import MdxSyntaxError from '../errors/mdx-syntax-error';
import hardBreaksPlugin from '../processor/plugin/hard-breaks';
import { recmaStripTags, rehypeStripTags } from '../processor/plugin/strip-tags';
import { rehypeToc } from '../processor/plugin/toc';
import {
defaultTransforms,
Expand All @@ -25,6 +26,13 @@ export type CompileOpts = CompileOptions & {
copyButtons?: boolean;
hardBreaks?: boolean;
missingComponents?: 'ignore' | 'throw';
/**
* Strip content that would execute in the page (currently `<script>`
* elements, whether written as a literal tag or as JSX inside an expression
* or export). Defaults to `false` to preserve existing rendering for callers
* that may rely on raw HTML; opt in per project.
*/
sanitize?: boolean;
useTailwind?: boolean;
};

Expand All @@ -39,7 +47,20 @@ const sanitizeSchema = deepmerge(defaultSchema, {

const compile = (
text: string,
{ components = {}, missingComponents, copyButtons, useTailwind, hardBreaks, ...opts }: CompileOpts = {},
{
components = {},
missingComponents,
copyButtons,
useTailwind,
hardBreaks,
sanitize = false,
// Pulled out of `...opts` so a caller's plugins extend the pipeline instead of
// replacing it — otherwise the spread below would drop `rehypeStripTags`.
remarkPlugins: userRemarkPlugins,
rehypePlugins: userRehypePlugins,
recmaPlugins: userRecmaPlugins,
...opts
}: CompileOpts = {},
) => {
// Destructure at runtime to avoid circular dependency issues
const { codeTabsTransformer, ...transforms } = defaultTransforms;
Expand All @@ -61,7 +82,13 @@ const compile = (
remarkPlugins.push([tailwindTransformer, { components }]);
}

const rehypePlugins: PluggableList = [...defaultRehypePlugins, [rehypeToc, { components }]];
remarkPlugins.push(...(userRemarkPlugins ?? []));

const rehypePlugins: PluggableList = [
...defaultRehypePlugins,
[rehypeToc, { components }],
...(userRehypePlugins ?? []),
];

if (opts.format === 'md') {
/**
Expand All @@ -81,12 +108,25 @@ const compile = (
rehypePlugins.push([rehypeSanitize, sanitizeSchema]);
}

const recmaPlugins: PluggableList = [...(userRecmaPlugins ?? [])];

// In `mdx` format a literal <script> parses as JSX rather than raw HTML, so it
// bypasses sanitization entirely — strip it in every format. Must stay last:
// in `md` format raw HTML isn't an element until `rehypeRaw` has run. A tag
// inside an expression or export is code, not an element, so the recma pass
// catches it in the compiled program instead.
if (sanitize) {
rehypePlugins.push(rehypeStripTags);
recmaPlugins.push(recmaStripTags);
}

try {
const vfile = mdxCompileSync(text, {
outputFormat: 'function-body',
providerImportSource: '#',
remarkPlugins,
rehypePlugins,
recmaPlugins,
...opts,
});

Expand Down
Loading
Loading