ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

MarkText 的 XSS 防线:从一份恶意文档夹具到 DOMPurify 多级净化的完整解析 MarkText 的 XSS 防线从一份恶意文档夹具到 DOMPurify 多级净化的完整解析【免费下载链接】marktextA simple and elegant markdown editor, available for Linux, macOS and Windows.项目地址: https://gitcode.com/gh_mirrors/ma/marktextMarkText 作为 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. 直接脚本与事件处理器scriptprocess.crash()/script img src# onerrorprocess.crash() svg/onloadprocess.crash()裸script最直白的注入依赖解析器是否将其当作可执行脚本img onerror图片加载失败触发onerror回调src#保证必然失败svg/onload斜杠替代空格的写法利用 SVG 元素的onload生命周期。2. SVG 内嵌脚本svg width100 height100 scriptprocess.crash()/script rect width100 height100 stylefill:rgb(0,0,0) / /svgSVG 命名空间内部的script在浏览器环境默认可执行是绕过“移除script标签”这类粗糙黑名单的经典手段。3. iframe 全变体iframe srcjavascript:process.crash();/iframe iframe src# onerrorprocess.crash() onloadprocess.crash()/iframe iframe srcnot-a-real-file.extension onerrorprocess.crash() onloadprocess.crash()/iframe iframe/srcdata:text/html,svg onload\process.crash()\覆盖javascript:协议、事件处理器、data:URI 以及斜杠拼接的畸形标签四种 iframe 攻击形态。4. base64 编码的 embed 向量embed srcdata:image/svgxml;base64,PHN2ZyB4bWxuczpzdmc9... typeimage/svgxml AllowScriptAccessalways/embed这段 base64 解码后是一个带scriptthrow new Error(XSS 8)/script的 SVG。这是整个夹具中最具代表性的一条embed标签本身不是脚本标签若渲染器直接以原始标签名创建 DOM 节点就会在加载 SVG 数据时执行其中的脚本。5. 带属性的 script 变体script fooprocess.crash()/script带无关属性的 script 标签测试黑名单正则是否只匹配了精确的script写法。6. Markdown 围栏代码块中的“伪 info string”注入style/onloadprocess.crash() fooscriptprocess.crash()/script foo这两个向量把 HTML 塞进围栏代码块的 info string语言标识位置。正常实现会把 info string 当作普通文本但早期 MarkText 的行内 HTML 渲染器曾以原始标签名作为 snabbdom 选择器攻击者可借此诱导解析出真实 DOM 节点——这正是仓库 [dompurifyXss.spec.ts](https://link.gitcode.com/i/cf17efc933ee9aa8cfecac987a5475a9) 注释中提到的历史修复对应上游 “Fix #1390 prevent XSS attack”的验证场景。 ## 二、E2E 验证方式崩溃即失败 这份夹具由 [xss.spec.ts](https://link.gitcode.com/i/1dddd1f701df6e9b878a84a77b4f44cc) 消费验证逻辑极其简洁而严格 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.tsimport type { Config } from dompurify; import DOMPurify from dompurify; const { sanitize, isValidAttribute } DOMPurify(); export { Config, isValidAttribute }; export default sanitize;模块顶层创建单例导出sanitize净化函数与isValidAttribute属性白名单校验两个原语分别服务于“整段 HTML 净化”和“逐属性过滤”两类场景。在其上再包一层策略函数见 utils/index.tsexport 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;scriptgt;保留标签体原文从而在不破坏展示的前提下让这些标签退化为不可执行的文本节点。“先转义、后净化”的双保险意味着即使 DOMPurify 配置未来出现疏漏script/style/title三类内容也无法作为活动 HTML 进入 DOM。四、两套 DOMPurify 配置预览与导出的差异净化配置定义在 config/index.tsexport 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_ATTRfalse拒绝任意data-*属性收敛属性面USE_PROFILES.html/svg/svgFilterstrue允许常规 HTML 与 SVG 绘制元素如夹具中的rect可安全展示但各 profile 默认剔除script、事件属性与危险协议mathMlfalse不启用 MathML profileADD_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 srcdata:image/svgxml;base64,...向量的克星——历史上 MarkText 曾因直接用原始标签名作为 snabbdom 选择器而让embed真身落地回归背景见 dompurifyXss.spec.ts 的注释。块级元素BLOCK_TYPE6同样降级为span以符合“行内容器不能嵌套块级元素”的 DOM 结构约束。第二级属性过滤门L78-L84for (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}lt;Empty HTML Blockgt;/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落入一个contenteditablefalse的容器构造函数 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 hrefjavascript:alert(1)、a hrefvbscript:alert(1)同时保留https:链接、相对路径、title、图片src/altdata-align 白名单EXPORT_DOMPURIFY_CONFIG.ADD_ATTR必须包含data-align且净化img srcx.png contenteditable="false">【免费下载链接】marktextA simple and elegant markdown editor, available for Linux, macOS and Windows.项目地址: https://gitcode.com/gh_mirrors/ma/marktext创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表