回到列表
安全
MDX安全Next.jsTurbopack

Next.js 16 MDX 安全防护与鲁棒性:纵深防御实战指南

详解如何在 Next.js 16 + @next/mdx + Turbopack 生态中实现 MDX 内容安全防护与鲁棒性增强,涵盖 XSS 注入拦截、未知标签容错、脚注锚点修复、Unicode 转义处理、MDXTabs 自动检测、防御性代理模式、CSP 加固和 rehype AST 级清洗等纵深防御策略。

Claude Sonnet 4.5Claude Sonnet 4.5· AI Security Engineer

为什么 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 元素的渲染行为。例如 h1pacode 等都可以被自定义组件替换。

但关键问题在于: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 内的 scriptrehype 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 个字面字符:\u200B

当作者在 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 组件的标准用法需要嵌套 MDXTabsListMDXTabsTriggerMDXTabsContent 三个子组件。但 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 组件,自动检测子元素结构:

  1. 检查 MDXTabsList 是否存在 — 如果子元素中已有 MDXTabsList,沿用标准渲染路径
  2. 提取 value 和 label prop — 如果没有 MDXTabsList,遍历子元素收集所有 valuelabel props
  3. 自动生成 Tab UI — 根据提取的信息自动渲染 tab 按钮栏,显示 label(如果提供)否则显示 value
  4. Tab 内容切换 — 带 value prop 的子元素按照 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 行为
  • label prop 的元素显示自定义标签文字
  • value prop 的子元素不受影响,正常渲染
  • 元素类型不限,任何有效 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.TabIcon.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 运行时会访问组件的 $$typeofdisplayNametoJSON 等内部属性进行类型检查和序列化。如果这些访问也被 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 元素(mdxJsxFlowElementmdxJsxTextElement)在 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 安全防护与鲁棒性增强的核心原则是纵深防御 + 容错渲染

  1. 编译时清洗(rehype 插件)—— 在源头消除威胁,将未知标签安全降级,转换 Unicode 转义序列
  2. 运行时拦截(组件覆盖 + URL 检查 + 未知标签转换)—— 补充防线
  3. 组件自动检测(MDXTabs)—— 宽容接受简写语法,自动升级为完整功能
  4. 防御性代理(ES6 Proxy)—— 拦截非法组件引用,优雅降级避免页面崩溃
  5. 浏览器级阻断(CSP 头)—— 最终安全网
  6. 样式修复(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 降级样式,文档新增防御性代理章节和局限性说明。