ARTICLE DETAIL

资讯详情

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

HTML尖括号转义详解:从渲染事故到XSS防御的完整指南

HTML尖括号转义详解:从渲染事故到XSS防御的完整指南 1. 从一个被忽略的渲染事故说起前阵子帮朋友排查一个后台管理系统的显示问题现象很怪运营人员在富文本编辑器里输入了一段商品描述里面包含和符号比如“尺寸 50cm”结果前端页面渲染出来之后这个“ 50cm”连同后面的半段文字全部消失了。打开浏览器控制台一看DOM 结构里凭空多出来一个不存在的标签把后面的内容全吞了。这个问题的根因就是今天要聊的主角——HTML 实体转义。很多人第一次接触这个概念是在学 HTML 基础的时候老师会说“尖括号要写成lt;和gt;”然后就没有然后了。但真正到了项目里你会发现这个知识点牵扯出来的东西远比想象中多XSS 防御、富文本渲染、邮件模板、PDF 导出、Markdown 转换、前后端数据传递……几乎每一条链路上都有它的身影。这篇内容我打算把和这两个符号的转义问题彻底讲透。不是停留在“怎么写”的层面而是把为什么必须转义、在哪些场景下会出问题、不同技术栈下怎么处理、以及转义和 XSS 防御之间到底是什么关系这几件事串起来讲清楚。适合已经写过一些页面、但对安全边界和字符处理还比较模糊的开发者也适合做后端接口、需要处理用户输入的同学参考。先给一个最基础的认知锚点HTML 里的和是标记语言的语法字符它们不是普通文本而是告诉浏览器“这里开始/结束一个标签”。当你想让它们作为普通文字显示时就必须用实体引用的方式告诉浏览器“别把它当语法解析”。这个逻辑听起来简单但实际项目里出问题的场景几乎都是因为某一条链路上有人忘了这件事。2. 尖括号为什么必须转义解析器的视角2.1 浏览器解析 HTML 的真实过程要理解转义的必要性得先站在浏览器的角度想问题。浏览器拿到一段 HTML 字符串之后并不是先理解语义再渲染而是走一套词法分析 语法分析的流程。词法分析阶段解析器会逐字符扫描遇到就进入“标签开始状态”遇到就认为标签结束。这个规则是硬编码在 HTML 解析规范里的没有任何商量余地。所以当你写p尺寸 50cm/p的时候解析器看到第一个进入标签状态读到p认为是标签名读到结束开始标签。接着读到“尺寸”正常文本。然后读到第二个又进入标签状态读到空格解析器会认为这是一个无效标签名但它不会报错而是尝试容错处理——把 50cm/p这一整段当成一个畸形的标签结构吞掉。最终你看到的结果就是文字丢失。这就是为什么用户输入的内容在拼进 HTML 之前必须转义。用户输入的对解析器来说和开发者手写的没有任何区别它不会知道“这个尖括号是用户想表达的数学符号”。2.2 五个必须记住的核心实体HTML 实体转义不是只有尖括号两个但和、强相关的核心实体有这么几个我列个表方便对照字符实体名称实体编号必须转义的场景lt;#60;几乎所有用户输入场景gt;#62;几乎所有用户输入场景amp;#38;只要出现就必须转义否则会破坏其他实体quot;#34;出现在 HTML 属性值内部时#39;#39;出现在单引号包裹的属性值内部时这里有个容易被忽略的点符号的转义优先级最高。因为所有实体都以开头如果你先把转成了lt;然后再去处理就会把刚生成的lt;又转成amp;lt;最终显示出来的是字面量lt;而不是。所以正确的转义顺序永远是先处理再处理其他字符。2.3 实体名称和实体编号怎么选lt;和#60;效果完全一样浏览器都认。那实际项目里用哪个我的经验是可读性优先的场景比如手写模板、邮件正文用实体名称lt;一眼能看出是小于号。兼容性优先的场景比如老系统、某些邮件客户端用实体编号#60;不依赖实体名称表的支持。程序化转义比如后端统一处理用编号更省心因为不用维护一张名称映射表直接按字符码点生成就行。有个细节值得提一下HTML5 规范里实体名称是区分大小写的LT;和lt;不是一回事前者在 HTML5 里其实也认但在 XHTML 里就可能出问题。所以如果你不确定目标环境用编号是最稳的。3. 转义与 XSS一条被误解的边界线3.1 XSS 的本质是“代码被当成了数据执行”聊转义绕不开 XSS。很多人对 XSS 的理解停留在“输入scriptalert(1)/script会弹窗”但这只是表象。XSS 的本质是攻击者让本该作为数据的内容被浏览器当成了可执行的代码。回到解析器的视角。假设一个搜索页面URL 参数是keyword后端直接把参数拼进 HTMLp您搜索的关键词是span用户输入/span/p如果用户输入的是img srcx onerroralert(1)拼进去之后变成p您搜索的关键词是spanimg srcx onerroralert(1)/span/p浏览器解析到img标签加载图片失败触发onerror脚本就执行了。整个过程里攻击者输入的尖括号没有被转义导致它从“文本”变成了“标签语法”。3.2 转义能挡住什么挡不住什么这里必须说清楚一个常见误区HTML 实体转义不是 XSS 的万能解药。它能挡住的是“通过尖括号注入标签”这一类攻击但 XSS 的注入点远不止这一种。我整理了一张对照表把转义能覆盖和覆盖不了的场景分开注入场景转义是否有效说明标签体内注入script有效尖括号被转义后无法形成标签属性值内注入 onmouseover...需要转义引号只转尖括号不够引号也必须处理hrefjavascript:...无效这是协议层面的问题转义管不了style内的 CSS 注入部分有效需要额外的 CSS 过滤DOM 型 XSSinnerHTML赋值取决于处理时机转义必须在赋值前完成富文本编辑器保留的合法标签不能全转需要白名单机制而非全量转义所以正确的认知是转义是防御体系里最基础的一层但不是唯一一层。它解决的是“字符被误解析”的问题解决不了“合法语法被滥用”的问题。3.3 一个真实的反射型 XSS 排查过程我之前在一个内部工具上遇到过反射型 XSS。现象是搜索框输入特定内容后页面会执行脚本。排查链路是这样的第一步确认注入点。在搜索框输入testtest查看返回的 HTML 源码发现test原样出现在页面里没有被转义。说明后端没有做输出编码。第二步确认上下文。注入点位于div classresult内部属于标签体上下文。这种上下文下只要转义、、三个字符就能阻断标签注入。第三步验证攻击载荷。输入img srcx onerroralert(document.domain)页面确实弹出了域名。确认是反射型 XSS。第四步修复。在后端模板渲染层统一加上 HTML 转义同时在前端用textContent替代innerHTML做动态内容填充。第五步回归验证。重新输入各种载荷包括大小写混写、实体编码绕过、svg标签等确认全部被转义为文本。这个链路里最关键的一步是第二步确认上下文。因为不同上下文需要的转义字符集不一样标签体内转义尖括号和就够了但属性值内还必须转义引号。如果不区分上下文一刀切要么防不住要么把正常内容转坏。4. 不同技术栈下的转义实操4.1 原生 JavaScripttextContent 与 innerHTML 的分水岭前端最常踩的坑就是innerHTML。看这段代码const userInput img srcx onerroralert(1); document.getElementById(output).innerHTML userInput;这段代码会直接执行脚本。因为innerHTML的语义就是“把字符串当 HTML 解析”。正确的做法是用textContentconst userInput img srcx onerroralert(1); document.getElementById(output).textContent userInput;textContent会把内容当纯文本处理尖括号自动变成文本显示不会触发解析。这是前端最省事也最可靠的转义方式——让浏览器自己处理而不是手动拼字符串。如果确实需要动态生成 HTML 结构那就用createElementtextContent组合const span document.createElement(span); span.textContent userInput; document.getElementById(output).appendChild(span);这样既保证了结构可控又保证了内容安全。4.2 后端模板引擎自动转义是默认行为主流模板引擎基本都默认开启 HTML 转义。比如 Thymeleaf 里用th:text会自动转义用th:utext才不转义。Jinja2 里{{ }}自动转义{% autoescape false %}才关闭。Rails 的 ERB 默认转义raw或html_safe才不转。这里有个经验除非你百分之百确定内容是可信的否则永远不要用“不转义”的那个 API。我见过太多项目为了图方便用th:utext渲染富文本结果富文本里混进了用户可控的内容直接变成存储型 XSS。如果确实需要渲染富文本正确做法是引入 HTML 净化库比如 DOMPurify、jsoup先过滤掉危险标签和属性再输出。净化库的工作方式是白名单机制——只允许特定标签和属性通过其余全部剔除或转义。4.3 富文本与 Markdown 转换中的转义陷阱富文本和 Markdown 是转义问题的高发区。原因很简单这两个场景都需要保留一部分 HTML 语法不能全量转义。以 Markdown 转 HTML 为例。Markdown 语法本身允许内嵌 HTML所以转换器需要判断哪些 HTML 是用户想表达的、哪些是危险注入。常见做法是先对 Markdown 源码做 HTML 转义把变成lt;再解析 Markdown 语法生成对应的 HTML 标签最后对生成的 HTML 做白名单过滤这个顺序不能反。如果先解析再转义会把 Markdown 生成的合法标签也转掉如果只转义不过滤用户可以通过 Markdown 的 HTML 内嵌功能注入脚本。富文本编辑器同理。编辑器内部维护的是一棵 DOM 树用户操作产生的是结构化数据。但数据存到后端再取出来渲染时如果直接innerHTML赋值就等于把存储的内容当 HTML 解析。正确做法是渲染前过一遍净化库或者用编辑器的只读模式渲染。4.4 邮件模板与 PDF 导出容易被遗忘的角落邮件模板和 PDF 导出是两个特别容易出转义问题的地方因为它们往往用的是独立的渲染引擎。邮件客户端对 HTML 的支持参差不齐。有些客户端对实体名称支持不好只认实体编号。所以邮件模板里我一般统一用#60;这种编号形式。另外邮件里如果用户可控的内容没转义虽然不会直接执行脚本大部分邮件客户端禁 JS但会破坏邮件布局甚至被判定为垃圾邮件。PDF 导出更麻烦。很多 PDF 生成库比如 iText、wkhtmltopdf内部也是 HTML 渲染引擎如果传入的 HTML 没转义轻则排版错乱重则渲染进程崩溃。我遇到过一次导出 PDF 时用户输入了table标签结果整个 PDF 的表格结构被撑爆生成了几百页空白。后来在数据进入模板前统一做了一次转义才解决。5. 转义实现的细节与常见错误5.1 手写转义函数的三个坑很多人喜欢自己写转义函数觉得就几个字符替换很简单。但手写有几个坑第一个坑是顺序错误。前面提过必须最先处理。如果先转再转lt;会变成amp;lt;。第二个坑是遗漏字符。只转和是不够的、、都要考虑。具体转哪些取决于输出上下文。第三个坑是重复转义。如果数据在链路中被转了两次会变成amp;lt;显示出来就是lt;而不是。这个问题在多层架构里特别常见——前端转一次、后端转一次、模板引擎又转一次。一个相对稳妥的手写实现是这样的function escapeHtml(str) { const map { : amp;, : lt;, : gt;, : quot;, : #39; }; return String(str).replace(/[]/g, ch map[ch]); }注意正则里放在最前面replace是单次遍历每个字符只处理一次不会出现重复转义的问题。5.2 转义时机的选择入库前还是出库前这是个经典争论。我的观点很明确转义应该在输出时做而不是入库时做。原因是数据在存储层应该是“原始形态”。同一份数据可能被输出到 HTML 页面、JSON 接口、CSV 文件、PDF 文档每种输出格式需要的转义规则完全不同。如果入库时就转成 HTML 实体那输出到 JSON 时就会带着一堆lt;前端拿到还得再处理一遍。正确的分层是存储层存原始数据不做任何转义业务层做数据校验和净化比如过滤危险标签输出层根据目标格式做对应的编码HTML 转义、JSON 序列化、CSV 引号包裹等这样每一层职责清晰也不会出现重复转义。5.3 属性值内的转义引号才是重点标签体内的转义重点是尖括号但属性值内的转义重点是引号。看这个例子a href用户输入链接/a如果用户输入是 onclickalert(1)拼进去变成a href onclickalert(1)链接/a引号闭合了href属性后面的内容变成了新的属性脚本就注入了。所以属性值内必须转义引号。如果属性值用单引号包裹就要转义单引号用双引号包裹就转义双引号。最稳的做法是两个引号都转。另外属性值最好始终用引号包裹。不包裹引号的属性值比如a href用户输入转义规则更复杂容易出漏洞。5.4 一个容易忽略的场景HTML 注释HTML 注释!-- --内部也是可以注入的。如果用户输入被拼进注释里输入--就能提前闭合注释后面的内容变成正常 HTML。虽然这个场景比较少见但在一些模板拼接的逻辑里确实存在。处理方式很简单不要把用户输入放进 HTML 注释。如果非要放至少把--转义掉。6. 从转义延伸到内容安全治理6.1 输入校验和输出编码是两件事很多团队把“输入校验”和“输出编码”混为一谈觉得在入口处过滤了危险字符就万事大吉。这是错的。输入校验解决的是“数据是否符合业务规则”比如用户名不能包含特殊字符、邮箱格式要正确。它的目的是保证数据质量不是防 XSS。输出编码解决的是“数据在特定上下文中如何安全呈现”。它的目的是防止数据被误解析为代码。两者是互补关系不能互相替代。一个合法的用户名比如张三admin可能通过输入校验但输出到 HTML 时必须转义。反过来一个包含script的输入即使被输入校验拦住了也不能保证所有输出场景都安全——因为可能还有其他入口。6.2 内容安全策略CSP作为兜底转义是“不让危险内容进来”CSP 是“即使危险内容进来了也不让它执行”。两者配合使用效果最好。CSP 通过 HTTP 响应头告诉浏览器只允许加载哪些来源的脚本、样式、图片。比如设置script-src self浏览器就只执行同源脚本内联脚本和外部恶意脚本都会被拦截。CSP 不能替代转义因为它是最后一道防线而且配置复杂、容易误伤。但作为兜底机制它能在转义失效时提供额外保护。6.3 一个完整的防御层次把前面聊的串起来一个相对完整的内容安全防御层次是这样的层次手段作用第一层输入校验过滤明显不合规的数据第二层输出编码根据上下文转义特殊字符第三层内容净化富文本场景下白名单过滤第四层CSP浏览器层面限制资源加载第五层HttpOnly Cookie防止脚本读取敏感 Cookie每一层都有它的边界没有哪一层是万能的。转义处在第二层是最基础也最必须做的一层。跳过它去谈其他防御就像盖楼不打地基。7. 我在实际项目里踩过的几个坑第一个坑是前后端重复转义。有一次接口返回的数据里带amp;lt;前端显示出来是lt;用户看到的就是一堆实体字符。排查发现后端在序列化时转了一次前端模板引擎又转了一次。后来约定后端只负责数据净化不做 HTML 转义前端根据渲染方式决定是否转义。第二个坑是富文本编辑器保存的内容被二次转义。编辑器保存的是 HTML 结构后端存储时如果做了转义取出来渲染时就会显示成源码。解决办法是富文本内容单独走一条链路存储时存原始 HTML渲染时过净化库而不是转义。第三个坑是邮件模板里的实体名称兼容性。用lt;在某些邮件客户端里显示正常在另一些里显示成字面量。后来统一改成#60;才解决。第四个坑是PDF 导出时的标签注入。用户输入了style标签导致 PDF 渲染引擎把后面的内容全当 CSS 解析整个文档样式崩掉。后来在数据进入 PDF 模板前加了一层标签剥离。这些坑的共同点是问题都不在转义本身而在转义的时机、次数和上下文判断上。转义规则很简单难的是在复杂的系统链路里保证它被正确地执行一次、且只执行一次。8. 给不同角色的实操建议如果你是前端开发记住一条动态内容优先用textContent必须用innerHTML时先过净化库。不要手动拼 HTML 字符串用 DOM API 构建结构。如果你是后端开发记住一条模板引擎的自动转义不要关除非你明确知道自己在做什么。接口返回的数据保持原始形态转义交给消费方按需处理。如果你是安全测试测 XSS 时不要只测scriptalert(1)/script要覆盖标签体、属性值、URL、CSS、注释等不同上下文还要测试大小写混写、实体编码、双写绕过等变形载荷。如果你是项目负责人把输出编码写进代码规范在代码审查时重点看有没有绕过模板引擎直接拼接 HTML 的地方。这类问题一旦上线修复成本远高于预防成本。和这两个符号看起来简单但它们背后牵扯的是整个 Web 内容安全的基石逻辑。把转义这件事做对、做稳很多安全问题在源头就被掐掉了。
返回列表