- 后端
【免费下载链接】defuddle
Get the main content of any page as Markdown.
导读
本文围绕 defuddle 项目的 security--markdown-image-escaping fixture 展开,讲解当 HTML 图片的alt描述文本中含有字面方括号(如Photo [one])时,defuddle 如何将其转换为语法安全、语义完整的 Markdown 图片语法(![Photo \[one\]](...)),并梳理背后的转义管线、危险 URL 过滤与回归测试验证。读完本文,你将理解 defuddle 的 Markdown 输出层如何保证图片文本既不破坏 Markdown 语法,也不被解析成超链接或注入恶意内容。
问题背景:方括号是 Markdown 链接语法的保留字符
在 Markdown 中,图片语法alt text的 alt 描述部分由方括号界定。若 alt 文本本身包含字面方括号[one],渲染器会把它当作嵌套链接文本的开始/结束标记,从而产生歧义:
![Photo [one]](https://example.com/photo.png)中的[one]可能被解析为嵌套链接的文本;- 更危险的是,若攻击者能在 alt 中注入
](javascript:...)之类的结构,可能把 Markdown 输出重新解析成可点击的危险链接或脚本载体。
defuddle 的 fixture security--markdown-image-escaping.html 构造了这样一个最小案例:
<img src="https://example.com/photo.png" alt="Photo [one]">对应的期望输出(即 security--markdown-image-escaping.md)是:
![Photo \[one\]](https://example.com/photo.png)即:字面方括号被反斜杠转义为\[/\],保证它作为纯文本存在于 alt 中,而不会在 Markdown 渲染阶段被误解析为链接语法。
转义发生的位置:Turndown 图片规则的 alt 处理
defuddle 的 Markdown 输出由 src/markdown.ts 中的createMarkdownContent完成,其底层基于 TurndownService。图片的转换由两个自定义规则实现。
普通<img>规则
// src/markdown.ts 第 447-456 行(image 规则) turndownService.addRule('image', { filter: 'img', replacement: function(content, node) { if (!isGenericElement(node)) return content; const alt = escapeInlineText(node.getAttribute('alt') || ''); const src = formatMarkdownLinkDestination(getBestImageSrc(node)); const titlePart = formatMarkdownLinkTitle(node.getAttribute('title')); return src ? `${alt}` : ''; } });<figure>规则
// src/markdown.ts 第 404-444 行(figure 规则) const alt = escapeInlineText(img.getAttribute('alt') || ''); const src = formatMarkdownLinkDestination(getBestImageSrc(img)); ... return `${src ? `${alt}` : alt}\n\n${caption}\n\n`;两条规则对 alt 文本都调用了同一个转义函数escapeInlineText,这正是Photo [one]变成Photo \[one\]的关键所在:
// src/markdown.ts 第 204-205 行 const escapeInlineText = (value: string): string => escapeEntityLike(baseEscape(value.replace(/[\r\n]+/g, ' ')).replace(/</g, '\\<'));它的工作顺序是:
- 折叠换行:
[\r\n]+统一替换为单个空格,防止 alt 中的换行破坏 Markdown 行结构; - Turndown 基础转义:
baseEscape会按 Markdown 语法规则转义[]()、\等保留字符——[与]在这里被转义为\[与\]; - 标签序列防护:
<被强制转义为\<,避免 alt 中的<tag>形态内容在 CommonMark 渲染器(如 Obsidian)中被当作原始 HTML 吞掉(对应 issue #285 的同类问题); - 实体形态再转义:
escapeEntityLike(src/markdown.ts 第 65-67 行)会把形如<、<这类实体形态中的&再转义为&,防止二次解码时被还原成可执行 HTML。
因此 fixture 中的alt="Photo [one]"会得到Photo \[one\],最终输出![Photo \[one\]](https://example.com/photo.png)。
目标 URL 的净化:危险协议过滤
alt 只负责"显示文本",图片的src才是真正决定输出安全性的部分。formatMarkdownLinkDestination(src/markdown.ts 第 69-76 行)在拼接[](https://link.gitcode.com/i/287975b7b1e2e94c4649fa67792cf506)前执行了多层净化:
function formatMarkdownLinkDestination(href: string): string { if (isDangerousUrl(href)) return ''; href = escapeEntityLike(href) .replace(/\\/g, '\\\\') .replace(/[<>\u0000-\u001f\u007f]/g, char => encodeURIComponent(char)); if (!/\s/.test(href)) return href.replace(/([()])/g, '\\$1'); return `<${href}>`; }- 危险协议直接拒绝:
isDangerousUrl(src/utils/dom.ts 第 73-80 行)会剥离空白与控制字符后检查协议。javascript:、blob:一律视为危险;data:仅当是data:image/内联图片时才放行,其余全部拒绝——因为 data/blob URL 可以把整个文档塞进属性里,绕过脚本与事件属性剥离; - 结构字符转义:
<、>、控制字符被encodeURIComponent编码,空格存在的 URL 用<...>尖括号形式包裹,URL 中的括号()会被\(转义,避免与 Markdown 目标语法冲突; - 空目标退化为纯文本:当
src被判定为危险 URL 时返回空串,image规则此时不输出图片语法而是直接降级;figure规则则只保留alt文本。
前后端双重验证:fixture 与 XSS 测试
fixture 对比测试
security--markdown-image-escaping.md 属于 tests/expected/ 目录下的期望输出,格式为"JSON 元数据代码块 + Markdown 内容"。它由 tests/fixtures.test.ts 驱动:测试自动发现 tests/fixtures/ 下的全部.html文件,用Defuddle(doc, url, { separateMarkdown: true, fetch: offlineFetch })处理(离线 fetch 保证提取路径确定、可复现),再把结果与 expected 文件逐字节比较。新增 fixture 只需放入.html文件后运行npm test生成基线,再人工审查 expected 输出。
Markdown 输出安全测试
tests/markdown-xss.test.ts 对 Markdown 输出做了更强的"再渲染"验证。其核心辅助函数expectSafe会把 Markdown 用marked解析回 HTML,然后断言:
- 所有元素没有任何
on*事件属性; - 所有
href/src都通过isDangerousUrl检查; - 页面中不存在
script或带srcdoc的iframe。
针对图片 alt 与标题,该文件专门有一组测试覆盖了"字面方括号 + 尖括号 + 反斜杠 + 实体 + 特殊字符"的组合:
test('preserves image labels, titles, and structural URL characters without adding elements', () => { const alt = 'A [label] <tag> and \\ brackets <b> © A & B'; const title = 'A "title" with \\ and <tag>'; const src = 'https://example.com/p(1).png?x=1&y=2'; const rendered = expectSafe(createMarkdownContent(`<img alt="${escapeHtml(alt)}" title="${escapeHtml(title)}" src="${escapeHtml(src)}">`, url)); expect(rendered.querySelectorAll('img')).toHaveLength(1); expect(img.getAttribute('alt')).toBe(alt); expect(img.getAttribute('title')).toBe(title); expect(img.getAttribute('src')).toBe(src); });该测试确认了三点关键行为:alt 中的方括号等字符经过转义后,渲染回 HTML 时alt属性值与原始文本逐字一致(说明转义没有破坏文本语义);src中带括号的查询参数 URL 在"Markdown 转义 → HTML 还原"的往返中保持同一目标;并且整个过程不会产生额外元素。这与 fixture 中"description should remain plain image text"的预期完全对应。
另外该文件还验证了 srcset 选择的安全边界:当srcset中混入javascript:alert(1)等危险候选时,getBestImageSrc(src/markdown.ts 第 136-169 行)只会采纳通过isDangerousUrl检查的最大宽度候选,测试断言最终输出回退到安全的src。
端到端复现:用 CLI 与 Node API 观察转义结果
你可以在本地复现这一转义行为。仓库 README 提供了三种入口(README.md 第 19-148 行)。
CLI 方式
# 解析本地 HTML 文件并输出 Markdown npx defuddle parse tests/fixtures/security--markdown-image-escaping.html --markdown--markdown(别名-m)会开启 Markdown 转换(README 的 CLI 选项表见 README.md 第 102-116 行)。对上述 fixture 运行后,图片行应输出为![Photo \[one\]](https://example.com/photo.png)。
Node.js 方式
import { parseHTML } from 'linkedom'; import { Defuddle } from 'defuddle/node'; const { document } = parseHTML(htmlWithImage); const result = await Defuddle(document, 'https://example.com/article', { markdown: true }); console.log(result.content); // 包含 ![Photo \[one\]](https://example.com/photo.png)对应选项:markdown(默认false,把提取内容转换为 Markdown)与separateMarkdown(默认false,保留 HTML 到content、Markdown 到contentMarkdown),二者最终都经由toMarkdown调用createMarkdownContent(src/markdown.ts 第 1014-1024 行)。fixture 测试采用的正是separateMarkdown: true路径。
更深一层:从源代码结构看转义管线的设计取舍
从 src/markdown.ts 的结构可以推断,defuddle 的 Markdown 转义不是逐条硬编码规则,而是一条贯穿全部文本出口的公共管线:
- 所有文本出口统一走转义:除图片 alt 外,链接文本、callout 标题(
escapeInlineText)、图片 title(formatMarkdownLinkTitle)等同样复用同一套转义逻辑,保证[、]、<、&等字符在任何上下文中都以相同规则处理; - 转义发生在 DOM 层面之外:HTML 提取阶段先由 src/standardize.ts 与 src/elements/images.ts 完成图片去重、懒加载
data-src/data-original提升、srcset归一化等标准化(alt属性在白名单 ALLOWED_ATTRIBUTES 中,会被保留传递),Markdown 转义只负责"最后一步"的语法化,职责单一; - 转义以渲染器兼容为目标:
escape覆写(src/markdown.ts 第 193-203 行)特意只转义"后接标签形态字符"的<,从而保留<a@b.com>、<https://x>这类合法 autolink 不被破坏,可见转义策略是"最小必要转义"。
结语
security--markdown-image-escaping这条 fixture 虽短,却浓缩了 defuddle Markdown 输出层的两个核心目标:语法正确(alt 中的字面方括号必须转义为\[one\],否则会被渲染成链接)与输出安全(目标 URL 必须经过isDangerousUrl过滤,javascript:/blob:/非图片data:一律拒绝)。理解 src/markdown.ts 中escapeInlineText与formatMarkdownLinkDestination的分工,以及 tests/markdown-xss.test.ts 的"再渲染断言"验证方式,可以帮助你在接入 defuddle 时判断自己的 Markdown 管道是否也需要同等级别的转义与净化防线。
- 后端
【免费下载链接】defuddle
Get the main content of any page as Markdown.
相关推荐
Pandoc Markdown 图片转 Typst 实战:figure 图、行内图片与尺寸/alt 属性的底层转换机制
Pandoc Markdown 图片转 Typst 实战:figure 图、行内图片与尺寸/alt 属性的底层转换机制 本文基于 pandoc 仓库中的回归测试
文档开发工具CLIObsidian Importer中OneNote内容转Markdown时的尖括号转义问题解析
Obsidian Importer中OneNote内容转Markdown时的尖括号转义问题解析 在文档管理工具Obsidian的Importer插件使用过程中,
数据集成知识管理PyMuPDF 文档格式转换完全指南:Markdown/EPUB 转 PDF、PDF 转 Markdown/SVG/图片/DOCX
PyMuPDF 文档格式转换完全指南:Markdown/EPUB 转 PDF、PDF 转 Markdown/SVG/图片/DOCX 本文以 PyMuPDF 官方
图像处理
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考