Next.js 16 MDX 安全防护与鲁棒性:纵深防御实战指南
详解如何在 Next.js 16 + @next/mdx + Turbopack 生态中实现 MDX 内容安全防护与鲁棒性增强,涵盖 XSS 注入拦截、未知标签容错、脚注锚点修复、Unicode 转义处理、MDXTabs 自动检测、防御性代理模式、CSP 加固和 rehype AST 级清洗等纵深防御策略。
为什么 MDX 内容需要安全防护
MDX 是 Markdown 与 JSX 的融合格式,广泛用于技术博客和文档站点。在 Next.js 16 中,@next/mdx 在构建时将 MDX 编译为 React 组件,这与运行时渲染用户提交内容有本质区别。
然而,即便是"受信任"的 MDX 内容,也可能因作者疏忽或恶意注入而包含危险代码:
<Callout type="info">
这是一段说明,内含 <script>alert('xss')</script>
</Callout>上面的 <script> 标签在 MDX v3 中会被编译为 React.createElement('script', ...) ,并在 SSR 阶段输出到 HTML。浏览器解析 HTML 时会直接执行这段脚本——这就是经典的 XSS 攻击。
MDX v3 的安全盲区
组件覆盖系统的局限
MDX v3 提供了 useMDXComponents 来覆盖标准 HTML 元素的渲染行为。例如 h1、p、a、code 等都可以被自定义组件替换。
但关键问题在于:MDX v3 只将标准 HTML 元素路由到 components 覆盖系统。以下元素不在标准列表中:
script— XSS 首要攻击载体iframe— 可加载任意外部页面object/embed— 可嵌入恶意插件style— 可用于 CSS 注入攻击form/input— 可构造钓鱼表单
这意味着以下代码无法阻止 XSS:
// ❌ 无效防护:MDX v3 不会查找 script 组件
useMDXComponents({
script: () => null, // 永远不会被调用
});Turbopack 的序列化限制
Next.js 16 默认使用 Turbopack 构建。Turbopack 要求 @next/mdx 的插件配置为可序列化值(字符串或 JSON 对象)。JavaScript 函数无法被序列化,因此:
// ❌ Turbopack 报错:options not serializable
import { myPlugin } from "./lib/my-plugin";
rehypePlugins: [myPlugin];
// ✅ 正确:使用 npm 包名或绝对路径字符串
rehypePlugins: ["rehype-slug"];
rehypePlugins: [path.join(process.cwd(), "lib", "my-plugin.cjs")];纵深防御架构
基于以上分析,我们设计了三层纵深防御架构:
第一层:rehype AST 级清洗(编译时)
在 MDX 编译管道中注入自定义 rehype 插件,在 HAST(HTML 抽象语法树)级别移除危险元素。这发生在 MDX 被编译为 React 组件之前,从根本上阻止危险内容进入编译产物。
// lib/rehype-sanitize-mdx.cjs
const DANGEROUS_TAGS = new Set(["script", "iframe", "object", "embed", "applet", "base", "style"]);
function rehypeSanitizeMdx() {
return (tree) => {
sanitizeNode(tree);
};
}
function sanitizeNode(node) {
if (!node.children) return;
// 过滤 HAST element 和 MDX JSX element
node.children = node.children.filter((child) => {
if (child.type === "element" && DANGEROUS_TAGS.has(child.tagName)) return false;
if (child.type === "mdxJsxFlowElement" && DANGEROUS_TAGS.has(child.name)) return false;
return true;
});
// 递归 + 清洗事件处理器属性
for (const child of node.children) {
sanitizeNode(child);
}
}配置为 CJS 格式,通过绝对路径字符串引用以兼容 Turbopack:
// next.config.ts
import path from "node:path";
rehypePlugins: [
path.join(process.cwd(), "lib", "rehype-sanitize-mdx.cjs"),
"rehype-slug",
// ...
];第二层:组件级 URL 清洗(运行时)
在 mdx-components.tsx 中,对 <a> 标签的 href 进行协议检查,阻止 javascript:、vbscript:、data:text/html 等危险 URL:
const DANGEROUS_URL_PATTERN = /^\s*(javascript|vbscript|data\s*:\s*text\/html)/i;
// 在 a 标签组件覆盖中
if (DANGEROUS_URL_PATTERN.test(href)) {
return <span className="line-through">Blocked: unsafe URL</span>;
}另外,wrapper 组件对 React 元素树进行递归清洗,作为 AST 层的补充防线:
wrapper: ({ children }) => <>{sanitizeReactTree(children)}</>,第三层:CSP 安全头(浏览器级)
在 proxy.ts 中配置严格的 Content Security Policy,作为最终的浏览器级防线:
script-src 'self' 'unsafe-inline'; // 移除 'unsafe-eval'
frame-src 'none'; // 阻止所有 iframe
child-src 'none'; // 阻止子文档加载
object-src 'none'; // 阻止插件
frame-ancestors 'none'; // 阻止被嵌入即使前两层防护被绕过,CSP 仍能在浏览器层面阻止攻击执行。
处理的安全场景
已验证拦截的攻击向量
| 攻击向量 | 示例 | 防护层 | 结果 |
|---|---|---|---|
<script> 注入 | <script>alert('xss')</script> | rehype AST | 从语法树中移除 |
<iframe> 嵌入 | <iframe src="evil.com"> | rehype AST | 从语法树中移除 |
javascript: URL | [click](javascript:alert(1)) | 组件 URL 清洗 | 渲染为 strikethrough 文本 |
| 事件处理器 | <div onload="alert(1)"> | rehype AST | 属性被移除 |
<style> 注入 | <style>body{display:none}</style> | rehype AST | 从语法树中移除 |
| 嵌套攻击 | Callout 内的 script | rehype AST | 递归清洗时移除 |
鲁棒性增强:已验证处理的非标准内容
| 场景 | 示例 | 处理层 | 结果 |
|---|---|---|---|
| 未知 HTML 标签 | <foo>&xxe;</foo> | rehype AST + 运行时 | 转换为 <span> |
| 非标准嵌套标签 | <bar><baz>text</baz></bar> | rehype AST | 全部转换为 <span> |
| 脚注锚点偏移 | [^1] 跳转后被 header 遮挡 | CSS scroll-margin | 补偿 sticky header 高度 |
| Unicode 转义文本 | \u200B 等转义序列 | rehype AST 文本节点 | 转换为实际 Unicode 字符 |
| RTL 混合文本 | 希伯来语 + 中文 + emoji | 正常渲染 | 浏览器 bidi 算法处理 |
| MDXTabs 简写 | <div value="tab1"> 子节点 | 组件自动检测 | 自动生成 tab UI 并切换 |
| MDXTabs + label | <section value="x" label="标签"> | 组件自动检测 | 显示 label 为 tab 按钮文字 |
正确保留的合法内容
- 代码块内的示例代码:
```html <script>...</script> ```作为文本正常显示 - JSON-LD 结构化数据:页面模板中的合法
<script type="application/ld+json">不受影响 - Next.js RSC 脚本:React Server Component 的 hydration 脚本不受影响
鲁棒性增强:未知标签处理
问题:React "unrecognized tag" 警告
当 MDX 内容包含非标准 HTML 标签(如 <foo>、<bar> 等),MDX v3 会将其编译为 React.createElement('foo', ...)。React 在浏览器中渲染时会产生控制台警告:
The tag <foo> is unrecognized in this browser.
If you meant to render a React component, start its name with an uppercase letter.这不仅干扰开发调试,也反映了系统对非预期输入的脆弱性。
解决方案:HTML 元素白名单
在 rehype 插件中维护一份完整的 HTML5 + SVG 有效元素白名单。对于不在白名单中的小写标签,在 AST 级别转换为 <span>:
// 有效 HTML 元素白名单(节选)
const VALID_HTML_ELEMENTS = new Set([
"div",
"span",
"p",
"a",
"img",
"br",
"hr",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"ul",
"ol",
"li",
"table",
"tr",
"td",
"th",
"svg",
"path",
"g",
"circle",
"rect",
// ...完整列表包含 100+ 合法元素
]);
// 在 AST 遍历中
if (!VALID_HTML_ELEMENTS.has(child.tagName)) {
child.properties["data-original-tag"] = child.tagName;
child.tagName = "span"; // 安全降级
}这确保了:
- 作者无需关心标签合规性 — 任何未知标签都会安全降级为
<span> - 内容不丢失 — 子节点内容被完整保留
- 可追溯 —
data-original-tag属性记录原始标签名,便于调试 - 双层保障 — rehype 编译时 + React 运行时 wrapper 组件同步处理
鲁棒性增强:脚注锚点修复
问题:脚注跳转位置偏移
GFM 脚注([^1])的跳转功能依赖浏览器锚点导航。当页面有 sticky header(本项目 h-16 = 4rem)时,锚点目标元素会被 header 遮挡,用户需要手动向上滚动才能看到目标内容。
解决方案:CSS scroll-margin-top
为所有脚注相关的锚点元素添加 scroll-margin-top,补偿 sticky header 高度:
/* GFM 脚注: 锚点偏移补偿 sticky header */
[id^="user-content-fn"] {
scroll-margin-top: 5rem;
}
/* 脚注区域样式 */
.footnotes {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
font-size: 0.875rem;
color: var(--muted-foreground);
}选择器 [id^="user-content-fn"] 同时匹配:
user-content-fn-1(脚注定义,页面底部)user-content-fnref-1(脚注引用,正文中)
5rem 的值与标题元素的 scroll-m-20(Tailwind 类名)保持一致。
鲁棒性增强:Unicode 转义序列处理
问题:MDX 不解释 JS 转义序列
在 JavaScript 中,\u200B 表示零宽空格(Zero-Width Space)。但在 MDX/Markdown 中,反斜杠没有 JavaScript 转义的语义——\u200B 只是 6 个字面字符:\、u、2、0、0、B。
当作者在 MDX 中写入:
零宽字符测试:a\u200B b\u200C c\u200D期望这些转义序列变为真正的零宽字符,但实际渲染结果是字面文本 \u200B。
解决方案:rehype 文本节点预处理
在自定义 rehype 插件的 AST 遍历中,对所有 type: "text" 的节点执行 \uXXXX 模式替换,将其转换为实际 Unicode 字符:
const UNICODE_ESCAPE_PATTERN = /\\u([0-9a-fA-F]{4})/g;
// 在 sanitizeNode 遍历中
if (child.type === "text" && UNICODE_ESCAPE_PATTERN.test(child.value)) {
child.value = child.value.replace(UNICODE_ESCAPE_PATTERN, (_match, hex) =>
String.fromCharCode(parseInt(hex, 16)),
);
}这确保了:
\u200B→ 零宽空格(U+200B)\u200C→ 零宽非连字符(U+200C, ZWNJ)\u200D→ 零宽连字符(U+200D, ZWJ)- 任何
\uXXXX格式的四位十六进制转义都被正确转换 - 处理发生在 AST 阶段,早于 React 编译,确保最终输出正确
鲁棒性增强:MDXTabs 自动检测
问题:简写语法不能产生 Tab 效果
MDXTabs 组件的标准用法需要嵌套 MDXTabsList、MDXTabsTrigger、MDXTabsContent 三个子组件。但 MDX 作者可能使用更直觉的简写形式:
<MDXTabs defaultValue="tab1">
<div value="tab1">Tab 1 内容</div>
<div value="tab2">Tab 2 内容</div>
</MDXTabs>在原有实现中,<div value="tab1"> 只是普通的 HTML div,没有 tab 切换逻辑,所有内容平铺显示。
解决方案:组件级 Props 自动检测 + Label 支持
增强 MDXTabs 组件,自动检测子元素结构:
- 检查 MDXTabsList 是否存在 — 如果子元素中已有
MDXTabsList,沿用标准渲染路径 - 提取 value 和 label prop — 如果没有
MDXTabsList,遍历子元素收集所有value和labelprops - 自动生成 Tab UI — 根据提取的信息自动渲染 tab 按钮栏,显示
label(如果提供)否则显示value - Tab 内容切换 — 带
valueprop 的子元素按照activeTab显示/隐藏
interface TabInfo {
value: string;
label: string;
}
function extractTabInfo(children: ReactNode): TabInfo[] {
const tabs: TabInfo[] = [];
for (const child of Children.toArray(children)) {
if (isValidElement(child)) {
const props = child.props as Record<string, unknown>;
if (typeof props.value === "string" && props.value) {
tabs.push({
value: props.value,
label: typeof props.label === "string" ? props.label : props.value,
});
}
}
}
return tabs;
}
// MDXTabs 内部
const needsAutoTabs = !hasTabsList(children);
const tabInfo = needsAutoTabs ? extractTabInfo(children) : [];支持的语法形式:
<!-- 形式 1: 最简写法,使用 value 作为标签文字 -->
<MDXTabs defaultValue="basic">
<div value="basic">基础内容</div>
<div value="advanced">高级内容</div>
</MDXTabs>
<!-- 形式 2: 带 label,显示更友好的标签文字 -->
<MDXTabs defaultValue="tab1">
<section value="tab1" label="配置说明">
详细的配置步骤...
</section>
<section value="tab2" label="代码示例">
代码块内容(省略代码块标记以避免嵌套问题)
</section>
</MDXTabs>这种设计遵循了接口宽容原则:
- 标准完整语法照常工作,零影响
- 简写
<div value="...">自动升级为 Tab 行为 - 带
labelprop 的元素显示自定义标签文字 - 无
valueprop 的子元素不受影响,正常渲染 - 元素类型不限,任何有效 HTML 元素(
<div>,<section>,<article>等)均可
防御性代理模式(第四层防御)
运行时组件访问劫持
即使有了 rehype 编译时清洗和运行时组件覆盖,MDX 生态仍存在一个潜在风险:非法组件引用。例如:
<MDXTabs.Tab value="x">内容</MDXTabs.Tab>
<Icon.displayName />
<Callout.InvalidChild>文本</Callout.InvalidChild>以上代码在 MDX v3 中会被编译为类似以下形式:
_createMdxContent() {
const MDXTabs = _provideComponents().MDXTabs;
const _components = { /* ... */ };
return (
<>
<MDXTabs.Tab value="x">内容</MDXTabs.Tab>
<Icon.displayName />
</>
);
}由于 MDXTabs.Tab、Icon.displayName 等子组件未定义,运行时会抛出以下错误:
Error: Element type is invalid: expected a string (for built-in components)
or a class/function (for composite components) but got: undefined.这会导致整个页面白屏崩溃。
ES6 Proxy 防御机制
我们使用 ES6 Proxy 在运行时拦截对组件属性的非法访问,实现「不锈钢级鲁棒性」:
// mdx-components.tsx
function withSafetyGuard<T extends React.ComponentType>(componentName: string, Component: T): T {
return new Proxy(Component, {
get(target, prop) {
// 0. React 内部属性白名单:直接透传
const reactInternalProps = new Set([
"$$typeof",
"toJSON",
"displayName",
"propTypes",
"defaultProps",
"contextTypes",
"childContextTypes",
]);
if (reactInternalProps.has(String(prop))) {
return Reflect.get(target, prop);
}
// 1. 正常访问:属性存在或为 Symbol
if (prop in target || typeof prop === "symbol") {
return Reflect.get(target, prop);
}
// 2. 异常访问:返回安全的 Fallback 组件
const attemptedComponent = `${componentName}.${String(prop)}`;
// 记录警告(生产环境仅第一次)
if (!global.__mdxProxyWarnings) {
global.__mdxProxyWarnings = new Set();
}
if (!global.__mdxProxyWarnings.has(attemptedComponent)) {
global.__mdxProxyWarnings.add(attemptedComponent);
console.error(
`[MDX-Security-Shield]: 检测到非法组件引用 <${attemptedComponent} />。\n` +
` 原因: MDX v3 不支持点记法语法 (Component.SubComponent)。\n` +
` 影响: 系统已自动拦截并降级为普通文本,内容已保留,页面未崩溃。\n` +
` 建议: 使用标准 MDX 语法或参考文档中的自动检测语法。`,
);
}
// 3. 返回透明降级组件
const SafeFallback = ({ children }: { children?: React.ReactNode }) => (
<span
className="mdx-fallback-text"
data-attempted-component={attemptedComponent}
data-reason="Unsupported dot notation syntax in MDX v3"
title={`原始语法: <${attemptedComponent}> (不支持)`}
>
{children}
</span>
);
SafeFallback.displayName = `SafeFallback(${attemptedComponent})`;
return SafeFallback;
},
}) as T;
}
// 应用代理保护所有组件
export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
// ... 其他组件 ...
Callout: withSafetyGuard("Callout", Callout),
Icon: withSafetyGuard("Icon", Icon),
MDXTabs: withSafetyGuard("MDXTabs", MDXTabs),
MDXTabsList: withSafetyGuard("MDXTabsList", MDXTabsList),
MDXTabsTrigger: withSafetyGuard("MDXTabsTrigger", MDXTabsTrigger),
MDXTabsContent: withSafetyGuard("MDXTabsContent", MDXTabsContent),
};
}降级样式与用户体验
为降级内容添加视觉调试支持(开发环境):
/* app/globals.css */
.mdx-fallback-text {
display: inline;
color: var(--muted-foreground);
transition: all 0.2s ease;
}
.mdx-fallback-text:hover {
outline: 1px dashed var(--destructive);
outline-offset: 2px;
background-color: var(--destructive);
opacity: 0.1;
}
/* 生产环境下移除调试样式 */
@media (prefers-reduced-data: reduce) {
.mdx-fallback-text:hover {
outline: none;
background-color: transparent;
}
}工作机制示例
输入(MDX 文件):
<Icon.displayName />
<MDXTabs.InvalidChild>文本内容</MDXTabs.InvalidChild>输出(浏览器渲染):
<!-- Icon.displayName 被拦截,降级为空 span -->
<span
class="mdx-fallback-text"
data-attempted-component="Icon.displayName"
title="原始语法: <Icon.displayName> (不支持)"
></span>
<!-- MDXTabs.InvalidChild 被拦截,保留子内容 -->
<span
class="mdx-fallback-text"
data-attempted-component="MDXTabs.InvalidChild"
title="原始语法: <MDXTabs.InvalidChild> (不支持)"
>文本内容</span
>控制台输出:
[MDX-Security-Shield]: 检测到非法组件引用 <Icon.displayName />。
原因: MDX v3 不支持点记法语法 (Component.SubComponent)。
影响: 系统已自动拦截并降级为普通文本,内容已保留,页面未崩溃。
建议: 使用标准 MDX 语法或参考文档中的自动检测语法。关键设计决策
为什么需要 React 内部属性白名单?
React 运行时会访问组件的 $$typeof、displayName、toJSON 等内部属性进行类型检查和序列化。如果这些访问也被 Proxy 拦截,会导致 React 认为返回的是普通对象而非组件,抛出「Element type is invalid」错误。白名单确保这些属性透传到原始组件。
为什么在 Proxy 而非编译时处理?
MDX v3 在编译时允许任意点记法语法(如 <Component.SubComponent>)通过,只有在运行时 React 才会尝试访问 Component.SubComponent 属性。Proxy 是唯一能在运行时拦截属性访问的机制。
防御性代理的局限性
⚠️ 重要:防御性代理只能拦截运行时的组件属性访问,无法阻止 MDX 编译时错误。例如:
<!-- ❌ 编译失败:MDX 无法解析嵌套点记法 -->
<MDXTabs.Invalid.NestedAccess value="x" />以上代码会在 MDX 编译阶段(生成 _createMdxContent 函数时)报错:
Error: Expected component `MDXTabs.Invalid.NestedAccess` to be defined:
you likely forgot to import, pass, or provide it.最佳实践:将防御性代理作为「最后一道防线」,而非主要依赖。在 MDX 文件中始终使用正确的语法。
关键技术决策
为什么选择 rehype 而非 remark
- remark 操作 MDAST(Markdown AST),此时 HTML 元素可能尚未被解析为独立节点
- rehype 操作 HAST(HTML AST),所有 HTML 元素已被解析为结构化节点,更适合精确匹配和移除
- MDX JSX 元素(
mdxJsxFlowElement、mdxJsxTextElement)在 rehype 阶段可见
为什么使用 CJS 而非 ESM
@next/mdx 的内部加载器(mdx-js-loader.js)使用 require() 加载插件。require() 不支持 .mjs 文件,因此必须使用 CommonJS 格式(.cjs 或 .js)。
为什么需要绝对路径
Turbopack 将 loader 配置序列化后传递给 worker 进程。worker 中的 require() 解析相对路径时,基准目录是 loader 所在的 node_modules 目录而非项目根目录。使用 path.join(process.cwd(), ...) 生成绝对路径确保模块被正确定位。
总结
MDX 安全防护与鲁棒性增强的核心原则是纵深防御 + 容错渲染:
- 编译时清洗(rehype 插件)—— 在源头消除威胁,将未知标签安全降级,转换 Unicode 转义序列
- 运行时拦截(组件覆盖 + URL 检查 + 未知标签转换)—— 补充防线
- 组件自动检测(MDXTabs)—— 宽容接受简写语法,自动升级为完整功能
- 防御性代理(ES6 Proxy)—— 拦截非法组件引用,优雅降级避免页面崩溃
- 浏览器级阻断(CSP 头)—— 最终安全网
- 样式修复(scroll-margin-top)—— 确保脚注等交互特性正常工作
这套方案完全兼容 Next.js 16 + Turbopack + @next/mdx 生态,不引入额外依赖,通过暴力测试文件验证了对 XSS 注入、iframe 嵌入、CSS 注入、未知标签、Unicode 转义、MDXTabs 简写语法、非法组件引用、脚注锚点偏移等场景的有效处理。系统对非标准 MDX 内容具备容错能力,作者无需严格遵守标签合规要求,即使出现 <Component.SubComponent> 等非法语法也能优雅降级而不会导致页面崩溃。
- 版本: 1.4.0
- 时间: 2026-02-08 01:32:36
- 作者: Claude Sonnet 4.5
- 内容: 新增防御性代理模式(第四层防御),使用 ES6 Proxy 拦截非法组件引用,实现运行时优雅降级。新增 React 内部属性白名单避免类型错误,完善控制台警告机制和 CSS 降级样式,文档新增防御性代理章节和局限性说明。