前端安全防御:XSS、CSRF、注入与传输的攻防清单
本文目录
浏览器端安全有一条主线:所有攻击都发生在"不可信数据流动"的路径上。用户输入、URL 参数、第三方脚本、网络传输,每一处都是入口。这篇文章按攻击类型组织——XSS、CSRF、点击劫持、postMessage 滥用、供应链投毒、传输劫持——每一类给出攻击原理、可落地的防御手段和验证方法,最后汇总成一份上线前自查清单。
先说清楚适用范围:默认你在一个有登录态的普通 Web 站点上工作。纯静态内容站和涉及支付、隐私数据的站点,威胁模型不同,这份清单需要按业务裁剪。
威胁模型:先明确防什么、防到什么程度
安全不是"全防",而是"明确接受哪些残余风险"。先把攻击面列出来:
| 攻击类型 | 攻击者目标 | 典型入口 | 防御要点 |
|---|---|---|---|
| 存储型 / 反射型 XSS | 在站点执行任意脚本、窃取会话 | 评论、昵称、富文本、URL 参数 | 服务端输出转义 + CSP 兜底 |
| DOM 型 XSS | 绕过服务端过滤直接在前端执行 | innerHTML、document.write、eval | 安全 sink + Trusted Types |
| CSRF | 借用户登录态发起非自愿请求 | 跨站表单、图片或脚本触发的请求 | SameSite + Origin 校验 + Token |
| 点击劫持 | 诱导用户在伪装页面上点击 | iframe 嵌入 | frame-ancestors |
| postMessage 滥用 | 窃取跨窗口通信中的数据 | 第三方 iframe、弹窗通信 | origin 白名单双向校验 |
| 供应链投毒 | 通过依赖或 CDN 脚本植入恶意代码 | npm 依赖、script 标签 | 审计 + SRI + CSP |
| 中间人 / 传输劫持 | 窃听或篡改明文流量 | HTTP、混合内容 | HTTPS + HSTS |
底线原则是纵深防御:任何单一手段都可能失效——转义有遗漏、CSP 有豁免、证书有配置错误。多层叠加的意义是让攻击者必须在每一层都成功,攻击成本才会真正上升。
XSS:三类注入,三个堵法
先分清三类
| 类型 | 注入位置 | 是否经过服务器 | 典型场景 | 防御重点 |
|---|---|---|---|---|
| 存储型 | 数据库 | 是 | 评论区写入脚本,所有访问者中招 | 服务端净化 + 输出转义 |
| 反射型 | URL / 请求参数 | 是但不落库 | 伪造 ?q=<script> 链接诱导点击 | 输出转义 + 不信任 URL 参数 |
| DOM 型 | 纯前端 | 否 | 前端把 location.hash 写入 innerHTML | 安全 sink + Trusted Types |
三类的关键差别:存储型和反射型可以在服务端统一拦截,DOM 型完全发生在浏览器里,服务端看不见。"后端已经转义了"这句话对 DOM 型 XSS 不成立。
第一道防线:默认安全的数据流
// 反面:inner html 拼接,一切转义努力白费
box.innerHTML = `<h2>${query}</h2>`; // query 来自 location.hash 时就是 DOM 型 XSS
// 正面:文本走 textNode,结构由代码控制
const title = document.createElement('h2');
title.textContent = query;
box.replaceChildren(title);
框架的默认插值(Vue 的 {{ }}、React 的 {})是转义的,真正出事的是显式逃生口:Vue 的 v-html、React 的 dangerouslySetInnerHTML、Angular 的 bypassSecurityTrust*。这些逃生口必须收敛到少数几个经过审计的模块里,每个都要回答两个问题:输入来自谁?经过了哪个净化函数?
富文本场景不要手写正则过滤,用经过维护的 HTML 净化库(如 DOMPurify 这类),配置成"只放行白名单标签和属性"的模式,并明确拒绝 SVG、MathML 里的事件属性。黑名单过滤("去掉 script 标签就行")必然被变种绕过。
CSP:把纵深防御写成策略
CSP 的价值不是阻止注入,而是限制注入之后的破坏半径——脚本来源收紧后,即使注入成功也加载不了外部 payload,也执行不了内联脚本。
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-r4nd0m'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' 'nonce-r4nd0m'
| 指令 | 作用 | 建议 |
|---|---|---|
default-src 'self' | 未显式指定类型的资源兜底 | 以此为基线,逐类放开 |
script-src 'self' 'nonce-…' | 脚本来源 | 每次响应生成新 nonce,配合 strict-dynamic 时可放行加载器 |
object-src 'none' | 禁止 object / embed | 直接禁掉这个攻击面 |
base-uri 'self' | 限制 base 标签 | 防 base 注入劫持相对路径 |
form-action 'self' | 限制表单提交目标 | 防表单被改到外部 |
upgrade-insecure-requests | 自动升级 http 子资源 | 混合内容治理 |
frame-ancestors 'none' | 谁可以嵌入本页 | 点击劫持防御,见下文 |
上线顺序很重要:先用 Content-Security-Policy-Report-Only 观察 1-2 周,收集违规报告(report-to 或 report-uri 指向收集端点),确认没有误伤再切到强制模式。Content-Security-Policy 也可以用 <meta> 下发,但 frame-ancestors、report-uri 等指令在 meta 里无效——安全相关的关键指令必须在响应头下发。
Trusted Types:管住 DOM XSS 的源头
CSP 之外还有更靠前的一道闸:require-trusted-types-for 'script'。开启后,所有 DOM XSS sink(innerHTML、eval、setInterval 传字符串等)只接受经过 Trusted Types policy 包装的值,等于把"谁能往危险 sink 里塞字符串"变成了显式审计点。
// 配合 CSP: require-trusted-types-for 'script' + trusted-types default
const policy = trustedTypes.createPolicy('default', {
createHTML(input) {
return sanitizeHTML(input); // 必须是经过审计的净化函数,不能原样返回
},
createScriptURL(url) {
if (new URL(url).origin === location.origin) return url;
throw new TypeError('blocked script url: ' + url);
},
});
// 此后框架内部的 innerHTML 赋值会经由 default policy,
// 未经净化的字符串直接进 sink 会直接抛 TypeError 并产生违规报告
边界要说明:Trusted Types 在 Chromium 系和新版 Firefox 已可用,其他内核支持进度不一,上线前按目标用户浏览器核实;对存量代码多的站点改造成本不低,优先级排在转义和 CSP 之后。
CSRF:让跨站请求带不上身份
CSRF 的本质:浏览器会自动附带 Cookie,攻击者构造一个自动提交的跨站页面,用户的登录态就被"顺走"了。防御思路是把"请求确实发自本站"变成可验证的事实。
SameSite 是第一道闸门
| SameSite 值 | 行为 | 适用 | 风险 |
|---|---|---|---|
Strict | 任何跨站请求都不带 | 纯 API 会话、后台功能 | 从外部链接点进来时也"像未登录",体验断裂 |
Lax | 顶级导航 GET 允许携带,其余跨站不带 | 大多数业务 Cookie(主流浏览器默认) | 顶级导航 GET 仍可能被利用 |
None(须配 Secure) | 跨站全带 | 第三方嵌入场景 | 明确扩大了 CSRF 面,必须叠加 Token |
Set-Cookie: session=…; HttpOnly; Secure; SameSite=Lax; Path=/
两个细节:HttpOnly 防 XSS 偷 Cookie,但不防 CSRF;SameSite=None 不带 Secure 会被现代浏览器直接拒绝。条件允许时用 __Host- 前缀(__Host-session),它强制 Secure、无 Domain、Path=/,天然收窄 Cookie 的发送范围。
Origin 校验:服务端核对请求来源
// Node 中间件示意:所有会改状态的方法必须带可信 Origin
function verifyOrigin(req, res, next) {
const safe = new Set(['GET', 'HEAD', 'OPTIONS']);
if (safe.has(req.method)) return next();
const origin = req.get('Origin') || parseRefererOrigin(req.get('Referer'));
if (!origin) return res.status(403).json({ error: 'missing origin' });
if (origin !== 'https://www.example.com') {
return res.status(403).json({ error: 'cross-origin blocked' });
}
next();
}
两点边界:Origin 与 SameSite 的粒度不同——SameSite 按"站点"(注册域)比较,同站子域攻击者可以绕过;Origin 按"源"(协议 + 域 + 端口)比较,更严。另外部分老客户端或特殊重定向链路可能两个头都不带,策略上"缺失即拒绝"最安全,但要先观察线上数据确认不会误伤正常流量。
CSRF Token:最后一道确认
<!-- 服务端渲染时生成一次性随机值,同时写进表单和会话 -->
<form method="post" action="/api/transfer">
<input type="hidden" name="csrf_token" value="d8f3c19e8b">
<button type="submit">转账</button>
</form>
服务端比对表单值与会话值,不匹配即拒绝。前后端分离项目常用"双重提交 Cookie"变体:随机值同时放进一个非 HttpOnly 的 Cookie 和请求头(如 X-CSRF-Token),服务端比对两者一致即可,不需要会话存储。Token 方案防的是"攻击者读不到同源凭证"这个前提,所以它与 SameSite、Origin 校验是互补而非替代关系。
点击劫持:一行响应头禁掉被嵌入
攻击方式:把目标站透明地叠在诱导页下面,用户以为在点"领奖",实际点的是目标站按钮。防御是在响应头声明"不允许被嵌入":
Content-Security-Policy: frame-ancestors 'none'
X-Frame-Options: DENY
| 手段 | 能力 | 说明 |
|---|---|---|
frame-ancestors 'none' | 标准方式,支持白名单多个来源 | 只能在响应头下发,meta 无效 |
X-Frame-Options: DENY | 旧浏览器回退 | 只有 DENY / SAMEORIGIN,无白名单能力 |
如果业务确实需要被嵌入(比如自己的 App、可信合作方页面),把 'none' 换成来源白名单,不要为了图省事放开全部。注意 frame-ancestors 只管"被嵌入",自己页面里主动嵌别人的 iframe 是另一回事,风险在 postMessage 一节。
postMessage:跨源通信必须双向验明身份
postMessage 是合法跨源通信的唯一通道,也因此是常见的事故点。两个方向都要校验:
// 发送方:不要用 '*',除非内容确实完全公开
widgetFrame.contentWindow.postMessage(
{ type: 'init', locale: 'zh-CN' },
'https://widget.example.com'
);
// 接收方:白名单 + 结构校验,缺一不可
const TRUSTED = new Set(['https://widget.example.com']);
window.addEventListener('message', (event) => {
if (!TRUSTED.has(event.origin)) return; // 来源校验
const data = event.data;
if (data === null || typeof data !== 'object' || typeof data.type !== 'string') {
return; // 结构校验,畸形消息直接丢弃
}
if (data.type === 'resize') applyHeight(data.height);
});
事故模式可以归纳成三条:发送方用 '*' 把敏感数据广播给任意窗口;接收方不校验 event.origin(或只判空),任何页面都能嵌入并发消息;把 JSON.parse(event.data) 包在 try/catch 外导致整页崩溃。origin 校验必须双向做——你校验别人的同时,别人也在校验你。
供应链:第三方代码也是攻击面
现代前端的实际攻击面有一半不在自己的代码里,而在 node_modules 和 CDN 脚本里。治理分三层:
依赖审计与锁定
npm audit --omit=dev # 只看生产依赖的高危项
npm ls event-light # 排查某个包被谁引入、版本链是否干净
工程上的硬规则:lockfile 必须提交;CI 里对新增依赖做审计卡点(高危漏洞阻断合并);新依赖先问"这 30KB 是否值得"再引入。审计只能发现已公开的漏洞,投毒包(改名伪装的恶意包)主要靠控制引入来源和最小化依赖数量。
SRI:让 CDN 改不了内容
<!-- integrity 值由构建工具对文件内容计算生成,下面是格式示意 -->
<script
src="https://cdn.example.com/lib/3.2.1/lib.min.js"
integrity="sha384-Base64EncodedHashOfTheFile"
crossorigin="anonymous"
></script>
文件内容与哈希不匹配时,浏览器拒绝执行——第三方 CDN 被入侵或被劫持时,你的页面不会跟着挂。注意 SRI 的双刃剑:版本号必须钉死(用具体版本路径,不能用 latest 这类漂移地址),否则文件一更新页面就挂。这个"挂"正是想要的失败方式:宁可不可用,不可执行未知代码。
CSP 收紧脚本来源
script-src 'self' 'nonce-…' 同时覆盖了供应链场景:依赖里的恶意代码如果要加载外部脚本或执行内联代码,都会被策略拦下。第三方脚本该走 nonce 接入流程,而不是简单加白名单域名——域名白名单下,该域名任何路径上的内容都是可信的。
传输安全:HTTPS 是前提,不是全部
HTTPS 解决窃听和篡改,但两个补充动作决定实际效果:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Content-Security-Policy: upgrade-insecure-requests
- HSTS:告诉浏览器"这个域名在有效期内一律走 HTTPS",把用户第一次访问后降级到 http 的劫持窗口关掉。
max-age建议逐步加大;确认全站(含所有子域)无 http 依赖后再加includeSubDomains;提交到 hstspreload.org 的 preload 列表是单向门——移除需要数月,没想清楚别提交。 - 混合内容:https 页面里混进 http 子资源是最常见的传输漏洞。可执行类(脚本、iframe、XHR)默认被现代浏览器阻止,但被阻止就等于功能坏了,所以要在构建期发现而不是等线上报错;图片、音频、视频类被动混合内容,Chromium 默认自动升级。
upgrade-insecure-requests让这个行为显式化,但根治办法是构建工具里加 lint 规则禁止 http 资源地址。
验证方法很直接:DevTools 的 Console 和 Network 面板筛 "blocked/mixed",或用 Lighthouse 的 "Best Practices" 审计跑一遍,把每个混合内容项按"删掉、升级、换源"分类处理。
上线前自查清单
| 检查项 | 验证方法 | 通过标准 |
|---|---|---|
| 用户输入渲染走安全 sink | 全局搜索 innerHTML / v-html / dangerouslySetInnerHTML | 仅白名单模块且经过净化库 |
| CSP 已强制下发 | 查看响应头 + 收集违规报告 | script-src 无 unsafe-inline |
| 写操作校验 Origin | 构造跨站表单 / curl 测试 | 非 GET 且 Origin 缺失或不匹配被拒 |
| Cookie 属性完整 | DevTools → Application → Cookies | Secure + SameSite + HttpOnly |
| 页面不可被任意嵌入 | 本地写个 iframe 页面测试 | frame-ancestors 生效 |
| 第三方脚本带 SRI | 篡改 integrity 值测试 | 页面拒绝执行该脚本 |
| 无混合内容 | Console / Lighthouse | 0 条 http 子资源 |
上图是拿六个维度给项目打分的雷达图示例(数值为示意,替换为实际评审结果)。它适合放进迭代评审:每个维度 1-5 分,连续两个迭代无提升的维度要有明确的整改计划或书面接受理由。
注意事项与边界
- 转义没有万能函数:HTML 文本、属性值、JS 字符串、URL 参数是四种上下文,用错上下文的转义等于没转义。优先用模板系统的默认转义。
- CSP 上线会误伤存量第三方脚本(统计、客服、CMS 注入的内联代码),一定要走 Report-Only 阶段,并把违规报告接入告警。
- 把 token 存 localStorage 再挂到请求头的方案,XSS 一旦成立 token 就直接可读;Cookie + HttpOnly 的组合在 XSS 场景下至少偷不走原始凭证。
- WAF 不是前端防御的一部分,它挡的是请求特征,不是输出注入逻辑,不要用它替代转义。
- CSP nonce / 头可能在某些反向代理、缓存层、安全网关上被改写或剥离,上线后要在真实线上域名验证响应头完整到达浏览器。
- 安全是持续过程而不是一次上线:新依赖、新 iframe、新 postMessage 通道、新接口,每一项都应该过一遍上面的清单。
总结
前端安全防御的结构可以记成三层:第一层是默认安全的数据流(安全 sink、净化库、框架默认转义),它解决"绝大多数输入";第二层是纵深策略(CSP、Trusted Types、SRI、frame-ancestors),它限制第一层失守后的破坏半径;第三层是传输与会话边界(HTTPS、HSTS、SameSite、Origin 校验、CSRF Token),它保证身份只在可信通道里使用。三层各自可验证:看代码、看响应头、看跨站请求的实际结果。把这三层写进 CI 和上线清单,比记一百个攻击案例有用。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。