前端安全系列(一):如何防止 XSS 攻击?
Category(分类): Other Status: 持续更新
原文参考:美团技术团队:前端安全系列(一)——如何防止 XSS 攻击?
XSS 是前端最常见、也最容易被“一个全局过滤函数”误导的问题之一。本文保留原文中“根据上下文处理输出”的主线,补充现代框架、CSP、Trusted Types 和安全测试的实践。
示例只用于本地或授权测试环境。不要把会执行脚本的 Payload 投放到生产站点,也不要测试不属于自己的系统。
一、先纠正两个常见误区
误区 1:XSS 只是后端的责任
不正确。存储型和反射型 XSS 经常由服务端模板输出不安全数据造成,DOM 型 XSS 则可能完全发生在浏览器端。输入校验、服务端输出、前端 DOM 操作、第三方组件和安全响应头都需要共同负责。
误区 2:所有数据统一调用一个过滤函数就安全
不正确。数据进入 HTML 文本、HTML 属性、URL、JavaScript、CSS 和富文本时,浏览器解析规则不同,编码策略也不同。HTML 文本编码不能直接替代 JavaScript 编码,也不能把一个危险 URL 变成安全 URL。
更可靠的原则是:
默认把外部数据当作不可信;优先使用框架自动转义和安全 DOM API;必须进入特殊上下文时,使用与该上下文匹配的编码、校验或清洗。
二、XSS 是什么
XSS(Cross-Site Scripting,跨站脚本攻击)是指攻击者让不可信数据在受害者浏览器中作为 HTML、JavaScript、URL 或其他可执行内容处理。
XSS 成功后可能导致:
- 读取页面中的个人信息、表单和非
HttpOnlyCookie; - 以当前用户身份调用同源接口;
- 修改页面、插入钓鱼表单或跳转到恶意站点;
- 读取
localStorage、sessionStorage和运行时令牌; - 在评论、私信等功能中传播,形成 XSS 蠕虫。
HttpOnly Cookie 可以阻止脚本通过 document.cookie 读取某个 Cookie,但不能阻止 XSS 代表用户发起请求,因此它只能降低部分影响,不能代替修复漏洞。
三、一个反射型 XSS 例子
假设服务端把 URL 中的 keyword 直接拼进模板:
<!-- 危险伪代码:raw 表示未经转义的插值 -->
<input type="text" value="{{ raw(keyword) }}">
<div>您搜索的关键词是:{{ raw(keyword) }}</div>
如果模板引擎没有自动转义,攻击者可以构造包含引号、标签或事件属性的输入,改变原本的 HTML 结构。浏览器不会知道这段内容是“恶意的”,它只会按 HTML 语法解析。
安全输出应使用模板引擎的默认转义:
<!-- 伪代码:keyword 必须经过 HTML 转义,不能使用 raw 插值 -->
<input type="text" value="{{ keyword }}">
<div>您搜索的关键词是:{{ keyword }}</div>
对于不需要 HTML 的内容,转义后应表现为普通文本。常见的 HTML 实体包括:
| 字符 | HTML 表示 |
|---|---|
& | & |
< | < |
> | > |
" | " |
' | ' |
这些规则适合 HTML 文本和普通属性值,但不适合直接解决所有输出上下文。
Vue、React 等框架
现代框架的普通插值通常会把值当作文本处理,例如 Vue 模板中的:
<template>
<p>{{ keyword }}</p>
</template>
但以下 API 会绕过默认保护,必须单独审查:
- Vue 的
v-html; - React 的
dangerouslySetInnerHTML; - Angular 的
bypassSecurityTrust...; - Lit 的
unsafeHTML; - 直接使用
innerHTML、outerHTML或insertAdjacentHTML。
框架减少了漏洞数量,但不会自动修复错误的 URL、第三方 HTML、服务端模板或不安全的逃生口。
四、HTML 转义不能替代 URL 校验
下面的跳转地址即使进行了 HTML 属性编码,也可能仍然是危险协议:
<a href="{{ redirectTo }}">跳转</a>
如果 redirectTo 的值是 javascript:...,浏览器可能在用户点击时把它当成脚本协议。不能只检查一个大小写敏感的字符串前缀,因为大小写、空白、URL 解析和编码都可能影响结果。
如果业务只允许站内跳转,可以限制到当前源:
function safeInternalUrl(input) {
try {
const url = new URL(input, window.location.origin)
if (url.origin !== window.location.origin) {
return '/404'
}
return url.href
} catch {
return '/404'
}
}
link.href = safeInternalUrl(userInput)
如果业务必须允许外部链接,应使用精确的协议和主机白名单,并考虑开放重定向、钓鱼和 data: 等协议。encodeURIComponent() 只负责编码 URL 的组成部分,不负责判断 URL 是否可信。
五、根据输出上下文选择防御
| 上下文 | 推荐做法 | 不要做什么 |
|---|---|---|
| HTML 文本 | 框架自动转义或 HTML 实体编码 | 直接拼接 innerHTML |
| 普通 HTML 属性 | 固定属性名、引号包裹、属性编码 | 让用户控制属性名或 on* 属性 |
| URL 参数 | 对参数值做 URL 编码 | 把整个 URL 盲目编码或只替换 javascript: |
href / src | 先解析 URL 并限制协议/来源,再赋值 | 直接使用不可信完整 URL |
| JavaScript | 尽量不把数据拼进脚本,改用 JSON 数据节点 | 把字符串直接拼进 <script> |
| CSS | 使用固定属性和结构化值 | 拼接选择器、样式代码或不可信 CSS URL |
| 富文本 | 使用维护中的 Sanitizer 和白名单 | 只删除 <script> 标签 |
危险上下文包括内联事件、脚本块、HTML 注释、动态标签名、CSS 选择器和任意 JavaScript 表达式。很多场景下,最安全的做法不是“寻找更复杂的转义”,而是改变数据流,避免把变量放入危险上下文。
六、内联 JSON 也需要安全处理
有些服务端页面会把初始化数据放进 HTML:
<script>
window.initialState = {{ data.toJSON() }}
</script>
如果 JSON 字符串包含 </script>,HTML 解析器可能提前结束当前脚本;U+2028、U+2029 等字符在旧 JavaScript 语境中也可能造成语法问题。不能把普通 HTML 转义和 JavaScript/JSON 编码混为一谈。
更容易控制的方式是使用 JSON 数据节点,并由服务端序列化后对 < 等字符做安全处理:
<script id="initial-state" type="application/json">
{"title":"示例"}
</script>
<script>
const raw = document.querySelector('#initial-state').textContent
const initialState = JSON.parse(raw)
</script>
服务端应使用成熟序列化库,并确保输出到 HTML 的 JSON 不会产生 </script>。示意性的字符处理通常包括将 <、>、&、U+2028、U+2029 表示成 Unicode 转义;不要在业务代码里手写一套不完整的转义规则。
七、XSS 的三种类型
1. 存储型 XSS
恶意内容被保存到数据库、缓存、评论、昵称、帖子、私信或工单中,其他用户访问页面时触发。
防护重点:
- 对长度、类型、格式和业务范围做服务端输入校验;
- 普通文本在输出时按上下文编码;
- 业务确实需要富文本时,使用成熟 HTML Sanitizer;
- 清洗后不要再次拼接不可信 HTML;
- 前端校验只能改善体验,不能代替服务端校验。
输入过滤不能简单地替代输出编码。数据可能会在多个页面、接口、日志和管理后台中以不同上下文输出,提前把所有 < 改成 < 可能造成乱码或双重编码。
2. 反射型 XSS
恶意数据通常来自查询参数、表单或请求头,服务端把它直接反射到当前响应。搜索、错误提示、跳转和预填表单是常见位置。
修复时应在最终输出点使用上下文匹配的编码,并检查所有响应模板,而不是只在某一个接口入口加过滤器。
3. DOM 型 XSS
恶意数据可能来自:
location.href、location.search、location.hash;document.referrer、window.name、postMessage;localStorage、sessionStorage和接口响应。
常见危险接收器包括:
// 危险:把不可信字符串当作 HTML 解析
container.innerHTML = value
container.insertAdjacentHTML('beforeend', value)
document.write(value)
// 危险:把字符串当作代码执行
eval(value)
new Function(value)
setTimeout(value, 0)
文本场景应使用:
container.textContent = value
input.value = value
如果必须展示 HTML,先用 DOMPurify 等维护中的 Sanitizer 清洗,并控制允许的标签、属性和 URL 协议:
const clean = DOMPurify.sanitize(untrustedHtml)
container.innerHTML = clean
清洗库需要持续升级,且清洗后的内容不能再交给会修改或重新解释它的库。
八、不要把输入校验误当作 XSS 的主防线
原文讨论了“前端过滤”和“入库前过滤”。正确的分工是:
- 输入校验:限制数据类型、长度、枚举、格式和业务范围;
- 输出编码:在确定输出上下文后,把数据当作数据展示;
- HTML 清洗:业务明确允许富文本时,删除危险节点、属性和 URL;
- CSP/Trusted Types:作为浏览器侧的额外限制层;
- 服务端授权:防止用户绕过前端直接调用接口。
不能指望一个请求拦截器知道某个参数最终会进入 HTML、CSS 还是 JavaScript。全局过滤器容易漏掉响应中的数据库数据、请求头和 DOM 型来源,也可能导致双重编码。
九、纵深防御
1. CSP
CSP 可以限制脚本来源、内联脚本和外部提交,降低 XSS 成功后的影响。示意:
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-<BASE64_NONCE>'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'
<BASE64_NONCE> 只是占位符,服务端必须为每次响应生成不可预测的 nonce,并把同一个 nonce 放到允许执行的脚本标签上;不能原样复制。建议先使用 Content-Security-Policy-Report-Only 观察兼容性,再逐步收紧策略。
CSP 是纵深防御,不能替代输出编码。不要为了让旧代码“先跑起来”长期依赖 'unsafe-inline' 和 'unsafe-eval'。
2. Trusted Types
在支持的 Chromium 环境中,可以评估:
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default
它可以让 innerHTML 等危险接收器拒绝普通字符串,迫使代码通过经过审查的 Trusted Types 策略。它不是所有浏览器都支持,也不是清洗和编码的替代方案。
3. Cookie 和存储
敏感会话 Cookie 可以设置:
Set-Cookie: __Host-session=随机值; Path=/; Secure; HttpOnly; SameSite=Lax
不要把长期会话令牌和敏感数据放在 localStorage。Cookie 属性只能降低部分影响,发生 XSS 后,恶意代码仍可能在当前会话中调用接口。
4. 第三方依赖和上传内容
第三方脚本会在当前页面权限下执行,应审核来源、版本、变更和权限。静态内容在适用时使用 SRI,并通过 CSP 限制脚本来源。用户上传的 HTML、SVG、脚本和可执行文件应隔离、下载或清洗,不能仅通过文件扩展名判断安全。
十、如何检测 XSS
1. 代码审查
从输入源追踪到 DOM 或模板接收器:
- 搜索
innerHTML、outerHTML、insertAdjacentHTML、document.write; - 搜索
eval、new Function、字符串定时器和动态脚本; - 检查
href、src、style和事件属性的来源; - 检查
v-html、dangerouslySetInnerHTML等框架逃生口; - 检查接口返回值、缓存和 Web Storage 是否被当成可信 HTML。
2. 授权环境测试
在测试环境中使用无害的唯一标记,确认它被当作文本还是 HTML;对需要执行验证的场景使用短、可控、可回收的测试标记,并确保不会把数据发送给外部地址。不要在生产环境直接提交网上流传的 XSS Polyglot。
自动化工具只能辅助发现问题,DOM 型 XSS 和业务上下文仍需要人工复核。可以参考:
- OWASP Web Security Testing Guide;
- OWASP ZAP;
- 浏览器 DevTools、CSP 报告和错误监控。
Arachni、w3af 和旧版 Mozilla Observatory 可以作为历史资料或特定环境工具,但维护状态和能力需要单独确认;HTTP Observatory 主要检查响应头,不能替代应用漏洞扫描。
十一、历史案例应该如何阅读
原文列举过 QQ 邮箱、新浪微博等历史反射型 XSS 案例,它们说明了:
- 公开网站也可能出现 XSS;
- 一个 URL 参数就可能影响多个输出位置;
- XSS 不只会弹窗,还可能修改页面、调用接口和传播;
- 不能因为案例已经修复,就把其中的域名和 Payload 复制到现实系统测试。
这些案例只适合帮助理解漏洞成因。今天的验证应使用授权测试环境、无害标记和现代工具。
十二、总结
防止 XSS 的关键不是寻找一个“万能过滤函数”,而是保持数据和代码的边界:
- 默认不信任用户、接口、第三方和浏览器存储中的数据;
- 优先使用框架自动转义和安全 DOM API;
- 根据 HTML、属性、URL、JavaScript、CSS 等上下文选择处理方式;
- 富文本使用成熟 Sanitizer,不能只删除
<script>; - 避免
innerHTML、事件属性、动态脚本和字符串求值; - 用 CSP、Trusted Types、Cookie 属性和依赖治理降低影响;
- 通过代码审查、测试、扫描和监控持续发现问题。