浏览器 Web 安全基础系列

  1. 浏览器怎么判断是不是同一个网站

  2. Cookie 为什么会自动携带

  3. Session 登录状态为什么可能被劫持

  4. HTTPS 为什么仍然不够

  5. CORS 到底在限制什么

  6. CSRF 是怎么冒用登录状态的

  7. CSRF 如何防御(当前篇)

  8. XSS 为什么能在你的网站执行

  9. CSP 与浏览器安全响应头

  10. Clickjacking 点击劫持

  11. 第三方资源为什么危险

  12. 防盗链到底防什么

没有一种单独措施适合所有系统。生产环境通常组合:安全 Cookie、CSRF Token、请求来源校验和正确的 HTTP 方法。每一层处理不同的绕过条件。

CSRF 多层防御

普通登录 Session 可以使用:

Set-Cookie: __Host-session=...; Path=/; HttpOnly; Secure; SameSite=Lax

Lax 会阻止很多跨站表单 POST 携带 Cookie,同时允许用户通过普通链接进入网站。敏感后台可以评估 Strict;确实需要跨站 Cookie 时使用 None; Secure,但必须加强其他 CSRF 防御。

SameSite 不能覆盖同站恶意子域名。evil.example.combank.example.com 可能同站,所以不能只靠它。

第二层:同步 CSRF Token

服务端在 Session 中保存随机 Token:

import crypto from 'node:crypto';

function createCsrfToken() {
  return crypto.randomBytes(32).toString('base64url');
}

sessions.set(sessionId, {
  userId,
  csrfToken: createCsrfToken()
});

传统表单将 Token 放进隐藏字段:

<input type="hidden" name="csrfToken" value="服务器生成的随机值">

前后端接口放进自定义请求头:

fetch('/api/transfer', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': csrfToken
  },
  body: JSON.stringify({ to: 'bob', amount: 100 })
});

服务端比较请求 Token 与 Session Token:

function verifyCsrf(session, requestToken) {
  if (!session || !requestToken) return false;

  const expected = Buffer.from(session.csrfToken);
  const actual = Buffer.from(requestToken);
  return expected.length === actual.length &&
    crypto.timingSafeEqual(expected, actual);
}
CSRF Token 流程

攻击页面可以让浏览器自动发送 Cookie,但通常无法跨源读取正常页面中的随机 Token,也无法用普通表单设置自定义请求头。

Token 放在哪里

推荐:

  • 传统 HTML 表单:隐藏字段;

  • Fetch/Ajax:X-CSRF-Token 请求头。

不建议放入 URL,因为 URL 会进入历史记录、访问日志、代理和 Referer。

只把 Token 放进 Cookie,然后服务端只检查 Cookie,没有意义:Cookie 在 CSRF 中同样会被自动携带。关键是请求必须主动提交第二份值。

无服务端 Session 的系统可以设置可由 JavaScript 读取的 CSRF Cookie,前端把值复制到请求头,服务端比较二者。更稳妥的实现应把 Token 与用户会话进行签名绑定,防止子域名注入 Cookie。

伪代码:

csrfCookie = random + '.' + HMAC(sessionId + random, secret)

// 请求时同时出现:
Cookie: csrf=...
X-CSRF-Token: ...

第三层:Origin 校验

正常请求:

Origin: https://bank.example

攻击请求:

Origin: https://evil.example

服务端对敏感方法精确匹配白名单:

const ALLOWED = new Set(['https://bank.example']);

function verifySource(req) {
  const origin = req.headers.origin;
  if (origin) return ALLOWED.has(origin);

  const referer = req.headers.referer;
  if (!referer) return false;

  try {
    return ALLOWED.has(new URL(referer).origin);
  } catch {
    return false;
  }
}

不能使用 includes、模糊正则或简单 endsWith 判断域名。

Referer 为什么只是回退

Referer 可能包含完整页面 URL,受 Referrer-Policy、隐私设置和跳转协议影响,存在缺失情况。Origin 更简洁、更适合来源判断;没有 Origin 时再解析 Referer 的 Origin。

高风险接口如果两个请求头都缺失,通常采取拒绝策略。业务必须支持特殊客户端时,可以使用 CSRF Token 或明确的非浏览器认证机制兜底,而不是无条件放行。

哪些请求需要 CSRF 防护

重点保护所有会改变状态的 Cookie 认证接口:

POST / PUT / PATCH / DELETE

GET、HEAD 应保持无副作用。登录接口也可能遭受 Login CSRF:攻击者让受害者登录到攻击者账号,随后诱导受害者把隐私数据写进该账号。

推荐中间件结构

function csrfProtection(req, res, next) {
  if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();

  if (!verifySource(req)) return res.status(403).send('Invalid source');
  if (!verifyCsrf(req.session, req.get('X-CSRF-Token') || req.body.csrfToken)) {
    return res.status(403).send('Invalid CSRF token');
  }

  next();
}

实际项目可使用成熟框架能力,但必须知道其 Token 模式、Cookie 属性和适用的认证方式。

XSS 与 CSRF 防御

如果攻击者已经能在目标页面执行 JavaScript,他可能读取页面中的 Token、使用正常 Origin 发请求,并借浏览器自动携带 HttpOnly Cookie。因此 XSS 防御优先级非常高,CSRF Token 不能替代输出编码和 CSP。

常见误区

Token 越频繁更换越安全

每请求一次性 Token 会增加多标签页、后退、重试和并发复杂度。很多系统使用每 Session 一个高强度随机 Token即可。

校验 Referer 字符串前缀

https://bank.example.evil.com 可能绕过错误前缀判断。必须用 URL 解析并比较 .origin

CORS 白名单等于 Origin 防御

CORS 决定响应可读性;Origin 校验决定业务是否执行。虽然都使用 Origin 请求头,但目的不同。

小结

推荐思路不是“选一个最强方案”,而是:Cookie 用 Secure + HttpOnly + SameSite,状态修改请求验证 CSRF Token 和来源,GET 不修改数据,并同时防御 XSS。

参考资料