只转义5个字符(&、、"、')就足够,因为html解析器仅凭它们识别标签、属性和脚本;其余unicode字符(如emoji、中文)不触发解析,无需转义;危险源于这些字符的原始形态被误解析为结构控制符。

为什么只转义这 5 个字符就足够
HTML 解析器只把 &、、<code>>、"、' 当作结构控制符。其余 Unicode 字符(包括 emoji、中文、数学符号)不会触发标签解析或脚本执行,无需转义。真正危险的是:<script></script> 被识别为标签,onerror="alert(1)" 被识别为事件属性,javascript: 被识别为协议——这些都依赖那 5 个字符的原始形态。
常见错误现象:innerHTML = userInput 直接插入 <img src="1" onerror="alert(1)">,浏览器还原后立即执行;服务端返回的 JSON 字段如 {"bio": "<b>hello"}</b>,前端用模板字符串拼成 <div>${bio}</div>,等同于未转义的 innerHTML。
- 转义不是“让内容看起来安全”,而是阻止解析器进入 markup 解析流程
-
>虽在多数上下文非必需,但保持与对称可避免意外闭合和解析错位 -
'在 IE 中支持不一致,统一用'更稳妥
服务端转义必须在模板渲染前完成
服务端输出 HTML 时,所有用户输入字段(表单提交、URL 参数、数据库读取)必须在插入模板前完成 HTML 实体编码。这不是可选项,是底线。
不同语言的标准做法:
- PHP:用
htmlspecialchars($input, ENT_QUOTES, 'UTF-8'),别漏掉第三个参数,否则非 UTF-8 编码下可能被绕过 - Python:用
html.escape(input, quote=True),quote=True确保双引号也被处理 - Java:用
StringEscapeUtils.escapeHtml4(input)(Apache Commons Text),注意不要混用旧版escapeHtml3 - Node.js:用
he.escape(input),避免用entities库——它默认不转义单引号
关键陷阱:模板引擎默认行为不统一。EJS 默认转义,但 会跳过;Vue 的 {{ data }} 转义,v-html 不转义;Twig 的 {{ data|raw }} 完全放行。上线前必须逐个确认变量插值是否走转义路径。
前端动态插入时,优先用 textContent,禁用 innerHTML 直接赋值
95% 的场景只需显示纯文本:用户名、错误提示、API 返回的消息体。这时 textContent 是零成本、零风险的选择——它完全绕过 HTML 解析器,浏览器根本不尝试解析其中任何字符。
只有三类情况才考虑 innerHTML:
- 富文本编辑器输出(如后台已净化的公告 HTML)
- Markdown 渲染结果(经
DOMPurify.sanitize()处理后) - 服务端明确标记为“已转义且可信”的 HTML 片段
若必须用 innerHTML,严禁手写正则替换。以下写法看似简单,实则漏洞百出:
userInput.replace(/<script>/gi, '').replace(/on\w+=/gi, '')</script>
它无法拦截 <scr>ipt></scr>、<img> 或注释内藏标签。正确做法是调用 DOMPurify.sanitize(dirtyHtml),并显式配置白名单:{ ALLOWED_TAGS: ['p', 'br', 'strong', 'a'], ALLOWED_ATTR: ['href'] }。
data-* 属性和 URL 参数是高危盲区
很多人以为 JSON 序列化进 data- 属性就安全了,其实不然。例如:
<div data-payload='{"name": "<b>test</b>"}'></div>
JSON.stringify() 只保证 JSON 合法,不处理 HTML 特殊字符。单引号包裹的属性值中, 仍会被解析为标签起始;而 <code>\' 这类 JSON 转义,HTML 解析器根本无视。
正确做法分两步:
- 先用语言内置函数对整个字符串做 HTML 转义(如 Python 的
html.escape(json_str)) - 再放入双引号包裹的属性:
<div data-payload="{html_escaped_json}"> <p>URL 参数更危险:<code>location.href = new URLSearchParams(location.search).get('redirect')若参数是javascript:alert(1),直接触发执行。必须检查.protocol,仅允许https:、http:、/开头的值。复杂点在于:转义必须严格匹配上下文。插在 HTML 文本流里,用 HTML 实体;插在 JS 字符串里,用 JS 转义;插在 URL 中,用
encodeURIComponent()。混用等于没防。











