Skip to content

前端安全:XSS、CSRF 与 CSP 实战 ​

前端安全最危险的地方在于:漏洞往往由一行看似正常的代码引入,而影响可能波及所有用户。

这篇文章不罗列 OWASP 条目,而是围绕前端最常见的三类风险——XSS、CSRF 和供应链攻击——讲清楚攻击路径、防御手段和落地配置。

XSS:把不可信数据当成代码 ​

XSS 的本质是用户可控的数据被当作 HTML 或 JavaScript 执行。常见类型:

  • 存储型:恶意内容存入数据库,所有访问者都会触发。评论区、昵称、富文本是重灾区。
  • 反射型:恶意参数通过 URL 反射到页面,通常配合钓鱼链接。
  • DOM 型:前端代码直接把 URL、localStorage 等数据写入危险 DOM API。

识别危险汇点 ​

ts
// 危险:直接写入 HTML
element.innerHTML = comment.content

// 危险:插入 HTML 片段
element.insertAdjacentHTML('beforeend', userContent)

// 危险:把用户输入当作链接
link.href = userInput

// 危险:动态执行字符串
eval(userInput)

框架的自动转义能挡住大部分模板插值,但不会保护这些"逃生舱":

  • Vue 的 v-html
  • React 的 dangerouslySetInnerHTML
  • 直接操作 DOM 的 innerHTML、outerHTML、document.write

原则是:能不渲染 HTML 就不渲染。确需富文本时,必须经过严格的白名单清洗:

ts
import DOMPurify from 'dompurify'

const cleanHtml = DOMPurify.sanitize(rawHtml, {
  USE_PROFILES: { html: true },
  FORBID_TAGS: ['style', 'iframe', 'form'],
  FORBID_ATTR: ['onerror', 'onload']
})

container.innerHTML = cleanHtml

DOMPurify 不能替代业务校验。允许用户上传 HTML 的场景,仍要限制标签、属性和 URL 协议。

URL 协议白名单 ​

javascript: 协议可以直接执行代码,所有跳转和图片地址都要校验:

ts
function toSafeUrl(input: string) {
  const url = new URL(input, window.location.origin)
  const allowed = ['http:', 'https:', 'mailto:']
  if (!allowed.includes(url.protocol)) {
    throw new Error('Unsupported protocol')
  }
  return url.href
}

CSP:限制代码来源 ​

内容安全策略(CSP)通过响应头告诉浏览器,哪些资源可以加载和执行。它是 XSS 的最后一道防线:

http
Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-RANDOM' 'strict-dynamic';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  connect-src 'self' https://api.example.com;
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';
  upgrade-insecure-requests

几个关键点:

  • script-src 使用 nonce 或 hash,避免 'unsafe-inline'。'strict-dynamic' 让受信任脚本动态加载的脚本也被允许。
  • object-src 'none' 禁用 Flash 等插件,减少攻击面。
  • frame-ancestors 'none' 防止页面被嵌入,配合 X-Frame-Options 防点击劫持。
  • connect-src 限制接口域名,攻击者即使注入脚本也难以外传数据。
  • 上线前先用 Content-Security-Policy-Report-Only 收集违规报告,避免直接打断业务。

带 nonce 的服务端实现需要每次请求生成随机值,并注入到脚本标签:

ts
import crypto from 'node:crypto'

app.use((req, res, next) => {
  res.locals.cspNonce = crypto.randomBytes(16).toString('base64')
  res.setHeader(
    'Content-Security-Policy',
    [
      "default-src 'self'",
      `script-src 'self' 'nonce-${res.locals.cspNonce}' 'strict-dynamic'`,
      "style-src 'self' 'unsafe-inline'",
      "object-src 'none'",
      "base-uri 'self'",
      "frame-ancestors 'none'"
    ].join('; ')
  )
  next()
})

如果项目使用 Trusted Types,可以进一步禁止危险的字符串赋值:

ts
const policy = window.trustedTypes?.createPolicy('default', {
  createHTML: (input: string) => DOMPurify.sanitize(input)
})

配合 CSP 的 require-trusted-types-for 'script',innerHTML 参数必须是 TrustedHTML 类型,能系统性减少 DOM XSS。

CSRF:借用用户的登录态 ​

CSRF(跨站请求伪造)利用浏览器会自动携带 Cookie 的特性,诱导已登录用户在不知情的情况下发起请求。

防御分三层:

ts
res.cookie('session', sessionId, {
  httpOnly: true,
  secure: true,
  sameSite: 'lax',
  path: '/'
})
  • HttpOnly 阻止 JavaScript 读取,降低 XSS 窃取风险。
  • Secure 只允许 HTTPS 传输。
  • SameSite=Lax 阻止大多数跨站 POST 请求携带 Cookie。Strict 更严格,但会影响从外部链接进入时的登录态。

2. CSRF Token ​

对状态变更请求(POST、PUT、DELETE)校验一次性令牌,令牌由服务端生成并绑定会话:

ts
app.post('/api/orders', (req, res) => {
  const token = req.get('x-csrf-token')
  if (!token || token !== req.session.csrfToken) {
    return res.status(403).json({ message: 'CSRF token invalid' })
  }
  // 处理业务
})

前端从页面注入或专用接口获取 token,放在请求头中。攻击者无法读取跨域页面的 token,也就无法构造有效请求。

3. 校验 Origin / Referer ​

对来自浏览器的请求,校验 Origin 是否在白名单内。它不能替代 token,但能作为额外防线:

ts
const allowedOrigins = new Set(['https://app.example.com'])

app.use((req, res, next) => {
  const origin = req.get('origin')
  if (origin && !allowedOrigins.has(origin)) {
    return res.status(403).json({ message: 'Origin not allowed' })
  }
  next()
})

注意:CORS 不能防 CSRF。CORS 限制的是"读取响应",而 CSRF 只要求请求被执行。

Token 应该存在哪里 ​

这是前端安全中最常争论的问题:

存储位置XSS 风险CSRF 风险适用场景
localStorage高,脚本可直接读取低,不会自动携带纯前端 API,接受风险
HttpOnly Cookie低,脚本不可读高,需 CSRF 防护同域 Web 应用
内存变量中,刷新丢失低短期 access token

推荐组合:短期 access token 放内存,refresh token 放 HttpOnly + Secure + SameSite Cookie。刷新接口同时校验 CSRF token 和 Origin。这样既不把长期凭证暴露给脚本,也能控制泄露窗口。

供应链与第三方脚本 ​

前端安全不只来自自己的代码:

  • 锁定依赖版本,提交 lockfile,CI 使用 --frozen-lockfile。
  • 定期执行 npm audit,但对告警做人工评估,不要盲目自动升级。
  • 对 CDN 引入的脚本使用 Subresource Integrity(SRI):
html
<script
  src="https://cdn.example.com/lib.js"
  integrity="sha384-BASE64_HASH"
  crossorigin="anonymous"
></script>
  • 第三方脚本(统计、客服、广告)通过 CSP 限制来源,并评估其权限。
  • 避免在页面中直接加载来源不明的微前端远程模块。

安全响应头清单 ​

http
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Content-Security-Policy: default-src 'self'; object-src 'none'; frame-ancestors 'none'

nosniff 阻止浏览器猜测 MIME 类型,降低上传文件被当作脚本执行的风险。Referrer-Policy 防止 URL 中的敏感参数通过 Referer 泄漏。

常见坑 ​

  1. 只做前端校验。前端校验是体验,服务端校验才是安全边界。
  2. CSP 直接上生产。先 Report-Only 收集违规,再逐步收紧,否则容易白屏。
  3. 认为用了框架就安全。v-html 和 dangerouslySetInnerHTML 仍是高危入口。
  4. 把 token 放 localStorage 还忽略 XSS。一旦有 XSS,token 可直接被读取。
  5. 第三方脚本无限制。任意第三方脚本都拥有页面同源权限,等于把安全交给了对方。
  6. 忽略依赖漏洞。构建工具、UI 库、polyfill 都可能成为攻击入口。

小结 ​

前端安全是纵深防御:输入层做校验与清洗,输出层做编码,运行时用 CSP 限制能力,会话层用 Cookie 属性和 CSRF token 防护,供应链层锁定依赖。任何单点措施都不够,组合起来才能把风险降到可接受范围。

基于 VitePress 构建 · 持续更新中