MarkText 的 XSS 防线:从一份恶意文档夹具到 DOMPurify 多级净化的完整解析
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.ts 的 launchElectron 通过 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>的标签定界符转义掉——注意它只转义开/闭标签本身(<script>),保留标签体原文,从而在不破坏展示的前提下让这些标签退化为不可执行的文本节点。
“先转义、后净化”的双保险意味着:即使 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 试净化:若结果为空(即该标签被安全策略整体拒绝,如 embed、object、iframe),则渲染时降级为 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) 逐一校验:onerror、onload、onclick 等事件属性与 javascript:/vbscript: 协议一律被拒,https: 链接、相对路径、title、图片 src/alt 放行。夹具中所有 onerror=/onload= 属性正是靠这一层被剥离。
另外,img 标签在 htmlTag 的 switch 分支中被特判路由到统一的图片渲染器(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}"><Empty HTML Block></div>`;
}
else {
const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, 'text/html');
// ... 图片 src 重写 ...
this.domNode!.innerHTML = doc.documentElement!.querySelector('body')!.innerHTML;
}
}
三个值得注意的细节:
- 用户偏好介入:
disableHtml选项默认false(见 config/index.ts),用户可在偏好中开启“禁用 HTML”,一旦开启,该块全文先整体转义,HTML 块预览退化为纯文本显示,攻击面直接归零; - 净化结果二次解析:净化后的字符串交给
DOMParser解析,图片src再经getImageSrc重写(处理相对路径),最终只取body.innerHTML落入一个contenteditable="false"的容器(构造函数 L44-L47),预览区本身不可编辑、不响应拼写检查; - 同样的
sanitize(html, PREVIEW_DOMPURIFY_CONFIG, disableHtml)模式也复用于图表预览 diagramPreview.ts、粘贴清洗 paste.ts 与 HTML 导出 markdownToHtml.ts、renderToStaticHTML.ts(后者使用EXPORT_DOMPURIFY_CONFIG),形成全仓库统一的净化入口。
七、单元测试如何锁死这些契约
utils/tests/dompurifyXss.spec.ts 用 jsdom 环境把上述契约固化成可回归的断言:
- 标签降级契约:
sanitize('<embed>')、sanitize('<object>')、sanitize('<iframe>')必须返回空字符串(触发 htmlTag 的 span 降级),而<span>、<code>、<mark>必须保留; - 属性过滤契约:
isValidAttribute必须拒绝a onclick、a onmouseover、img onerror、a 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),可以分别运行两层测试:
- 单元层(无需图形环境,jsdom 内验证净化契约):在 packages/muya 下运行
pnpm test:unit(对应 vitest.spec.config.ts); - E2E 层(启动完整 Electron 应用载入 xss.md 并断言不崩溃):在 packages/desktop 下运行
pnpm test:e2e xss,实际执行由 playwright.config.ts 驱动的 xss.spec.ts。
需要说明的适用前提:该 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 的项目而言,这套“转义-净化-降级-锁契约”的分层方案都具备直接参考价值。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00