diff --git a/.changeset/safe-mdx-content.md b/.changeset/safe-mdx-content.md new file mode 100644 index 00000000..0f706e46 --- /dev/null +++ b/.changeset/safe-mdx-content.md @@ -0,0 +1,5 @@ +--- +"@studiocms/mdx": minor +--- + +Restricts the built-in renderer to Markdown/GFM syntax, rejects executable MDX constructs, and sanitizes rendered HTML. Use a custom renderer for full MDX execution. diff --git a/cspell.json b/cspell.json index d9ef3890..77d3c0c0 100644 --- a/cspell.json +++ b/cspell.json @@ -104,6 +104,7 @@ "jdtjenkins", "Jike", "johndoe", + "jsxs", "katex", "kevinzunigacuellar", "knip", diff --git a/packages/@studiocms/mdx/README.md b/packages/@studiocms/mdx/README.md index cc592b7e..220f3e0d 100644 --- a/packages/@studiocms/mdx/README.md +++ b/packages/@studiocms/mdx/README.md @@ -40,6 +40,12 @@ Optional list of recma plugins Optional: options for remarkRehype +## Content safety + +The built-in renderer supports Markdown (including GFM) and safe, static intrinsic HTML elements from the sanitizer's allowlist. It rejects MDX JavaScript expressions, ESM imports/exports, custom React components, dynamic/spread attributes, event handlers, and unsafe URLs. Rendered HTML is sanitized after the configured rehype plugins run. + +This is an input restriction, not a general JavaScript sandbox or a defense against resource-exhaustion attacks. Configured Remark, Rehype, and Recma plugins are trusted application code and must be reviewed accordingly. If full MDX execution is required, use a custom renderer and only process content from trusted authors. + ## License [MIT Licensed](./LICENSE). \ No newline at end of file diff --git a/packages/@studiocms/mdx/package.json b/packages/@studiocms/mdx/package.json index eda356b4..1f354e0a 100644 --- a/packages/@studiocms/mdx/package.json +++ b/packages/@studiocms/mdx/package.json @@ -67,6 +67,7 @@ "react": "catalog:react", "react-dom": "catalog:react", "rehype-highlight": "^7.0.2", + "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "unified": "^11.0.5" }, diff --git a/packages/@studiocms/mdx/src/lib/render.ts b/packages/@studiocms/mdx/src/lib/render.ts index 7c60a4d6..c33f5dfb 100644 --- a/packages/@studiocms/mdx/src/lib/render.ts +++ b/packages/@studiocms/mdx/src/lib/render.ts @@ -1,10 +1,12 @@ -import { evaluate } from '@mdx-js/mdx'; +import { compile, run } from '@mdx-js/mdx'; import { createElement } from 'react'; import * as runtime from 'react/jsx-runtime'; import { renderToString } from 'react-dom/server'; import rehypeHighlight from 'rehype-highlight'; +import rehypeSanitize from 'rehype-sanitize'; import remarkGfm from 'remark-gfm'; import type { PluggableList } from 'unified'; +import { restrictMdx, studiocmsSchema } from './restrict-mdx.js'; import { shared } from './shared.js'; const baseRemarkPlugins = [remarkGfm]; @@ -12,28 +14,20 @@ const baseRehypePlugins = [rehypeHighlight]; const { recmaPlugins, rehypePlugins, remarkPlugins, remarkRehypeOptions } = shared.mdxConfig; -const makeList = (included: PluggableList, userDefinedPlugins?: PluggableList): PluggableList => { - const Plugins: PluggableList = included; - - if (userDefinedPlugins) { - for (const plugin of userDefinedPlugins) { - Plugins.push(plugin); - } - } - - return Plugins; -}; +const makeList = (...pluginLists: (PluggableList | undefined)[]): PluggableList => + pluginLists.flatMap((plugins) => plugins ?? []); export async function renderMDX(content: string): Promise { - const { default: MDXContent } = await evaluate(content, { - // biome-ignore lint/suspicious/noExplicitAny: this is a valid use case for explicit any - ...(runtime as any), - remarkPlugins: makeList(baseRemarkPlugins, remarkPlugins), - rehypePlugins: makeList(baseRehypePlugins, rehypePlugins), + const compiledCode = await compile(content, { + outputFormat: 'function-body', // Essential: Outputs MDX as a pure string evaluation block + remarkPlugins: makeList(baseRemarkPlugins, remarkPlugins, [restrictMdx]), + rehypePlugins: makeList(baseRehypePlugins, rehypePlugins, [[rehypeSanitize, studiocmsSchema]]), recmaPlugins: recmaPlugins, remarkRehypeOptions: remarkRehypeOptions, }); + const { default: MDXContent } = await run(compiledCode, { ...runtime }); + return renderToString(createElement(MDXContent)); } diff --git a/packages/@studiocms/mdx/src/lib/restrict-mdx.ts b/packages/@studiocms/mdx/src/lib/restrict-mdx.ts new file mode 100644 index 00000000..4cee0e58 --- /dev/null +++ b/packages/@studiocms/mdx/src/lib/restrict-mdx.ts @@ -0,0 +1,174 @@ +import { getRegistryComponents } from 'studiocms:component-registry/runtime'; +import { defaultSchema } from 'rehype-sanitize'; +import type { Plugin } from 'unified'; + +interface AstNode { + type?: string; + children?: AstNode[]; + name?: string | null; + attributes?: AstAttribute[]; + data?: Record; +} + +interface AstAttribute { + type?: string; + name?: string; + value?: unknown; +} + +const componentTags = getRegistryComponents().map((component) => component.name.toLowerCase()); + +const componentAttributes: Record = getRegistryComponents().reduce( + (acc, component) => { + acc[component.name.toLowerCase()] = component.props.map((prop) => prop.name); + return acc; + }, + {} as Record +); + +export const studiocmsSchema: typeof defaultSchema = { + ...defaultSchema, + tagNames: [...(defaultSchema.tagNames ?? []), ...componentTags], + protocols: { + cite: [...(defaultSchema.protocols?.cite ?? []), 'storage-file'], + href: [...(defaultSchema.protocols?.href ?? []), 'storage-file'], + longDesc: [...(defaultSchema.protocols?.longDesc ?? []), 'storage-file'], + src: [...(defaultSchema.protocols?.src ?? []), 'storage-file'], + poster: [...(defaultSchema.protocols?.poster ?? []), 'storage-file'], + 'data-src': [...(defaultSchema.protocols?.['data-src'] ?? []), 'storage-file'], + }, + attributes: { + ...defaultSchema.attributes, + ...componentAttributes, + span: [...(defaultSchema.attributes?.span ?? []), ['className', /^hljs-/]], + }, +}; + +const forbiddenNodeTypes = new Set(['mdxjsEsm', 'mdxFlowExpression', 'mdxTextExpression']); +const safeHtmlTags = new Set(studiocmsSchema.tagNames ?? []); +const safeGlobalAttributes = new Set(['class', 'className', 'id', 'lang', 'dir', 'role', 'title']); + +const isSafeUrl = (attributeName: string, value: string): boolean => { + // biome-ignore lint/suspicious/noControlCharactersInRegex: yes we know, we want to remove control characters from the URL before checking the scheme + const compactValue = value.replace(/[\u0000-\u0020\u007f]/g, ''); + const scheme = /^([a-z][a-z\d+.-]*):/i.exec(compactValue)?.[1]?.toLowerCase(); + if (!scheme) return true; + + const allowedSchemes = studiocmsSchema.protocols?.[attributeName] ?? []; + return allowedSchemes.includes(scheme); +}; + +const validateHtmlElement = (node: AstNode): void => { + if (!node.name || !safeHtmlTags.has(node.name)) { + throw new Error(`Unsupported MDX element: ${node.name ?? 'fragment'}`); + } + + const rules = [ + ...(studiocmsSchema.attributes?.['*'] ?? []), + ...(studiocmsSchema.attributes?.[node.name] ?? []), + ]; + + for (const attribute of node.attributes ?? []) { + if ( + attribute.type !== 'mdxJsxAttribute' || + !attribute.name || + !(typeof attribute.value === 'string' || attribute.value == null) + ) { + throw new Error('Unsupported MDX attribute: only static HTML attributes are allowed.'); + } + + const name = attribute.name; + const rule = rules.find((candidate) => + typeof candidate === 'string' ? candidate === name : candidate[0] === name + ); + const isAriaOrData = /^(aria-[a-z\d-]+|data-[a-z\d-]+)$/i.test(name); + if (!rule && !safeGlobalAttributes.has(name) && !isAriaOrData) { + throw new Error(`Unsupported MDX HTML attribute: ${name}`); + } + + if (Array.isArray(rule) && rule.length > 1) { + const value = attribute.value == null ? true : attribute.value; + const allowed = rule + .slice(1) + .some((allowedValue) => + allowedValue instanceof RegExp && typeof value === 'string' + ? allowedValue.test(value) + : allowedValue === value + ); + if (!allowed) { + throw new Error(`Unsupported value for MDX HTML attribute: ${name}`); + } + } + + if (name === 'class' || name === 'className') { + if (typeof attribute.value === 'string' && !/^[\w\s-]*$/.test(attribute.value)) { + throw new Error(`Unsupported value for MDX HTML attribute: ${name}`); + } + } + + if ( + ['href', 'src', 'cite', 'longDesc', 'action', 'formAction', 'useMap'].includes(name) && + typeof attribute.value === 'string' && + !isSafeUrl(name, attribute.value) + ) { + throw new Error(`Unsafe URL in MDX HTML attribute: ${name}`); + } + } +}; + +/** + * Restrict MDX input to Markdown syntax. MDX expressions, module declarations, + * and JSX are rejected so source content cannot provide executable JavaScript. + */ +export const assertSafeMdxTree = (tree: unknown): void => { + const visit = (node: AstNode): void => { + if (node.type && forbiddenNodeTypes.has(node.type)) { + throw new Error(`Unsupported MDX syntax: ${node.type}`); + } + if (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') { + validateHtmlElement(node); + } + + for (const child of node.children ?? []) { + visit(child); + } + }; + + visit(tree as AstNode); +}; + +const toHastPropertyName = (name: string): string => { + if (name === 'class') return 'className'; + return name.replace( + /^(aria|data)-([a-z\d-]+)$/i, + (_match, prefix: string, rest: string) => + `${prefix.toLowerCase()}${rest.replace(/-([a-z\d])/gi, (_m, char: string) => char.toUpperCase()).replace(/^./, (char) => char.toUpperCase())}` + ); +}; + +// Validated JSX is rewritten so remark-rehype emits plain elements for rehype-sanitize to process. +const convertJsxToElements = (node: AstNode): void => { + if (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') { + const properties: Record = {}; + for (const attribute of node.attributes ?? []) { + const name = toHastPropertyName(attribute.name as string); + const value = attribute.value == null ? true : attribute.value; + properties[name] = + name === 'className' && typeof value === 'string' + ? value.split(/\s+/).filter(Boolean) + : value; + } + + node.data = { ...node.data, hName: node.name, hProperties: properties }; + node.type = 'mdxIntrinsicElement'; + } + + for (const child of node.children ?? []) { + convertJsxToElements(child); + } +}; + +export const restrictMdx: Plugin = () => (tree) => { + assertSafeMdxTree(tree); + convertJsxToElements(tree as AstNode); +}; diff --git a/packages/@studiocms/mdx/test/lib/render-integration.test.ts b/packages/@studiocms/mdx/test/lib/render-integration.test.ts new file mode 100644 index 00000000..da372a00 --- /dev/null +++ b/packages/@studiocms/mdx/test/lib/render-integration.test.ts @@ -0,0 +1,48 @@ +import * as allure from 'allure-js-commons'; +import { describe, expect, test } from 'vitest'; +import { renderMDX } from '../../src/lib/render.js'; +import { parentSuiteName, sharedTags } from '../test-utils.js'; + +const localSuiteName = 'renderMDX Function Tests'; + +describe(parentSuiteName, () => { + [ + { + name: 'keeps safe static HTML elements in the rendered output', + content: '
allowed HTML
', + expected: '
allowed HTML
', + }, + { + name: 'keeps safe inline HTML elements inside Markdown', + content: 'Hello world', + expected: '

Hello world

', + }, + ].forEach(({ name, content, expected }) => + test(name, async () => { + await allure.parentSuite(parentSuiteName); + await allure.suite(localSuiteName); + await allure.subSuite('MDX Security Tests'); + await allure.tags(...sharedTags); + + await allure.step(`Rendering MDX content: ${content}`, async (ctx) => { + const html = await renderMDX(content); + await ctx.parameter('renderedHTML', html); + expect(html).toBe(expected); + }); + }) + ); + + test('rejects custom components', async () => { + await allure.parentSuite(parentSuiteName); + await allure.suite(localSuiteName); + await allure.subSuite('MDX Security Tests'); + await allure.tags(...sharedTags); + + await allure.step('Rendering MDX content with a custom component', async (ctx) => { + await ctx.parameter('mdxContent', ''); + await expect(renderMDX('')).rejects.toThrow( + 'Unsupported MDX element: Button' + ); + }); + }); +}); diff --git a/packages/@studiocms/mdx/test/lib/render.test.ts b/packages/@studiocms/mdx/test/lib/render.test.ts index 4301ecb2..f3317829 100644 --- a/packages/@studiocms/mdx/test/lib/render.test.ts +++ b/packages/@studiocms/mdx/test/lib/render.test.ts @@ -1,58 +1,46 @@ import * as allure from 'allure-js-commons'; -import type { JSXElementConstructor, ReactElement } from 'react'; -import type { PluggableList } from 'unified'; -import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { renderMDX } from '../../src/lib/render.js'; -import { createMockMDXContent, parentSuiteName, sharedTags } from '../test-utils.js'; +import { assertSafeMdxTree, restrictMdx } from '../../src/lib/restrict-mdx.js'; +import { parentSuiteName, sharedTags } from '../test-utils.js'; interface MockReactElement { type: unknown; props: Record; - key: string | null; } -// Mock the dependencies +const defaultCompiledCode = ` + const { jsx: _jsx } = arguments[0]; + function MDXContent(props = {}) { + return _jsx('p', { ...props, children: 'Rendered output' }); + } + return { default: MDXContent }; +`; + vi.mock('@mdx-js/mdx', () => ({ - evaluate: vi.fn(), + compile: vi.fn(), + run: vi.fn(), })); vi.mock('react', () => ({ createElement: vi.fn( - ( - component: unknown, - props: Record = {}, - ...children: unknown[] - ): MockReactElement => ({ - type: component, - props: { ...props, children }, - key: null, + (type: unknown, props: Record = {}): MockReactElement => ({ + type, + props, }) ), })); vi.mock('react-dom/server', () => ({ - renderToString: vi.fn((element: MockReactElement | string): string => { - // Simple mock renderer - if (typeof element === 'string') return element; - if (element?.type) { - const props = element.props || {}; - const children = props.children || ''; - return `<${element.type}${Object.entries(props) - .filter(([key]) => key !== 'children') - .map(([key, value]) => ` ${key}="${value}"`) - .join('')}>${children}`; - } - return ''; - }), -})); - -vi.mock('rehype-highlight', () => ({ - default: vi.fn(), + renderToString: vi.fn(() => '

Rendered output

'), })); -vi.mock('remark-gfm', () => ({ +vi.mock('rehype-highlight', () => ({ default: vi.fn() })); +vi.mock('rehype-sanitize', () => ({ default: vi.fn(), + defaultSchema: { attributes: { span: [] } }, })); +vi.mock('remark-gfm', () => ({ default: vi.fn() })); vi.mock('../../src/lib/shared.js', () => ({ shared: { @@ -68,313 +56,172 @@ vi.mock('../../src/lib/shared.js', () => ({ const localSuiteName = 'renderMDX Function Tests'; describe(parentSuiteName, () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); + const { compile, run } = await import('@mdx-js/mdx'); + vi.mocked(compile).mockResolvedValue({ toString: () => defaultCompiledCode } as never); + vi.mocked(run).mockResolvedValue({ default: vi.fn() } as never); }); afterEach(() => { vi.restoreAllMocks(); }); - test('should render simple MDX content', async () => { + test('compiles function-body output and renders its default module export', async () => { await allure.parentSuite(parentSuiteName); await allure.suite(localSuiteName); await allure.subSuite('MDX Rendering Tests'); await allure.tags(...sharedTags); - const { evaluate } = await import('@mdx-js/mdx'); - const { renderToString } = await import('react-dom/server'); + const { compile } = await import('@mdx-js/mdx'); + const { run } = await import('@mdx-js/mdx'); const { createElement } = await import('react'); + const { renderToString } = await import('react-dom/server'); + const content = '# Hello World'; + + const result = await renderMDX(content); + + expect(compile).toHaveBeenCalledWith( + content, + expect.objectContaining({ + outputFormat: 'function-body', + remarkPlugins: expect.any(Array), + rehypePlugins: expect.any(Array), + recmaPlugins: expect.any(Array), + remarkRehypeOptions: expect.any(Object), + }) + ); + expect(createElement).toHaveBeenCalledWith(expect.any(Function)); + expect(run).toHaveBeenCalledOnce(); + expect(vi.mocked(run).mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ toString: expect.any(Function) }) + ); + expect(vi.mocked(run).mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ jsx: expect.any(Function), jsxs: expect.any(Function) }) + ); + expect(renderToString).toHaveBeenCalled(); + expect(result).toBe('

Rendered output

'); + }); - await allure.step('Should render simple MDX content correctly', async (ctx) => { - const mockContent = createMockMDXContent('# Hello World'); - const mockComponent = vi.fn(() => 'Hello World'); - - await ctx.parameter('MDX Content', mockContent); - - vi.mocked(evaluate).mockResolvedValue({ - default: mockComponent, - }); - - vi.mocked(createElement).mockReturnValue({ - type: mockComponent, - props: {}, - key: null, - } as unknown as ReactElement, string | JSXElementConstructor>); - - vi.mocked(renderToString).mockReturnValue('

Hello World

'); - - const result = await renderMDX(mockContent); - - await ctx.parameter('Rendered Output', result); + test.each(['mdxjsEsm', 'mdxFlowExpression', 'mdxTextExpression'])( + 'rejects executable MDX syntax: %s', + async (nodeType) => { + await allure.parentSuite(parentSuiteName); + await allure.suite(localSuiteName); + await allure.subSuite('MDX Security Tests'); + await allure.tags(...sharedTags); - expect(evaluate).toHaveBeenCalledWith( - mockContent, - expect.objectContaining({ - remarkPlugins: expect.any(Array), - rehypePlugins: expect.any(Array), - recmaPlugins: expect.any(Array), - remarkRehypeOptions: expect.any(Object), - }) + expect(() => assertSafeMdxTree({ type: 'root', children: [{ type: nodeType }] })).toThrow( + `Unsupported MDX syntax: ${nodeType}` ); + } + ); - expect(createElement).toHaveBeenCalledWith(mockComponent); - expect(renderToString).toHaveBeenCalled(); - expect(result).toBe('

Hello World

'); - }); + test('allows ordinary Markdown syntax', async () => { + await allure.parentSuite(parentSuiteName); + await allure.suite(localSuiteName); + await allure.subSuite('MDX Security Tests'); + await allure.tags(...sharedTags); + + expect(() => + assertSafeMdxTree({ + type: 'root', + children: [{ type: 'heading', children: [{ type: 'text' }] }], + }) + ).not.toThrow(); }); - test('should render MDX content with JSX components', async () => { + test('passes empty content to the compiler', async () => { await allure.parentSuite(parentSuiteName); await allure.suite(localSuiteName); await allure.subSuite('MDX Rendering Tests'); await allure.tags(...sharedTags); - const { evaluate } = await import('@mdx-js/mdx'); - const { renderToString } = await import('react-dom/server'); - const { createElement } = await import('react'); - - await allure.step('Should render MDX content with JSX components correctly', async (ctx) => { - const mdxContent = `import { Button } from './Button'; - -# Hello World - -`; + const { compile } = await import('@mdx-js/mdx'); - const mockComponent = vi.fn(() => 'Hello World with Button'); - - await ctx.parameter('MDX Content', mdxContent); - - vi.mocked(evaluate).mockResolvedValue({ - default: mockComponent, - }); - - vi.mocked(createElement).mockReturnValue({ - type: mockComponent, - props: {}, - key: null, - } as unknown as ReactElement, string | JSXElementConstructor>); - - vi.mocked(renderToString).mockReturnValue( - '

Hello World

' - ); + await renderMDX(''); - const result = await renderMDX(mdxContent); - - await ctx.parameter('Rendered Output', result); - - expect(evaluate).toHaveBeenCalledWith( - mdxContent, - expect.objectContaining({ - remarkPlugins: expect.any(Array), - rehypePlugins: expect.any(Array), - recmaPlugins: expect.any(Array), - remarkRehypeOptions: expect.any(Object), - }) - ); - - expect(result).toBe('

Hello World

'); - }); + expect(compile).toHaveBeenCalledWith('', expect.any(Object)); }); - test('should handle empty content', async () => { + test('includes the base remark and rehype plugins', async () => { await allure.parentSuite(parentSuiteName); await allure.suite(localSuiteName); await allure.subSuite('MDX Rendering Tests'); await allure.tags(...sharedTags); - const { evaluate } = await import('@mdx-js/mdx'); - const { renderToString } = await import('react-dom/server'); - const { createElement } = await import('react'); - - await allure.step('Should handle empty MDX content correctly', async (ctx) => { - await ctx.parameter('MDX Content', '(empty string)'); - - const mockComponent = vi.fn(() => ''); - - vi.mocked(evaluate).mockResolvedValue({ - default: mockComponent, - }); - - vi.mocked(createElement).mockReturnValue({ - type: mockComponent, - props: {}, - key: null, - } as unknown as ReactElement, string | JSXElementConstructor>); + const { compile } = await import('@mdx-js/mdx'); - vi.mocked(renderToString).mockReturnValue(''); + await renderMDX('test'); - const result = await renderMDX(''); - - await ctx.parameter('Rendered Output', result); - - expect(evaluate).toHaveBeenCalledWith('', expect.any(Object)); - expect(result).toBe(''); - }); + const options = vi.mocked(compile).mock.calls[0]?.[1]; + if (!options) throw new Error('compile was not called with options'); + const remarkPluginOptions = options.remarkPlugins; + const rehypePluginOptions = options.rehypePlugins; + if (!remarkPluginOptions || !rehypePluginOptions) { + throw new Error('compile was not called with plugin options'); + } + expect(remarkPluginOptions).toHaveLength(2); + expect(rehypePluginOptions).toHaveLength(2); + expect(remarkPluginOptions.at(-1)).toBe(restrictMdx); + const { default: rehypeSanitize } = await import('rehype-sanitize'); + const sanitizer = rehypePluginOptions.at(-1); + expect(Array.isArray(sanitizer) && sanitizer[0]).toBe(rehypeSanitize); }); - test('should include base remark and rehype plugins', async () => { + test('uses the shared MDX plugin configuration', async () => { await allure.parentSuite(parentSuiteName); await allure.suite(localSuiteName); await allure.subSuite('MDX Rendering Tests'); await allure.tags(...sharedTags); - const { evaluate } = await import('@mdx-js/mdx'); - const { renderToString } = await import('react-dom/server'); - const { createElement } = await import('react'); - - await allure.step('Should include base plugins in MDX rendering options', async (ctx) => { - const mockComponent = vi.fn(() => 'test'); - - await ctx.parameter('MDX Content', 'test'); - - vi.mocked(evaluate).mockResolvedValue({ - default: mockComponent, - }); - - vi.mocked(createElement).mockReturnValue({ - type: mockComponent, - props: {}, - key: null, - } as unknown as ReactElement, string | JSXElementConstructor>); - - vi.mocked(renderToString).mockReturnValue('test'); - - await renderMDX('test'); + const { compile } = await import('@mdx-js/mdx'); - const evaluateCall = vi.mocked(evaluate).mock.calls[0]; - const options = evaluateCall[1]; + await renderMDX('test'); - expect(options.remarkPlugins).toHaveLength(1); // remark-gfm - expect(options.rehypePlugins).toHaveLength(1); // rehype-highlight - }); + const options = vi.mocked(compile).mock.calls[0]?.[1]; + if (!options) throw new Error('compile was not called with options'); + expect(options.recmaPlugins).toEqual([]); + expect(options.remarkRehypeOptions).toEqual({}); }); - test('should use default shared config', async () => { + test('propagates MDX compilation errors', async () => { await allure.parentSuite(parentSuiteName); await allure.suite(localSuiteName); await allure.subSuite('MDX Rendering Tests'); await allure.tags(...sharedTags); - const { evaluate } = await import('@mdx-js/mdx'); - const { renderToString } = await import('react-dom/server'); - const { createElement } = await import('react'); - - await allure.step('Should use default shared config for MDX rendering', async (ctx) => { - await ctx.parameter('MDX Content', 'test'); - const mockComponent = vi.fn(() => 'test'); - - vi.mocked(evaluate).mockResolvedValue({ - default: mockComponent, - }); - - vi.mocked(createElement).mockReturnValue({ - type: mockComponent, - props: {}, - key: null, - } as unknown as ReactElement, string | JSXElementConstructor>); - - vi.mocked(renderToString).mockReturnValue('test'); + const { compile } = await import('@mdx-js/mdx'); + const error = new Error('Malformed MDX'); + vi.mocked(compile).mockRejectedValueOnce(error); - await renderMDX('test'); - - const evaluateCall = vi.mocked(evaluate).mock.calls[0]; - const options = evaluateCall[1]; - - expect(options.remarkPlugins).toHaveLength(1); // remark-gfm - expect(options.rehypePlugins).toHaveLength(1); // rehype-highlight - expect(options.recmaPlugins).toHaveLength(0); // no custom recma plugins - expect(options.remarkRehypeOptions).toEqual({}); - }); + await expect(renderMDX('')).rejects.toBe(error); }); - test('should handle malformed MDX content', async () => { + test('propagates MDX runtime errors', async () => { await allure.parentSuite(parentSuiteName); await allure.suite(localSuiteName); await allure.subSuite('MDX Rendering Tests'); await allure.tags(...sharedTags); - const { evaluate } = await import('@mdx-js/mdx'); - const { renderToString } = await import('react-dom/server'); - const { createElement } = await import('react'); - - await allure.step('Should handle malformed MDX content gracefully', async (ctx) => { - const malformedContent = ` -# Broken MDX + const { run } = await import('@mdx-js/mdx'); + const error = new Error('Invalid MDX runtime output'); + vi.mocked(run).mockRejectedValueOnce(error); - -This is broken JSX - -import { Component } from 'react'; - { - const unicodeContent = `# Hello World + await renderMDX(content); -This is **bold** with emojis - -Math symbols and unicode text - -Special chars: <>&"'`; - - await ctx.parameter('MDX Content', unicodeContent); - - const mockComponent = vi.fn(() => 'test'); - - vi.mocked(evaluate).mockResolvedValue({ - default: mockComponent, - }); - - vi.mocked(createElement).mockReturnValue({ - type: mockComponent, - props: {}, - key: null, - } as unknown as ReactElement< - Record, - string | JSXElementConstructor - >); - - vi.mocked(renderToString).mockReturnValue('test'); - - await renderMDX(unicodeContent); - - expect(evaluate).toHaveBeenCalledWith(unicodeContent, expect.any(Object)); - } - ); + expect(compile).toHaveBeenCalledWith(content, expect.any(Object)); }); }); diff --git a/packages/@studiocms/mdx/test/lib/restrict-mdx.test.ts b/packages/@studiocms/mdx/test/lib/restrict-mdx.test.ts new file mode 100644 index 00000000..08571445 --- /dev/null +++ b/packages/@studiocms/mdx/test/lib/restrict-mdx.test.ts @@ -0,0 +1,76 @@ +import { compile } from '@mdx-js/mdx'; +import * as allure from 'allure-js-commons'; +import { describe, expect, test } from 'vitest'; +import { restrictMdx } from '../../src/lib/restrict-mdx.js'; +import { parentSuiteName, sharedTags } from '../test-utils.js'; + +const localSuiteName = 'restrictMdx Plugin Tests'; + +describe(parentSuiteName, () => { + test.each([ + ['ESM imports', "import Component from './component.js'", 'Unsupported MDX syntax: mdxjsEsm'], + ['text expressions', 'Hello {globalThis.process}', 'Unsupported MDX syntax: mdxTextExpression'], + ['flow expressions', '{globalThis.process}', 'Unsupported MDX syntax: mdxFlowExpression'], + ['custom components', '', 'Unsupported MDX element: Button'], + ['dangerous HTML elements', '', 'Unsupported MDX element: script'], + [ + 'dynamic HTML attributes', + '
', + 'only static HTML attributes are allowed', + ], + [ + 'event handler attributes', + '
', + 'Unsupported MDX HTML attribute: onClick', + ], + [ + 'scriptable URLs', + 'click', + 'Unsafe URL in MDX HTML attribute: href', + ], + ])('rejects %s', async (_label, content, expectedError) => { + await allure.parentSuite(parentSuiteName); + await allure.suite(localSuiteName); + await allure.subSuite('MDX Security Tests'); + await allure.tags(...sharedTags); + + await allure.step(`Compiling MDX content: ${content}`, async (ctx) => { + await ctx.parameter('mdxContent', content); + await ctx.parameter('expectedError', expectedError); + await expect( + compile(content, { + outputFormat: 'function-body', + remarkPlugins: [restrictMdx], + }) + ).rejects.toThrow(expectedError); + }); + }); + + [ + { + name: 'allows safe static HTML elements', + content: '
allowed HTML
', + }, + { + name: 'continues to compile regular Markdown and GFM', + content: '# Heading\n\n- [x] safe Markdown', + }, + ].forEach(({ name, content }) => + test(name, async () => { + await allure.parentSuite(parentSuiteName); + await allure.suite(localSuiteName); + await allure.subSuite('MDX Security Tests'); + await allure.tags(...sharedTags); + + await allure.step(`Compiling MDX content: ${content}`, async (ctx) => { + await ctx.parameter('mdxContent', content); + await expect( + compile(content, { + outputFormat: 'function-body', + remarkPlugins: [restrictMdx], + }) + ).resolves.toBeDefined(); + }); + }) + ); +}); diff --git a/packages/@studiocms/mdx/test/test-utils.ts b/packages/@studiocms/mdx/test/test-utils.ts index aabea5e1..bc28727c 100644 --- a/packages/@studiocms/mdx/test/test-utils.ts +++ b/packages/@studiocms/mdx/test/test-utils.ts @@ -15,9 +15,3 @@ export const createMockMDXOptions = ( remarkRehypeOptions: {}, ...overrides, }); - -/** - * Mock MDX content for testing - */ -export const createMockMDXContent = (content = '# Hello World\n\nThis is a test MDX content.') => - content; diff --git a/packages/@studiocms/mdx/vitest.config.ts b/packages/@studiocms/mdx/vitest.config.ts index a7559d45..74a3dacd 100644 --- a/packages/@studiocms/mdx/vitest.config.ts +++ b/packages/@studiocms/mdx/vitest.config.ts @@ -14,7 +14,7 @@ const testIntegration: AstroIntegration = { imports: { 'studiocms:component-registry/runtime': // Test-only identity renderer: mirrors API shape but skips sanitization on purpose. - 'export const createRenderer = (result, sanitize, preRenderer) => (content) => content;', + 'export const createRenderer = (result, sanitize, preRenderer) => (content) => content; export const getRegistryComponents = () => [];', }, }); }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ffaff15..0931ebd3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -534,6 +534,9 @@ importers: rehype-highlight: specifier: ^7.0.2 version: 7.0.2 + rehype-sanitize: + specifier: ^6.0.0 + version: 6.0.0 remark-gfm: specifier: ^4.0.1 version: 4.0.1 @@ -5218,6 +5221,9 @@ packages: hast-util-raw@9.1.0: resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + hast-util-sanitize@5.0.2: + resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} + hast-util-to-estree@3.1.3: resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} @@ -6561,6 +6567,9 @@ packages: rehype-recma@1.0.0: resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + rehype-sanitize@6.0.0: + resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} + rehype-stringify@10.0.1: resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} @@ -11508,6 +11517,12 @@ snapshots: web-namespaces: 2.0.1 zwitch: 2.0.4 + hast-util-sanitize@5.0.2: + dependencies: + '@types/hast': 3.0.5 + '@ungap/structured-clone': 1.3.3 + unist-util-position: 5.0.0 + hast-util-to-estree@3.1.3: dependencies: '@types/estree': 1.0.9 @@ -13203,6 +13218,11 @@ snapshots: transitivePeerDependencies: - supports-color + rehype-sanitize@6.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-sanitize: 5.0.2 + rehype-stringify@10.0.1: dependencies: '@types/hast': 3.0.5