08 · 前端安全
本篇解决什么问题: 前端安全常被误解为"后端的事"。但 XSS、CSRF、依赖投毒、Token 存储这些 都直接发生在前端,而且出了事往往先从前端爆。 本篇按攻击类型 → 原理 → 防御组织,每条都给可落地的代码。
📌 核心原则:前端的一切输入都不可信,前端的一切校验都可绕过——安全边界必须在服务端。
开篇速览
| 威胁 | 一句话 | 关键防御 |
|---|---|---|
| XSS | 把恶意脚本注入页面执行 | 转义输出 + CSP + 避免 innerHTML |
| CSRF | 诱导用户在已登录状态下发请求 | SameSite Cookie + Token/双重验证 |
| 点击劫持 | 透明 iframe 覆盖按钮 | X-Frame-Options / CSP frame-ancestors |
| 依赖供应链 | 第三方包被投毒 | lockfile + 审计 + 最小依赖 |
| Token 存储 | Token 放哪才不容易被盗 | 优先 HttpOnly Cookie |
| 传输安全 | 中间人窃听/篡改 | 全站 HTTPS + HSTS |
一、XSS(跨站脚本攻击)
原理:把恶意 JS 注入到页面,在受害者浏览器里执行。
1.1 三种类型
| 类型 | 注入位置 | 触发方式 | 例子 |
|---|---|---|---|
| 反射型 | URL 参数 | 点恶意链接 | ?q=<script>alert(1)</script> 被回显到页面 |
| 存储型 | 数据库(评论/昵称) | 所有访问者都中招 | 评论区存了恶意脚本 |
| DOM 型 | 前端 JS 处理不当 | 前端自己把数据当代码执行 | innerHTML = location.hash |
1.2 防御(四条,按优先级)
① 输出转义(最根本)
// ❌ 危险:把用户输入当 HTML 解析
el.innerHTML = userInput
// ✅ 用 textContent(浏览器自动转义,不当 HTML 解析)
el.textContent = userInput// 需要渲染 HTML 时,必须**净化**(用 DOMPurify)
import DOMPurify from 'dompurify'
el.innerHTML = DOMPurify.sanitize(userInput)框架默认已转义:
- React 的
{userInput}默认转义(dangerouslySetInnerHTML才不转义) - Vue 的
默认转义(v-html才不转义)
🎯 99% 的 XSS 来源于:
dangerouslySetInnerHTML/v-html/innerHTML/eval/new Function/document.write。 见到这些就要警觉。
② 不要用危险的 API
// ❌ 全部禁止
eval(code)
new Function(code)
setTimeout('alert(1)', 100) // 字符串参数会被 eval
el.setAttribute('onclick', userInput) // 事件属性注入
location.href = userInput // javascript: 伪协议
// ❌ 危险 URL
<a href="javascript:alert(1)"> // 校验协议白名单:只允许 http/https/mailto③ CSP(内容安全策略)——纵深防御
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-abc123'; # 只允许同源 + 带 nonce 的脚本
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
frame-ancestors 'none'; # 防点击劫持
object-src 'none';
base-uri 'self';<!-- 配合 nonce:每次请求生成随机值 -->
<script nonce="abc123">…</script>💡 CSP 是第二道防线:即使发生了 XSS,也能阻止恶意脚本加载外部资源或外传数据。 建议先用
Content-Security-Policy-Report-Only观察,再正式启用。
④ Cookie 加 HttpOnly
即使 XSS 成功,也偷不走 document.cookie 里的 HttpOnly Cookie(见第五节)。
1.3 富文本场景(编辑器/评论)
必须服务端 + 前端双净化,且用白名单而非黑名单:
DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href', 'title'],
ALLOW_DATA_ATTR: false,
})⚠️ 黑名单(过滤
<script>)一定会被绕过——有成千上万种变形(onerror=、svg、iframe…)。只能用白名单。
二、CSRF(跨站请求伪造)
原理:用户登录了 A 站,访问恶意 B 站,B 站自动发请求到 A 站, 浏览器自动带上 A 站的 Cookie → A 站以为是用户本人操作。
<!-- 恶意页面:img 标签会自动发 GET 请求 -->
<img src="https://bank.com/transfer?to=attacker&amount=10000">2.1 防御(三种,可叠加)
① SameSite Cookie(首选,最省事)
Set-Cookie: session=xxx; HttpOnly; Secure; SameSite=Lax| 值 | 行为 |
|---|---|
Strict | 完全不随跨站请求发送(最安全,但影响从外链跳转过来的体验) |
Lax(推荐) | 允许顶级导航 GET(点链接进来),阻止 POST 与子资源请求 |
None | 都发送,必须配合 Secure |
② CSRF Token
服务端渲染页面时下发随机 token(放表单隐藏域或 meta)
→ 前端每次请求带上(Header 或 body)
→ 服务端校验// 从 meta 读取,统一加到请求头
axios.defaults.headers.common['X-CSRF-Token'] =
document.querySelector('meta[name="csrf-token"]').content③ 自定义 Header + 校验 Origin/Referer
// 服务端校验
if (req.headers.origin !== 'https://your-site.com') return 403🎯 现代实践:SameSite=Lax Cookie + 自定义请求头(如
X-Requested-With) 基本能挡住绝大多数 CSRF。用了Authorization: Bearer的方案(Token 不放 Cookie)天然免疫 CSRF。
三、点击劫持(Clickjacking)
原理:用透明 iframe 套住目标站点,诱导用户点击"看起来无害"的按钮。
防御:
X-Frame-Options: DENY # 或 SAMEORIGIN
Content-Security-Policy: frame-ancestors 'none'
X-Frame-Options是老标准,CSP 的frame-ancestors是推荐做法,两者可同时用。
四、依赖供应链安全
风险:npm 包被投毒(维护者账号被盗、恶意包名近似——typosquatting)。
防御:
| 措施 | 做法 |
|---|---|
| 提交 lockfile | package-lock.json / pnpm-lock.yaml 必须提交,CI 用 npm ci |
| 定期审计 | npm audit / pnpm audit,或 Snyk、Dependabot |
| 最少依赖 | 每加一个依赖都是引入一份风险;小功能能自己写就自己写 |
| 检查包名 | 确认拼写(如 lodash vs lodahs)、下载量、维护活跃度 |
| 锁定版本 | 关键依赖用精确版本,避免 ^ 自动拉到恶意版本 |
| 忽略脚本 | .npmrc 里 ignore-scripts=true(阻断 postinstall 执行) |
| 私有源 | 企业用私有 registry + 安全扫描 |
npm audit fix # 自动修复(注意会改 lockfile,要 review)
pnpm audit --prod # 只看生产依赖⚠️
postinstall脚本是重灾区:装包时自动执行的脚本拥有你机器的权限。
五、Token 存储:放哪
| 方案 | XSS 风险 | CSRF 风险 | 评价 |
|---|---|---|---|
localStorage | ❌ 高(JS 可读,XSS 直接偷走) | ✅ 无(不自动发送) | ⚠️ 常见但不推荐 |
sessionStorage | ❌ 同上 | ✅ 无 | 同上,关标签即失效 |
| 内存(变量) | ✅ 低(刷新即丢) | ✅ 无 | 安全但体验差(刷新要重登) |
| HttpOnly Cookie | ✅ 低(JS 读不到) | ⚠️ 有(要配 SameSite + CSRF Token) | ✅ 推荐 |
推荐方案:
| 场景 | 方案 |
|---|---|
| 常规 Web 应用 | HttpOnly + Secure + SameSite=Lax Cookie + CSRF Token |
| 需要长期免登录 | HttpOnly Cookie 存 Refresh Token(长),Access Token 存内存(短,如 15 分钟) |
| 纯 API/移动端 | Access Token 内存 + 安全存储(App 用 Keychain/Keystore) |
最佳实践(无感刷新):
Access Token(15min)存内存 → 请求用
Refresh Token(7d)存 HttpOnly Cookie → 过期时换新 Access Token// 响应拦截器:401 时刷新并重放
if (res.status === 401 && !isRefreshing) {
const ok = await refresh() // 用 HttpOnly Cookie 里的 refresh token
return ok ? api.retry(originalRequest) : redirectToLogin()
}❌ 不要把 Token 放 URL(会被记录在日志、Referer、浏览器历史里)。
六、传输与其他
6.1 传输安全
Strict-Transport-Security: max-age=31536000; includeSubDomains # HSTS,强制 HTTPS| 措施 | 说明 |
|---|---|
| 全站 HTTPS | 含静态资源(否则会被"混合内容"拦截或降级) |
| HSTS | 强制浏览器只走 HTTPS,防 SSL Stripping |
| 证书监控 | 到期告警 |
6.2 其他常见风险
| 风险 | 防御 |
|---|---|
| 开放重定向 | ?redirect= 必须校验白名单,否则被用于钓鱼 |
| 敏感信息进前端 | 不要把密钥/内网地址打进 bundle(构建产物是公开的) |
| Source Map 泄漏 | 用 hidden 且不部署到 CDN(见 A12) |
| iframe 嵌入第三方 | 用 sandbox 属性限制权限 |
| 密码明文 | 前端传输前至少做一次哈希(但仍需 HTTPS;真正安全靠后端加盐慢哈希) |
自检清单
- [ ] 能说出 XSS 三种类型,以及"99% 的 XSS 来源"是哪些 API
- [ ] 知道富文本必须用白名单净化(DOMPurify),黑名单会被绕过
- [ ] 能说出 CSRF 的原理与三种防御,知道
SameSite=Lax与 Bearer Token 方案 - [ ] 能说出 Token 四种存储方案的 XSS/CSRF 风险对比,并选出推荐方案
- [ ] 知道依赖供应链的防护措施(lockfile + 审计 + 最小依赖)
常见误区
| ❌ 误区 | ✅ 事实 |
|---|---|
| "React/Vue 会自动转义,所以不会 XSS" | 用了 dangerouslySetInnerHTML/v-html 就没保护 |
"把 <script> 过滤掉就安全" | 黑名单必被绕过,只能白名单 |
| "Token 存 localStorage 最方便" | XSS 一来直接被偷走 |
| "用了 HTTPS 就不用管 CSRF" | HTTPS 只管传输,CSRF 是请求伪造,两回事 |
| "前端做了校验就安全" | 前端校验只是体验,攻击者直接发请求;服务端必须校验 |
| "环境变量里的密钥前端看不到" | 前端环境变量会被打进 bundle,是公开的 |
动手练习
- 在一个页面里用
innerHTML渲染用户输入,复现存储型 XSS,再用textContent+ DOMPurify 修复。 - 给项目配置 CSP(
default-src 'self'+ nonce),用 Report-Only 模式观察一天,看有哪些资源被拦截。 - 检查项目里 Token 存在哪里;如果是
localStorage,评估改成 "Access Token 内存 + Refresh Token HttpOnly Cookie" 的成本。
相关文档
- 监控与脱敏 → A12 · 前端可观测性与线上排障
- Source Map 泄漏风险 → A16 · CI/CD 与部署运维
- 依赖与构建安全 → A21 · 构建工具原理 · A28 · 代码生成与规范化工具链
- 服务端鉴权与会话 → S 域 · 安全计划
- 体系化的安全知识 → AI 09 · 安全治理与合规