ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

HTML字符转义与XSS攻击防御:规则、场景与最佳实践

HTML字符转义与XSS攻击防御:规则、场景与最佳实践

1. 先说清楚:字符转义到底在防什么

先在开篇把话说透:HTML字符转义的核心使命,就是防止浏览器把用户输入的内容当成可执行的代码。如果你在某个文本框里输入了<script>alert(1)</script>,服务端原样输出到页面上,浏览器就会把它解析成一个标签,然后执行里面的脚本。这就是XSS攻击(跨站脚本攻击)的第一性原理——你以为用户在填数据,实际上他在往你的页面里塞代码。

从事Web开发这么多年,我见过太多被XSS打得措手不及的项目。有的团队觉得“反正我们就是个小后台,没人会来打”,结果某天用户反馈“登录后弹出的广告怎么回事”,一查才知道是评论区被植入了恶意脚本。还有的团队以为“用了框架就万事大吉”,结果在动态拼接HTML的地方栽了跟头。说到底,XSS大概率不是被“攻破”的,而是开发者在输出用户数据时,脑子里少了一根“必须先转义”的弦。

这篇内容解决什么问题?一句话:让你彻底搞清楚HTML转义的规则、场景、工具和踩坑点,在每一个可能出问题的输出位置,都能给出正确的处理方式。适合谁看?前端工程师、后端工程师、全栈开发者,以及所有需要跟用户输入打交道、把数据渲染到页面上的从业者。这篇内容我会从攻击原理讲到实际编码,再讲完整防御链路,最后是我的排查经验,你可以直接照着落地。

请注意一个事实:转义不是“可选项”,而是所有动态输出用户数据的页面必须执行的安全底线。你输出的每一段用户内容,都可能是攻击者递进来的刀。

2. 原理拆解:XSS是怎么打进来的,转义为什么能挡住

2.1 浏览器解析HTML的“观察者效应”

理解转义,先理解浏览器。浏览器解析HTML不是一次性完成的,它分阶段工作:先做词法分析,识别出标签、属性、文本节点;碰到<script>或者onclick这种可执行代码的载体,再进入脚本执行阶段。

这里有个关键点:浏览器在解析HTML时,对“文本内容”和“代码结构”是严格区分处理的。当它读到<时,会判断后面跟的是什么。如果后面是字母,它认为这是一个新标签的开始;如果是/,它认为这是闭合标签;如果是空格、数字或者其他字符,它才觉得“这是普通文本”。攻击者利用的就是这个判断逻辑——他输入的内容里带了<,浏览器就误以为你在闭合一个标签,或者开启一个新标签。

转义做的事情,就是把<变成&lt;。浏览器在文本节点里看到&lt;,会先把它解码成<显示出来,但它不会再触发“新标签开始”的判断。这就是字符转义能防XSS的根本原因:它改变了字符在HTML解析器眼中的身份,让“代码性格”变成“文本性格”。

2.2 三类XSS攻击场景,来源各不相同

存储型XSS(也叫持久型):恶意脚本被存到数据库里,每个访问页面的用户都会中招。经典的注入点就是评论、昵称、站内信、个人简介。攻击者提交的 payload 被永久保存,之后不管谁打开页面,脚本都会加载执行。这类攻击危害最大,因为受害者完全不需要做任何额外操作。

反射型XSS(非持久型):恶意脚本通过URL参数传到服务端,服务端没做过滤就直接拼进了响应页面。比如某搜索功能的URL长这样:/search?keyword=<script>alert(1)</script>,服务端把这个 keyword 原样渲染到结果页,用户就得手动点开这个链接才会触发。攻击者通常会把链接包装成短信、邮件里的短链,诱导点击。

DOM型XSS:这里跟服务端没关系,纯粹是前端代码的问题。浏览器端的JavaScript读取location.hash、document.referrer、window.name等数据,然后直接用innerHTML或document.write拼到页面里。数据根本没出过浏览器,服务端自然无从过滤,只能在前端做转义或过滤。

三种类型的执行效果一样,但修复位置完全不同。存储型和反射型可以在服务端出口统一编码,DOM型必须在前端处理。我见过太多团队只修了服务端,结果拿爬虫测试DOM型漏洞时,页面照样弹窗。

2.3 哪些字符是“高危分子”

HTML上下文里,真正需要重点关注的字符并不多,但每一个都不能放过:

字符转义实体风险说明
<&lt;开启新标签,最高危字符
>&gt;闭合标签,配合<使用
&&amp;实体引用起始符,会被解析成新实体
"&quot;双引号,可逃逸属性边界
'&#x27;单引号,HTML5中属性也能用单引号包裹
/&#x2F;闭合标签的斜杠,在某些场景需要转义

还有一个容易被忽视的细节:空格本身不需要转义,但如果你在用onerror这一类事件属性,空格可以变成换行符、Tab符来绕过基于正则的简单过滤。所以凡是进入HTML属性值的用户数据,转义规则比纯文本要严苛得多。

3. 核心细节:一套可靠的转义方案是怎么落地的

3.1 转义库选型:能不用手写就别手写

手写转义函数看起来简单,比如str.replace(/</g, "&lt;"),但真实项目的坑在于你会遗漏场景。不同场景需要转义的字符集不一样,手写的函数拿到JavaScript上下文里用,会把引号漏掉;拿到URL参数里用,不编码空格又会出乱码。成熟的项目不要重复造轮子,直接用经过安全审计的库。

  • 前端/Node环境:lodash的_.escape能覆盖基本的HTML实体转义,处理& < > " '几个字符;he库支持完整的HTML实体编解码,适合处理复杂文本。
  • 服务端Java:commons-lang3的StringEscapeUtils.escapeHtml4(),老牌可靠,满足绝大多数场景。
  • 服务端PHP:htmlspecialchars()配合ENT_QUOTES参数,这是PHP生态里最正统的做法。
  • Python:html.escape()是标准库自带函数,默认转义引号,开箱即用。
  • 通用领域:OWASP Java Encoder(针对Java生态)、Microsoft AntiXSS(针对.NET),它们提供的都是上下文感知编码方案。

这里我强烈建议:你跟团队约定一个“统一出口函数”。例如在Java项目里封装一个SafeHtmlUtil.escape(String input),内部调StringEscapeUtils,然后全项目只允许用这个工具类输出用户数据。这个做法比每个开发各写各的强得多——安全基线统一,review成本大幅降低。

3.2 解码优先级:为什么顺序错了会翻车

浏览器解析HTML实体有个重要机制:字符引用会在词法分析阶段就被解码。也就是说,&lt;script&gt;在浏览器眼里先变成<script>,但它已经处于文本节点内,所以不会真正执行。

而如果你做了“二次编码”,比如把用户输入的<存成%26lt%3B(先URL编码,又HTML编码),输出的结果可能变成&amp;lt;。浏览器解码一次变成&lt;,显示在页面上是&lt;而不是<。这是双重编码问题,虽然不产生XSS漏洞,但污染了展示效果。

我踩过的坑是这样的:有一次在搜索页输出高亮关键字,我先用URLEncoder.encode编码了用户输入,再拿去做HTML拼接,结果搜索词带个&就直接变成乱码。排查了半天才发现是编码顺序错了。正确做法是:用户原始数据先做HTML转义,不要做多余的URL编码。编码顺序虽然不影响安全,但直接影响用户看到的内容是否正常。

3.3 属性、脚本、URL:三个上下文,三套规则

这是最容易出事的环节。很多人知道“要转义”,但拿着同一套转义规则到处用,结果要么漏防,要么误伤。

HTML元素内容(HTML context):只需转义& < >就够了。引号在这里是普通文本字符,转义了也不会有坏影响,但不转义也不存在漏洞。我习惯在文本节点里一把梭,把引号也转了,省得后面代码挪了位置出问题。

HTML属性值(Attribute context):必须转义& < > " '。典型漏洞:

<input value="<%= userInput %>">

用户输入" onfocus="alert(1)后,页面变成:

<input value="" onfocus="alert(1)">

双引号闭合了value属性,onfocus事件成功注入。所以在属性上下文,双引号和单引号都必须转义成&quot;和&#x27;。

JavaScript字符串(Script context):HTML实体转义在这里不够用。比如:

var msg = "<%= userInput %>";

攻击者输入</script><script>alert(1)</script>,直接闭合了外层script标签。这种场景要先做JavaScript字符串转义(转义\ ' "换行等),再把<转义掉以防万一。OWASP建议的规则是:先转义为JSON字符串格式,再做HTML实体转义。

URL属性(href/src context):不仅要转义,还要做协议限制。攻击者可能传javascript:alert(1)。防御套路是两层:先校验协议只允许http/https/mailto等白名单,再做URL编码。只做实体转义是不够的,javascript:里没有HTML特殊字符,实体转义完全拦不住。

来看一个综合示例:

<a href="<%= safeUrl(userInput) %>">点击链接</a> <a title="<%= escapeHtmlAttr(userInput) %>">悬停信息</a> <script>var msg = "<%= escapeJsStr(userInput) %>";</script> <div><%= escapeHtml(userInput) %></div>

四个输出位置,四套不同的处理函数。记不住规则的时候,直接按OWASP的XSS Prevention Cheat Sheet来做,它把每个上下文的规则和示例代码都列清楚了。

4. 实操过程:从服务端到前端的完整演练

4.1 第一步:确认所有用户输入出口

做安全整改别一上来就写函数,先盘资产。把项目里所有“用户可控数据→页面输出”的路径列出来,通常集中在这几类:

  • 表单提交后回显的内容(搜索词、表单校验错误信息)
  • 数据库里存的用户生成内容(评论、文章、昵称)
  • URL参数的直接渲染(location.search拿来拼DOM)
  • 服务端模板里的变量输出(JSP的<%= %>、Thymeleaf的th:text、Freemarker的${})
  • 前端拼接HTML字符串的innerHTML赋值

我习惯用表格建一份“输出点清单”,每一行记录:路径URL、参数名、输出上下文(HTML/属性/JS/URL)、当前是否转义。这份清单就是后续代码审查的依据。

4.2 第二步:服务端模板的正确写法

以Java的JSP为例,很多人写<%= request.getParameter("keyword") %>直接输出,这是最低级的错误。正确做法:

<%@ page import="org.apache.commons.lang3.StringEscapeUtils" %> <div>搜索关键词:<%= StringEscapeUtils.escapeHtml4(request.getParameter("keyword")) %></div>

Thymeleaf 里注意区分th:text和th:utext——前者默认转义,后者不转义。我看到过有人图省事用了th:utext,还把用户评论直接塞进去,这等于把转义这道门拆了。th:utext只在渲染富文本且经过白名单过滤的情况下使用。

Freemarker 也一样,${userContent}默认不转义 HTML,需要加<#escape x as x?html>开启全局转义,或者每个变量手动?html。

后端转义的黄金法则:在到达页面之前,数据必须是编码状态。不要在Controller里转义后存数据库,那样搜索、统计、导出都会受影响。在视图层做转义,数据层保持原始数据。

4.3 第三步:前端框架的自动转义与例外

React 和 Vue 默认帮我们处理了大部分转义。JSX 里{userContent}渲染的是文本节点,框架内部会做转义;Vue 的插值表达式{{ userContent }}同样安全。危险的是这些“逃生舱”:

  • React 的dangerouslySetInnerHTML
  • Vue 的v-html
  • 原生 JavaScript 的innerHTML、outerHTML、document.write

一个常见的场景是渲染富文本编辑器的内容。这个场景不能做全面转义——你得允许<strong>、<a>、<img>这些标签存在。正确思路是白名单过滤:只允许特定标签和特定属性,其他的全部剔除。

推荐用 DOMPurify,这个库在开发者社区的口碑很好,功能就是清洗HTML字符串。基本用法:

import DOMPurify from "dompurify"; const clean = DOMPurify.sanitize(userRichText, { ALLOWED_TAGS: ["b", "strong", "i", "em", "a", "img", "ul", "ol", "li", "p", "br"], ALLOWED_ATTR: ["href", "title", "src", "alt", "target", "rel"], ALLOW_DATA_ATTR: false }); element.innerHTML = clean;

注意几个细节:href属性要加协议校验,DOMPurify 默认会拦截javascript:协议,但最好再写一个白名单函数兜底;target属性建议强制加_blank时同时加rel="noopener noreferrer",防止新窗口页面通过window.opener反向操作原页面。

4.4 第四步:CSP 作为纵深防御

转义是有可能被绕过或遗漏的,所以要用不依赖于编码正确性的兜底方案。**CSP(内容安全策略)**就是干这个的:它告诉浏览器“只允许从哪些来源加载什么类型的资源”。

一个基础的CSP响应头配置:

Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'

这条策略的效果:脚本只能加载同源资源,禁止插件、禁止修改<base>标签。万一某个转义点漏了,攻击者的内联脚本也执行不了。

需要内联脚本怎么办?用 nonce 或 hash:

Content-Security-Policy: script-src 'self' 'nonce-随机值'

页面里的<script nonce="随机值">才会被执行,攻击者不知道 nonce 的值,注入的脚本就成了摆设。CSP + 转义的关系是“default 高墙”和“第一道门”的关系,两道都做,安全感才足。

4.5 第五步:用编码工具函数统一拦截

如果项目历史包袱重,改模板工程量太大,我建议封装一个前端工具函数,在截获用户输入的第一时间做处理:

function encodeHTML(str) { return String(str) .replace(/&/g, "&amp;") .replace(/</g, "&lt;") .replace(/>/g, "&gt;") .replace(/"/g, "&quot;") .replace(/'/g, "&#x27;"); }

这个函数处理五类字符,覆盖HTML文本和属性上下文的基本需求。不过再次强调,JavaScript字符串上下文、URL上下文,它是不覆盖的。如果你用它去拼JavaScript代码:

let username = encodeHTML(userInput); eval("greet('" + username + "')");

攻击者传'); alert(1); ('依然能闭合引号并注入代码。工具函数不能解决所有问题,关键在于选对上下文规则。

5. 常见的翻车现场与排查方法

5.1 双重编码导致页面显示乱码

症状:用户输入AT&T,页面显示AT&amp;T。原因:数据进库前转了一次义,输出模板又转了一次义。

排查步骤:看数据库存储的是原始值还是实体值。如果是实体值,说明转义发生在Dao层或Service层,需要把转义逻辑挪到View层。做一个简单的取值测试就能确认:

输入:1 < 2 && 3 > 2 期望显示:1 < 2 && 3 > 2 如果显示:1 &lt; 2 &amp;&amp; 3 &gt; 2 那么就是双重编码

5.2 属性上下文漏转义引号,被植入事件

我接手过一个内部系统,某列表页的筛选条件是这么写的:

<input type="text" value="<%= request.getParameter("q") %>">

当时服务端只做了escapeHtml,但它对双引号不管(用的老的escapeHtml3库)。攻击者构造链接:

/?q=" onmouseover="alert(document.cookie)

最终渲染:

<input type="text" value="" onmouseover="alert(document.cookie)">

修法很简单:升级到escapeHtml4或自定义转义函数把&quot;和&#x27;也处理掉。但教训是:旧库的转义范围可能不完整,选型时要确认它到底转哪些字符。

5.3javascript:伪协议绕过

有人觉得href属性只要做了HTML实体转义就安全,这是误区。javascript:alert(1)里没有< > "这些字符,实体转义完全无效。

正确做法:

public static String safeUrl(String url) { if (url == null) return ""; url = url.trim().toLowerCase(); if (url.startsWith("javascript:") || url.startsWith("data:") || url.startsWith("vbscript:")) { return ""; } return url; }

再加一层:服务端校验URL的开头必须匹配http://或https://,否则返回空。前端所有target="_blank"的链接统一加rel="noopener noreferrer"。

5.4 富文本过滤正则被绕过

有人为了方便,写了这样的过滤:

content.replace(/<script>/gi, "").replace(/on\w+=/gi, "")

这能拦一些简单的攻击,但绕过方式太多了:大小写混合、加注释符、用&#x31;实体编码、用Tab和换行拆分属性名、嵌套标签……很多年前的XSS速查表里就有几十种变体。正则过滤不可靠,富文本必须用 DOMPurify 或者服务端的白名单解析器(如Jsoup的clean方法)。

5.5 排查XSS问题时的“笨办法”

如果怀疑页面某个位置有XSS,我先在输入框填这串测试数据:

'"<svg/onload=alert(1)>'

然后看页面行为。如果弹窗了,说明某个上下文漏了转义;如果没有任何反应,继续检查渲染后的HTML源码(DevTools里查看元素),确认特殊字符的呈现状态。这一步能快速定位是文本节点、属性节点还是脚本节点出了问题。

另外可以打开浏览器的XSS Auditor(现在已经废弃了,新版Chrome用Trusted Types取代),调试时开启严格模式有助于暴露风险操作。

6. 我现在是怎么做“全面防御”的

经过多次踩坑之后,我把整套思路收敛成了一个固定配置,分享给你参考:

  1. 统一转义函数:所有HTML文本输出走同一个函数,属性上下文用加强版,JS上下文用专门的escapeJs,URL上下文有safeUrl校验。
  2. 模板层强制转义:Thymeleaf 一律用th:text,禁用th:utext除非过了白名单;JSP 一律用自定义标签或工具类,禁止裸<%= %>输出。
  3. 富文本只信白名单:DOMPurify 白名单配置严格精确,所有的href、src做协议校验。
  4. CSP兜底:新项目上线前必须配置好 CSP,老项目逐步收紧策略。
  5. 自动化扫描:在CI里接入基础扫描工具,自动检查页面有没有把用户输入直接插入到危险位置;配合人工代码审查双保险。

这套流程落地之后,我负责的项目再也没有出现过XSS漏洞。它不会花你太多时间,但对页面安全的作用是决定性的。

根据我个人的实操体会,转义这件事看起来技术含量不高,一旦理解透了,你会对“数据如何在HTML里流动”有更深的认识。最后再分享一个小技巧:在新功能开发时,不管输入来源是否可信,一律当作“不可信输入”处理——养成这个习惯,你就已经比大多数开发者安全了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进