首页
/ MarkText 的 XSS 防线:从一份恶意文档夹具到 DOMPurify 多级净化的完整解析

MarkText 的 XSS 防线:从一份恶意文档夹具到 DOMPurify 多级净化的完整解析

2026-09-04 17:00:36作者:江焘钦

MarkText 作为 Electron 桌面端 Markdown 编辑器,必须直面一个经典安全问题:用户打开的 Markdown 文档本身可能就是攻击载体。本文以 E2E 测试夹具 xss.md 为入口,完整梳理其中每一类攻击向量的设计意图,并结合 muya 编辑器内核 中的 DOMPurify 净化层、HTML 预览块与行内 HTML 标签降级逻辑,还原 MarkText 从“文件载入”到“渲染进 DOM”全链路的 XSS 防御实现,帮助读者掌握在富文本/所见即所得编辑器中构建多层防注入的实战方法。

一、夹具文件 xss.md:一份系统化的攻击向量清单

xss.md 并不是普通文档,而是一份刻意构造的“恶意文档”。它的目标是让编辑器一旦存在任何 HTML 注入漏洞就立即崩溃——所有 payload 都调用 Electron 渲染进程独有的 process.crash()。只要应用不崩溃,就说明这些向量全部被拦截。全文分为 HTML 与 Markdown 两大类:

1. 直接脚本与事件处理器

<script>process.crash()</script>
<img src="#" onerror="process.crash()">
<svg/onload="process.crash()">
  • <script>:最直白的注入,依赖解析器是否将其当作可执行脚本;
  • <img onerror>:图片加载失败触发 onerror 回调(src="#" 保证必然失败);
  • <svg/onload>:斜杠替代空格的写法,利用 SVG 元素的 onload 生命周期。

2. SVG 内嵌脚本

<svg width="100" height="100">
  <script>process.crash()</script>
  <rect width="100" height="100" style="fill:rgb(0,0,0)" />
</svg>

SVG 命名空间内部的 <script> 在浏览器环境默认可执行,是绕过“移除 <script> 标签”这类粗糙黑名单的经典手段。

3. iframe 全变体

<iframe src="javascript:process.crash();"></iframe>
<iframe src="#" onerror="process.crash()" onload="process.crash()"></iframe>
<iframe src="not-a-real-file.extension" onerror="process.crash()" onload="process.crash()"></iframe>
<iframe/src="data:text/html,<svg onload=\"process.crash()\"">

覆盖 javascript: 协议、事件处理器、data: URI 以及斜杠拼接的畸形标签四种 iframe 攻击形态。

4. base64 编码的 embed 向量

<embed src="data:image/svg+xml;base64,PHN2ZyB4bWxuczpzdmc9..."
       type="image/svg+xml" AllowScriptAccess="always"></embed>

这段 base64 解码后是一个带 <script>throw new Error('XSS 8')</script> 的 SVG。这是整个夹具中最具代表性的一条:<embed> 标签本身不是脚本标签,若渲染器直接以原始标签名创建 DOM 节点,就会在加载 SVG 数据时执行其中的脚本

5. 带属性的 script 变体

<script foo>process.crash()</script>

带无关属性的 script 标签,测试黑名单正则是否只匹配了精确的 <script> 写法。

6. Markdown 围栏代码块中的“伪 info string”注入

```<style/onload=process.crash()>
foo
```"><script>process.crash()</script>
foo

这两个向量把 HTML 塞进围栏代码块的 info string(语言标识位置)。正常实现会把 info string 当作普通文本,但早期 MarkText 的行内 HTML 渲染器曾以原始标签名作为 snabbdom 选择器,攻击者可借此诱导解析出真实 DOM 节点——这正是仓库 [dompurifyXss.spec.ts](https://gitcode.com/gh_mirrors/ma/marktext/blob/43bd8b77795fb27b1a9512737c000f7362031ea0/packages/muya/src/utils/__tests__/dompurifyXss.spec.ts?utm_source=gitcode_repo_files) 注释中提到的历史修复(对应上游 “Fix #1390 prevent XSS attack”)的验证场景。

## 二、E2E 验证方式:崩溃即失败

这份夹具由 [xss.spec.ts](https://gitcode.com/gh_mirrors/ma/marktext/blob/43bd8b77795fb27b1a9512737c000f7362031ea0/packages/desktop/test/e2e/xss.spec.ts?utm_source=gitcode_repo_files) 消费,验证逻辑极其简洁而严格:

```typescript
const { app: electronApp, page: firstPage } = await launchElectron(['test/e2e/data/xss.md'])
// 等待解析与渲染
await new Promise((resolve) => setTimeout(resolve, 3000))

const { isVisible, isCrashed } = await app.evaluate(async(process) => {
  const mainWindow = process.BrowserWindow.getAllWindows()[0]
  return {
    isVisible: mainWindow.isVisible(),
    isCrashed: mainWindow.webContents.isCrashed()
  }
})
expect(isVisible).toBeTruthy()
expect(isCrashed).toBeFalsy()

其中 helpers.tslaunchElectron 通过 Playwright 的 _electron.launch 以独立 --user-data-dir 临时目录启动完整应用,并将夹具路径作为命令行参数传入(即 MarkText 打开指定文件的标准入口)。整个断言的哲学是:只要 process.crash() 被任一向量触发,webContents.isCrashed() 就会为真,测试立即失败。这是一种以“进程崩溃”为金丝雀(canary)的端到端安全回归测试。

三、净化核心:DOMPurify 封装与两层 HTML 转义

真正的防御实现集中在 muya 内核的 utils/dompurify.ts

import type { Config } from 'dompurify';
import DOMPurify from 'dompurify';

const { sanitize, isValidAttribute } = DOMPurify();

export { Config, isValidAttribute };
export default sanitize;

模块顶层创建单例,导出 sanitize(净化函数)与 isValidAttribute(属性白名单校验)两个原语,分别服务于“整段 HTML 净化”和“逐属性过滤”两类场景。

在其上再包一层策略函数,见 utils/index.ts

export function sanitize(html: string, purifyOptions: Config, disableHtml: boolean) {
    if (disableHtml)
        return runSanitize(escapeHTML(html), purifyOptions);
    else
        return runSanitize(escapeInBlockHtml(html), purifyOptions);
}

两个分支对应两种威胁模型:

  • disableHtml = true:整个文档禁止 HTML,先对全文escapeHTML(转义 & < > ' " 五个字符),HTML 变成纯文本后再送 DOMPurify,任何标签都不可能被解析;
  • disableHtml = false:允许 HTML 块,但先用 escapeInBlockHtml(同文件 L174-L181)把 <style><script><title>标签定界符转义掉——注意它只转义开/闭标签本身(&lt;script&gt;),保留标签体原文,从而在不破坏展示的前提下让这些标签退化为不可执行的文本节点。

“先转义、后净化”的双保险意味着:即使 DOMPurify 配置未来出现疏漏,script/style/title 三类内容也无法作为活动 HTML 进入 DOM。

四、两套 DOMPurify 配置:预览与导出的差异

净化配置定义在 config/index.ts

export const PREVIEW_DOMPURIFY_CONFIG = {
    // 不禁用 `class`,因为 code 元素靠 class 展示语言名
    FORBID_ATTR: ['contenteditable'],
    ALLOW_DATA_ATTR: false,
    USE_PROFILES: {
        html: true,
        svg: true,
        svgFilters: true,
        mathMl: false,
    },
    RETURN_TRUSTED_TYPE: false,
};

export const EXPORT_DOMPURIFY_CONFIG = {
    FORBID_ATTR: ['contenteditable'],
    ALLOW_DATA_ATTR: false,
    ADD_ATTR: ['data-align'],
    USE_PROFILES: {
        html: true,
        svg: true,
        svgFilters: true,
        mathMl: false,
    },
    RETURN_TRUSTED_TYPE: false,
    // 允许 file: 协议,以便在 Windows 上导出图片(#1997)
    ALLOWED_URI_REGEXP:
    /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp|file):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
};

关键参数解读:

参数 取值 作用
FORBID_ATTR ['contenteditable'] 显式禁止可编辑属性,防止恶意文档把静态预览区域变成注入编辑入口
ALLOW_DATA_ATTR false 拒绝任意 data-* 属性,收敛属性面
USE_PROFILES.html/svg/svgFilters true 允许常规 HTML 与 SVG 绘制元素(如夹具中的 <rect> 可安全展示),但各 profile 默认剔除 script、事件属性与危险协议
mathMl false 不启用 MathML profile
ADD_ATTR: ['data-align'] 仅导出配置 导出时保留 MarkText 的图片对齐元数据;预览端不保留,因为编辑器从块状态重新推导对齐
ALLOWED_URI_REGEXP file: 默认配置会因 file: 协议剥离 Windows 本地图片引用,此处按 #1997 显式放行

从源码结构看,mathMl: false 加上 profile 默认的 URI 检查,恰好把夹具中 javascript:data:text/html 这类 iframe 向量挡在门外;而 svg: true 只放行图形元素,不放行 <script>,因此夹具第 11-14 行的 SVG 内嵌脚本会被剥离。

五、行内 HTML 标签的两级降级:标签门 + 属性门

Markdown 文档中的行内 HTML 由 inlineRenderer/renderer/htmlTag.ts 渲染。其中 buildRawHtmlTag 实现了两级安全门:

第一级:标签降级门(L51-L53):

// Use code !sanitize(`<${tag}>`) to filter some malicious tags. for example: <embed>.
let selector
    = BLOCK_TYPE6.includes(tag) || !sanitize(`<${tag}>`) ? 'span' : tag;

它把“标签名”本身丢给 DOMPurify 试净化:若结果为空(即该标签被安全策略整体拒绝,如 embedobjectiframe),则渲染时降级为 span,标签名仅作为灰色源码展示,绝不进入 DOM。这正是夹具中 <embed src="data:image/svg+xml;base64,..."> 向量的克星——历史上 MarkText 曾因直接用原始标签名作为 snabbdom 选择器而让 <embed> 真身落地(回归背景见 dompurifyXss.spec.ts 的注释)。块级元素(BLOCK_TYPE6)同样降级为 span,以符合“行内容器不能嵌套块级元素”的 DOM 结构约束。

第二级:属性过滤门(L78-L84):

for (const attr of Object.keys(attrs)) {
    if (attr !== 'id' && attr !== 'class') {
        const attrData = attrs[attr];
        if (attrData && isValidAttribute(tag, attr, attrData))
            data.attrs[attr] = attrData;
    }
}

通过 DOMPurify 导出的 isValidAttribute(tag, attr, value) 逐一校验:onerroronloadonclick 等事件属性与 javascript:/vbscript: 协议一律被拒,https: 链接、相对路径、title、图片 src/alt 放行。夹具中所有 onerror=/onload= 属性正是靠这一层被剥离。

另外,img 标签在 htmlTagswitch 分支中被特判路由到统一的图片渲染器(L140-L152),意味着行内 <img> 走的是编辑器自身的受控图片通道,而非裸 DOM 属性透传。

六、HTML 块预览:sanitize + disableHtml 的落点

独立的 HTML 块(如夹具第一类向量整体作为块级内容)由 htmlPreview.ts 渲染:

update(html = this._html) {
    if (this._html !== html)
        this._html = html;

    const { disableHtml } = this.muya.options;
    const htmlContent = sanitize(html, PREVIEW_DOMPURIFY_CONFIG, disableHtml) as string;

    // handle empty html block
    if (isEmptyHtmlBlock(htmlContent)) {
        this.domNode!.innerHTML = `<div class="${CLASS_NAMES.MU_EMPTY}">&lt;Empty HTML Block&gt;</div>`;
    }
    else {
        const parser = new DOMParser();
        const doc = parser.parseFromString(htmlContent, 'text/html');
        // ... 图片 src 重写 ...
        this.domNode!.innerHTML = doc.documentElement!.querySelector('body')!.innerHTML;
    }
}

三个值得注意的细节:

  1. 用户偏好介入disableHtml 选项默认 false(见 config/index.ts),用户可在偏好中开启“禁用 HTML”,一旦开启,该块全文先整体转义,HTML 块预览退化为纯文本显示,攻击面直接归零;
  2. 净化结果二次解析:净化后的字符串交给 DOMParser 解析,图片 src 再经 getImageSrc 重写(处理相对路径),最终只取 body.innerHTML 落入一个 contenteditable="false" 的容器(构造函数 L44-L47),预览区本身不可编辑、不响应拼写检查;
  3. 同样的 sanitize(html, PREVIEW_DOMPURIFY_CONFIG, disableHtml) 模式也复用于图表预览 diagramPreview.ts、粘贴清洗 paste.ts 与 HTML 导出 markdownToHtml.tsrenderToStaticHTML.ts(后者使用 EXPORT_DOMPURIFY_CONFIG),形成全仓库统一的净化入口。

七、单元测试如何锁死这些契约

utils/tests/dompurifyXss.spec.ts 用 jsdom 环境把上述契约固化成可回归的断言:

  • 标签降级契约sanitize('<embed>')sanitize('<object>')sanitize('<iframe>') 必须返回空字符串(触发 htmlTag 的 span 降级),而 <span><code><mark> 必须保留;
  • 属性过滤契约isValidAttribute 必须拒绝 a onclicka onmouseoverimg onerrora href="javascript:alert(1)"a href="vbscript:alert(1)",同时保留 https: 链接、相对路径、title、图片 src/alt
  • data-align 白名单EXPORT_DOMPURIFY_CONFIG.ADD_ATTR 必须包含 data-align,且净化 <img src="x.png" data-align="center" /> 后该属性仍然存在——保证图片对齐元数据在导出 Markdown 时不丢失;
  • 预览配置回归(#3594/#3697):预览配置必须保留 style 属性(内联样式 HTML 块在编辑器与导出表现一致),但必须剥离 onclick

该文件的注释明确指出这些测试是“防止未来 DOMPurify 升级或配置变更悄悄重开漏洞”的回归锁,体现了“把安全假设写成测试”的工程实践。

八、防御矩阵:攻击向量到代码位置的对应关系

夹具中的向量 拦截位置 机制
<script>(含 <script foo> sanitize 工具 escapeInBlockHtml/escapeHTML 先行转义,DOMPurify 兜底剥离
<img onerror><svg/onload> 等事件属性 htmlTag.ts 属性门 isValidAttribute 拒绝事件属性
<svg> 内嵌 <script> PREVIEW_DOMPURIFY_CONFIG svg profile 允许图形元素但剔除脚本元素
<iframe> 各变体(含 data:/javascript: URI) DOMPurify URI 正则 + 标签降级门 默认 ALLOWED_URI_REGEXP 拒绝 javascript:/data:iframe 标签净化为空后降级为 span
base64 <embed>(XSS 8) htmlTag.ts 标签门 !sanitize('<embed>') 为真,标签降级为 span,永不生成真实 <embed> 节点
围栏 info string 注入 行内渲染器 + 单元测试 dompurifyXss.spec.ts info string 不作为 HTML 解析;即使被行内化也走标签/属性双门

九、如何在本地复现验证

在仓库根目录按 monorepo 约定安装依赖后(pnpm install),可以分别运行两层测试:

需要说明的适用前提:该 E2E 断言依赖 Electron 渲染进程的 process.crash(),因此只能在完整启动 MarkText 桌面应用的环境中生效;而 DOMPurify 层契约可以在纯 Node/jsdom 环境中独立验证。

十、小结

xss.md 的价值不在于文件本身,而在于它代表了一套“以崩溃为信号”的安全测试思路:先枚举攻击向量(脚本标签、事件属性、SVG 命名空间脚本、iframe 协议与 data URI、base64 编码载荷、Markdown 语法缝隙),再以端到端金丝雀验证应用整体免疫。MarkText 的防御则体现为纵深四层的组合——入口转义escapeHTML/escapeInBlockHtml + disableHtml 偏好开关)、整段净化(DOMPurify 的 preview/export 双配置)、标签降级(净化试算结果为空即回退 span)、属性过滤isValidAttribute 白名单),最后由 dompurifyXss.spec.ts 把每一层的安全假设固化为可回归的单元测试。对任何构建所见即所得编辑器或处理不可信 HTML/Markdown 的项目而言,这套“转义-净化-降级-锁契约”的分层方案都具备直接参考价值。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
903
1.82 K
docsdocs
暂无描述
Markdown
888
5.78 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.51 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384
flutter_flutterflutter_flutter
本仓库是 Flutter SDK 与 Flutter Engine 的 OpenHarmony 适配版本,由 CPF-Flutter 团队维护。开发者可使用熟悉的 Flutter 技术栈开发 OpenHarmony 应用,3.35.7 及以后的适配版本可基于本仓库源码构建支持 OpenHarmony 的 Flutter Engine。
Dart
1.17 K
341