前端 XSS 防护:从攻击链路分析到纵深防御体系的工程实践
一、XSS 攻击的真实成本与防护盲区
XSS(跨站脚本攻击)是前端安全领域最古老也最顽固的威胁。OWASP Top 10 常年上榜,但很多团队对它的理解还停留在"转义一下就好了"的层面。真实情况远比这复杂。
生产环境中的 XSS 事故成本极高:Cookie 劫持导致用户会话被盗、键盘记录器窃取密码、钓鱼注入骗取支付信息、蠕虫式传播污染整个平台。一次严重的 XSS 事故,直接经济损失可能达到六位数,间接损失(品牌信誉、用户流失)更难估量。
防护盲区通常出现在三个地方:富文本编辑器的 HTML 输出、URL 参数的动态渲染、第三方脚本注入。这三个场景的共同特征是:数据来源不可信,但业务要求必须渲染。简单的转义在这里行不通——转义会破坏 HTML 结构,导致富文本无法正常显示。
二、XSS 攻击链路与防御层次
先理清攻击链路,再逐层设防。
flowchart TD
A[攻击者注入恶意脚本] –> B{注入点类型}
B –>|存储型| C[恶意数据写入数据库]
C –> D[其他用户读取并渲染]
B –>|反射型| E[恶意参数在 URL 中]
E –> F[服务端反射到页面]
B –>|DOM 型| G[前端 JS 直接读取 URL/Hash]
G –> H[innerHTML 动态渲染]
D –> I[脚本在受害者浏览器执行]
F –> I
H –> I
I –> J[窃取 Cookie / Session]
I –> K[键盘记录 / 表单劫持]
I –> L[钓鱼 / 蠕虫传播]
style A fill:#ffebee
style I fill:#ffebee
style J fill:#ffebee
style K fill:#ffebee
style L fill:#ffebee
三种 XSS 类型的区别在于恶意脚本的注入路径,但防御的核心原则相同:不信任任何来自外部的数据,在输出时做安全编码。
防御层次一:输入验证——白名单优于黑名单
// 白名单验证:只允许已知安全的输入格式
interface InputValidator {
validate(value: string): { valid: boolean; sanitized: string }
}
// URL 参数验证
class URLValidator implements InputValidator {
private readonly ALLOWED_PROTOCOLS = ['https:', 'http:']
private readonly ALLOWED_HOSTS: string[]
constructor(allowedHosts: string[]) {
this.ALLOWED_HOSTS = allowedHosts
}
validate(value: string): { valid: boolean; sanitized: string } {
try {
const url = new URL(value)
// 协议白名单
if (!this.ALLOWED_PROTOCOLS.includes(url.protocol)) {
return { valid: false, sanitized: '' }
}
// 域名白名单
if (!this.ALLOWED_HOSTS.some(host => url.hostname.endsWith(host))) {
return { valid: false, sanitized: '' }
}
// 禁止 javascript: 和 data: 协议
if (url.protocol === 'javascript:' || url.protocol === 'data:') {
return { valid: false, sanitized: '' }
}
return { valid: true, sanitized: url.href }
} catch {
return { valid: false, sanitized: '' }
}
}
}
黑名单过滤(如过滤 <script> 标签)是无效的。攻击者可以用 <img onerror>、<svg onload>、<a href="javascript:"> 等无数变体绕过。白名单只允许已知安全的格式通过,从根本上杜绝绕过。
防御层次二:输出编码——上下文感知的转义
// 上下文感知的输出编码
class OutputEncoder {
// HTML 上下文:转义 < > & " '
htmlEncode(str: string): string {
const map: Record<string, string> = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
}
return str.replace(/[&<>"']/g, char => map[char])
}
// HTML 属性上下文:额外处理空格和等号
attrEncode(str: string): string {
return this.htmlEncode(str)
.replace(/ /g, ' ')
.replace(/=/g, '=')
}
// JavaScript 上下文:Unicode 转义
jsEncode(str: string): string {
return str.replace(/[^\\w\\s]/g, char =>
`\\\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`
)
}
// CSS 上下文:只允许字母数字和短横线
cssEncode(str: string): string {
return str.replace(/[^a-zA-Z0-9-]/g, char =>
`\\\\${char.charCodeAt(0).toString(16)} `
)
}
// URL 上下文:encodeURIComponent
urlEncode(str: string): string {
return encodeURIComponent(str)
}
}
不同上下文需要不同的编码方式,这是很多人忽略的关键点。在 HTML 属性中用 htmlEncode,在 JS 变量中用 jsEncode,在 CSS 中用 cssEncode。用错编码方式等于没编码——比如在 <script> 标签内用 htmlEncode,攻击者可以用 </script><script> 闭合标签绕过。
防御层次三:CSP(内容安全策略)——最后一道防线
// CSP 策略配置:生产级严格策略
const CSP_POLICY = {
'default-src': ["'none'"],
'script-src': [
"'self'", // 只允许同源脚本
"'strict-dynamic'", // 允许由可信脚本动态加载的脚本
"https://cdn.example.com", // 可信 CDN
],
'style-src': [
"'self'",
"'unsafe-inline'", // Tailwind 等需要内联样式
"https://cdn.example.com",
],
'img-src': ["'self'", "data:", "https:"],
'font-src': ["'self'", "https://cdn.example.com"],
'connect-src': ["'self'", "https://api.example.com"],
'frame-src': ["'none'"],
'object-src': ["'none'"],
'base-uri': ["'self'"],
'form-action': ["'self'"],
'frame-ancestors': ["'none'"], // 禁止被 iframe 嵌入
'report-uri': ['/api/csp-report'], // 违规上报
}
// 生成 CSP Header
function generateCSPHeader(policy: Record<string, string[]>): string {
return Object.entries(policy)
.map(([directive, values]) => `${directive} ${values.join(' ')}`)
.join('; ')
}
// Next.js 中间件设置 CSP
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
const csp = generateCSPHeader({
…CSP_POLICY,
'script-src': [
`'self'`,
`'nonce-${nonce}'`, // 使用 nonce 替代 unsafe-inline
`'strict-dynamic'`,
"https://cdn.example.com",
],
})
const response = NextResponse.next()
response.headers.set('Content-Security-Policy', csp)
response.headers.set('X-Content-Type-Options', 'nosniff')
response.headers.set('X-Frame-Options', 'DENY')
response.headers.set('X-XSS-Protection', '0') // 禁用旧版 XSS 过滤器(有绕过风险)
// 把 nonce 传给页面,用于标记可信内联脚本
response.cookies.set('csp-nonce', nonce, { httpOnly: false })
return response
}
'strict-dynamic' 是现代 CSP 的关键。它允许由可信脚本(通过 nonce 或 hash 标记)动态创建的 <script> 标签执行,解决了第三方脚本(分析 SDK、支付网关)的加载问题。没有 'strict-dynamic',你要么用 'unsafe-inline'(等于没设 CSP),要么把所有第三方脚本的 hash 都列出来(维护噩梦)。
三、富文本 XSS 防护的生产级方案
富文本是最棘手的 XSS 场景。业务要求渲染 HTML,但不能执行脚本。解决方案是 DOMPurify + 白名单标签/属性。
import DOMPurify from 'dompurify'
// 生产级富文本净化配置
const RICH_TEXT_CONFIG: DOMPurify.Config = {
// 允许的标签白名单
ALLOWED_TAGS: [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'p', 'br', 'hr',
'ul', 'ol', 'li',
'blockquote', 'pre', 'code',
'strong', 'em', 'del', 'ins',
'a', 'img',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
'span', 'div',
],
// 允许的属性白名单
ALLOWED_ATTR: [
'href', 'target', 'rel', // a 标签
'src', 'alt', 'width', 'height', // img 标签
'class', // 通用
'colspan', 'rowspan', // 表格
],
// 强制给 a 标签添加 rel="noopener noreferrer"
ADD_ATTR: ['target'],
// 自定义钩子:处理 a 标签的 href
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false,
RETURN_DOM_IMPORT: false,
}
// 创建净化实例
function createSanitizer() {
const purify = DOMPurify()
// 自定义钩子:确保 a 标签安全
purify.addHook('afterSanitizeAttributes', (node) => {
// 强制 a 标签在新窗口打开 + 防止 opener 泄漏
if (node.tagName === 'A') {
node.setAttribute('target', '_blank')
node.setAttribute('rel', 'noopener noreferrer')
// 阻止 javascript: 协议
const href = node.getAttribute('href') || ''
if (/^javascript:/i.test(href.trim())) {
node.removeAttribute('href')
}
}
// 移除所有事件处理属性
;[…node.attributes].forEach(attr => {
if (/^on/i.test(attr.name)) {
node.removeAttribute(attr.name)
}
})
})
return purify
}
// 使用示例
const sanitizer = createSanitizer()
function sanitizeRichText(html: string): string {
return sanitizer.sanitize(html, RICH_TEXT_CONFIG)
}
DOMPurify 的关键设计是:它先在 DOM 中解析 HTML,再用白名单过滤,最后序列化回字符串。这比正则过滤可靠得多——正则无法处理嵌套标签、编码绕过、HTML 实体等变体。
四、XSS 防护的架构权衡与盲区
CSP 的部署成本
CSP 不是加上就完事的。从 Content-Security-Policy-Report-Only(只上报不拦截)开始,先收集违规报告,确认没有误拦截,再切换到强制模式。这个过程通常需要 2-4 周。如果直接上线强制 CSP,第三方脚本(广告、分析、支付)大概率被拦截,页面功能直接挂掉。
富文本的边界
DOMPurify 能过滤 HTML 标签和属性,但无法过滤 CSS 中的表达式。style="background: url('javascript:alert(1)')" 在现代浏览器中不会执行 JS,但 style="behavior: url(evil.htc)" 在旧版 IE 中可以。如果需要支持 IE,CSS 过滤也要做。
第三方脚本的风险
CSP 的 'strict-dynamic' 允许可信脚本动态加载其他脚本。但如果可信脚本本身被攻击者篡改(供应链攻击),'strict-dynamic' 就成了攻击的放大器。缓解方案:用 Subresource Integrity(SRI)校验第三方脚本的完整性。
<!– SRI 校验:脚本内容被篡改时拒绝执行 –>
<script
src="https://cdn.example.com/analytics.js"
integrity="sha384-abc123…"
crossorigin="anonymous"
></script>
SSR 场景的特殊风险
服务端渲染时,用户数据可能在 HTML 模板拼接阶段被注入。React 的 JSX 默认转义,但 Vue 的 v-html 和模板字符串拼接不会。SSR 场景下,XSS 的注入点在服务端,但执行在客户端——两端都要做输出编码。
五、总结
XSS 防护不是单一技术点,是纵深防御体系。第一层输入验证(白名单),第二层输出编码(上下文感知),第三层 CSP(内容安全策略),第四层运行时净化(DOMPurify)。四层防线叠加,任何一层被突破,下一层仍然能拦截。
落地路线:先做输出编码(成本最低、收益最高),覆盖所有动态渲染点;再加 CSP(从 Report-Only 模式开始);最后处理富文本场景(DOMPurify + 白名单)。每一步都要用自动化扫描验证覆盖率,不要依赖人工审查——人眼看不出 <img/src=x onerror=alert(1)> 这种变体。安全防护的有效性取决于最薄弱的环节,一个遗漏的渲染点就能让所有防线形同虚设。

