首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >CSRF 不是漏洞,是浏览器的"特性"——从 Cookie 自动提交说起的安全困局

CSRF 不是漏洞,是浏览器的"特性"——从 Cookie 自动提交说起的安全困局

作者头像
前端达人
发布2026-04-02 20:38:20
发布2026-04-02 20:38:20
1250
举报
文章被收录于专栏:前端达人前端达人

最近在和几个开发者聊安全话题,有人问我:"CSRF 攻击真的那么危险吗?"

我回答说:危险的不是 CSRF 本身,而是浏览器那个看似"便利"的自动提交特性。

很多人把 CSRF(跨站请求伪造)当成一种漏洞来对待,但实际上它更像是浏览器设计中的一个"困局"——一个为了用户体验而埋下的安全地雷。要理解这个困局有多深,咱们得从 Cookie 自动提交说起。

Cookie 自动提交:浏览器的"贴心"设计

想象一下,你现在登录了淘宝账户。浏览器收到服务器的 Set-Cookie 指令,把 session 信息存到本地。

然后你在淘宝浏览商品时,突然想起来去 V2EX 看看技术讨论。打开新标签,访问 v2ex.com。

这时候,很关键的事情发生了:当你的浏览器向 v2ex.com 发送请求时,它仍然会自动在请求里带上淘宝的 Cookie

等等,这是咋回事?跨域请求,浏览器不是应该有"同源策略"的吗?

别急,这里有个细节:同源策略主要是限制 JavaScript 读取跨域的响应内容,但它不禁止浏览器在跨域请求中自动带上 Cookie。这就像你在路上,虽然陌生人看不到你钱包里的钱(跨域限制),但你自己的钱包仍然会跟着你走。

这个"自动带 Cookie"的设计,当初是为了用户体验。比如你在论坛发帖后刷新,浏览器需要自动认证你的身份,不然每次都得重新登录。很合理,对吧?

但这就埋下了 CSRF 的种子。

CSRF 攻击的真面目:你的浏览器成了帮凶

现在考虑一个场景。恶意网站 evil.com 的页面上,有这么一行代码:

代码语言:javascript
复制
<img src="https://bank.com/api/transfer?amount=10000&to=attacker" />

一个图片标签而已。当你访问 evil.com 时,浏览器会尝试加载这张图片。虽然这个"图片"其实是一个转账请求。

关键来了:浏览器会自动在这个请求里带上你在 bank.com 的 Cookie。服务器看到一个合法的 Session ID,就认为这是你本人在操作。

代码语言:javascript
复制
你的浏览器              evil.com              bank.com
    │                     │                       │
    ├──访问────────────>  │                       │
    │                     │                       │
    │        <img src="bank.com/transfer">        │
    │                     │                       │
    │    尝试加载图片     │                       │
    │                     ├──GET /transfer?...───>│
    │                     │ (自动附带 Cookie)    │
    │                     │                       │
    │                     │                  [验证 Cookie]
    │                     │                  [执行转账]
    │                     │<──200 OK────────────│
    │                     │                       │

转账完成了,而且用户可能根本不知道发生了什么。

这就是 CSRF 的本质:不是服务器的认证有问题,也不是用户密码被破解,而是浏览器在跨域请求中自动提交 Cookie 这个"特性",被恶意利用了。

为什么说 CSRF 不是漏洞,是"特性"?

漏洞通常指的是代码的 bug。但 CSRF 不是 bug,浏览器的自动 Cookie 提交正常工作着。问题在于这个设计本身与现代 Web 的安全需求产生了矛盾。

换个角度想:如果浏览器禁止所有跨域请求自动带 Cookie,那很多合法的场景(比如第三方支付回调、OAuth 回跳)就会破裂。但如果允许自动带,就打开了 CSRF 的大门。

这就是困局。浏览器的设计者早就知道这个风险,但没法简单地"关闭"这个特性,因为生态里太多场景依赖它了。

CSRF Token:用"秘密"来对抗"自动"

既然浏览器会自动提交 Cookie,那咱们就加一层防护:额外的秘密信息

这个秘密就是 CSRF Token。核心思路是:

Cookie 可以被自动提交,但只有我服务器知道的秘密 Token 不行。恶意网站看不到这个秘密,所以无法伪造完整的请求。

具体怎么实现?先看服务端生成 Token:

代码语言:javascript
复制
import express from"express";
import crypto from"crypto";
import session from"express-session";

const app = express();
app.use(express.json());
app.use(
  session({
    secret: "super-secret-key",
    resave: false,
    saveUninitialized: true
  })
);

// 生成随机 Token
function generateCSRFToken() {
return crypto.randomBytes(32).toString("hex");
}

// 每个会话生成一个 Token,存在服务端
app.use((req, res, next) => {
if (!req.session.csrfToken) {
    req.session.csrfToken = generateCSRFToken();
  }
  next();
});

服务器通过 API 把这个 Token 发给客户端(注意,不是通过 Cookie,而是通过 JSON 或 HTML):

代码语言:javascript
复制
app.get("/api/csrf-token", (req, res) => {
  res.json({ csrfToken: req.session.csrfToken });
});

客户端拿到 Token,存在内存里,然后在发送"有状态操作"(POST、PUT、DELETE)时,把 Token 放到请求头里:

代码语言:javascript
复制
let csrfToken = null;

asyncfunction fetchCSRFToken() {
const response = await fetch("/api/csrf-token");
const data = await response.json();
  csrfToken = data.csrfToken;
}

await fetchCSRFToken();

asyncfunction transferMoney(amount, to) {
const response = await fetch("/api/transfer", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-CSRF-Token": csrfToken  // 关键:Token 在请求头里
    },
    body: JSON.stringify({ amount, to })
  });
return response.json();
}

服务端验证这个 Token:

代码语言:javascript
复制
function verifyCSRF(req, res, next) {
const token = req.headers["x-csrf-token"];

if (!token || token !== req.session.csrfToken) {
    return res.status(403).json({ error: "Invalid CSRF token" });
  }
  next();
}

app.post("/api/transfer", verifyCSRF, (req, res) => {
const { amount, to } = req.body;
console.log(`Transfer ${amount} to ${to}`);
  res.json({ success: true });
});

为什么这样就安全了?

代码语言:javascript
复制
恶意网站 evil.com                 bank.com
    │                              │
    ├─ 尝试伪造请求               │
    │  (有 Cookie,但没 Token)     │
    │                              │
    ├──POST /transfer──────────>  │
    │  Headers: {                  │
    │    Cookie: session=xxx,      │ ← 自动提交
    │    X-CSRF-Token: ???         │ ← 缺少正确的 Token
    │  }                           │
    │                          [验证失败]
    │                          [拒绝请求]
    │<──403 Forbidden─────────────│

恶意网站可以让浏览器自动带上 Cookie,但它看不到页面内容(同源策略),所以无法读取 Token 的值。没有正确的 Token,请求就会被拒绝。

另一个防线:双重提交 Cookie

还有一种防护思路,叫"双重提交"(Double Submit Cookie)。它的逻辑是:

我在 Cookie 里放一个随机值,同时也要求你在请求体或请求头里带上同一个值。如果两者都对上了,说明这个请求来自真正的用户。

为什么这样也能行?因为恶意网站即使能让浏览器自动提交 Cookie,但它写不了 Cookie(受同源策略限制)。所以恶意网站无法伪造那个额外的值。

这是怎么实现的:

代码语言:javascript
复制
app.get("/login-success", (req, res) => {
const csrfToken = generateCSRFToken();

// 在 Cookie 里存一份 Token
  res.cookie("csrfToken", csrfToken, {
    httpOnly: false,  // 注意:必须允许 JS 读取
    sameSite: "Strict"
  });

// 同时也返回给客户端
  res.json({ csrfToken });
});

客户端读取 Cookie,然后在请求时也提交这个值:

代码语言:javascript
复制
function getCookie(name) {
const cookies = document.cookie.split(";");

for (const cookie of cookies) {
    const [key, value] = cookie.trim().split("=");
    if (key === name) {
      return value;
    }
  }
returnnull;
}

asyncfunction updateProfile(data) {
const token = getCookie("csrfToken");
const response = await fetch("/api/profile", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-CSRF-Token": token
    },
    body: JSON.stringify(data)
  });
return response.json();
}

服务端验证:

代码语言:javascript
复制
function verifyDoubleSubmit(req, res, next) {
  const cookieToken = req.cookies.csrfToken;
  const headerToken = req.headers["x-csrf-token"];
  
  if (!cookieToken || cookieToken !== headerToken) {
    return res.status(403).send("CSRF validation failed");
  }
  next();
}

这个方案的好处是不需要服务端维护 Token 状态,缺点是 Cookie 要暴露给 JavaScript(设置 httpOnly: false),稍微增加了 XSS 的风险。

SameSite Cookie:浏览器层面的防护

Google 等大公司后来意识到,与其靠应用层的 Token,不如从浏览器层面彻底改变 Cookie 的行为。

这就是 SameSite 属性。它告诉浏览器:只有在同一站点的请求中,才自动提交这个 Cookie。跨站请求就别带了。

代码语言:javascript
复制
res.cookie("sessionId", "abc123", {
  httpOnly: true,
  secure: true,
  sameSite: "Strict"  // ← 关键
});

SameSite 有三个值:

行为

安全性

Strict

任何跨站请求都不带 Cookie

最安全,但可能破坏合法场景

Lax

只有顶级导航(比如链接跳转)才带 Cookie,POST 等请求不带

平衡点

None

所有跨站请求都带 Cookie

最不安全,但某些场景必需

现代浏览器的默认行为逐渐从 SameSite=None 转向 SameSite=Lax。这意味着单靠浏览器的演进,CSRF 风险已经大幅降低。

但这也说明了一个问题:CSRF 防护需要多个层面的配合——应用层(Token)、HTTP 层(SameSite)、框架层(中间件自动化)。没有一个银弹。

困局的两面性

到这里,咱们看到了整个防护体系。但问题还没完全解决,因为存在一些现实的制约:

  1. OAuth 回调的困境 OAuth 的一个常见流程是:用户在第三方登录后,服务器向你的回调 URL 发起 POST 请求,带上授权码。如果你强制 SameSite=Strict,这个回调就会失败。所以很多应用被迫使用 SameSite=Lax 甚至 None。
  2. 遗留系统的包袱 很多老应用根本没有实现 CSRF Token,改造成本太高。而且用户会在客户端禁用这些防护(比如关闭 Cookie),导致防护形同虚设。
  3. API 时代的新问题 现代前端是 SPA(单页应用)或 React/Vue,请求通过 JavaScript 发送。这些框架通常会自动从 Cookie 或 LocalStorage 里读取 Token。但如果前端的某个地方出现了 XSS 漏洞,那 Token 也会被盗。此时 CSRF Token 和 XSS 防护就混在一起了。

这就是为什么我说 CSRF 是个"困局"。不是因为防护方案不存在,而是:

  • 完全的防护需要跨越多个层面(浏览器、应用、框架)的配合
  • 任何一层的疏漏,都可能导致整个防护体系崩塌
  • 不同的应用场景对防护的需求不一致,难以有一刀切的解决方案

现代应用的最佳实践

那在 2026 年,咱们到底该怎么做?

我的建议是 分层防护

第一层:浏览器默认

设置 SameSite=Strict 或 Lax(取决于你是否需要跨站 Cookie 传递)。这是成本最低的防护。

代码语言:javascript
复制
// 针对认证 Cookie
res.cookie("session", sessionId, {
  httpOnly: true,
  secure: true,
  sameSite: "Strict"  // 默认就这么设
});

第二层:应用层 Token

对于会改变数据的操作(POST、PUT、DELETE),都要验证 CSRF Token。这可以通过中间件自动化。

在 Express 生态里,直接用 csurf 中间件:

代码语言:javascript
复制
import csurf from "csurf";
import cookieParser from "cookie-parser";

app.use(cookieParser());
const csrfProtection = csurf({ cookie: true });

app.get("/form", csrfProtection, (req, res) => {
  res.send(`
    <form action="/submit" method="POST">
      <input type="hidden" name="_csrf" value="${req.csrfToken()}">
      <input type="text" name="data">
      <button type="submit">Submit</button>
    </form>
  `);
});

app.post("/submit", csrfProtection, (req, res) => {
  res.send("Form submitted safely");
});

对于 SPA,在 HTTP 客户端层面集中处理 Token:

代码语言:javascript
复制
async function apiRequest(url, options = {}) {
const token = await getCSRFToken();

const headers = {
    "Content-Type": "application/json",
    "X-CSRF-Token": token,
    ...(options.headers || {})
  };

const response = await fetch(url, {
    ...options,
    headers,
    credentials: "include"// 带上 Cookie
  });

if (!response.ok) {
    thrownewError("Request failed");
  }

return response.json();
}

// 所有 POST、PUT、DELETE 都用这个函数
apiRequest("/api/transfer", {
method: "POST",
body: JSON.stringify({ amount: 1000, to: "account123" })
});

第三层:安全意识

定期审查自己的应用是否有 XSS 漏洞(因为 XSS 会破坏 Token 防护),是否正确地实现了认证逻辑,是否有其他的状态改变操作被遗漏了。

总结

CSRF 不是漏洞,是浏览器为了便利用户而做的"特性"。这个特性在现代 Web 安全的背景下,成了一个困局。

我们无法通过禁用自动 Cookie 提交来根本解决(因为生态依赖它),所以只能在应用层、HTTP 层、框架层加多道防线。

如果你现在还在纠结"要不要加 CSRF Token",答案是:必须要。不仅要有 Token,还要有 SameSite,还要有定期的安全审计。

这不是完美的解决方案,但在当下的 Web 生态里,这是我们能做到的最好的。


🤔 思考题

你在自己的应用里实现过 CSRF Token 吗?如果有,是用会话存储还是双重提交方案?如果没有,是因为框架自动化了,还是确实没重视?

或者你踩过什么安全方面的坑?评论区分享一下,咱们一起讨论 👇

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-03-14,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 前端达人 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • Cookie 自动提交:浏览器的"贴心"设计
  • CSRF 攻击的真面目:你的浏览器成了帮凶
  • 为什么说 CSRF 不是漏洞,是"特性"?
  • CSRF Token:用"秘密"来对抗"自动"
  • 另一个防线:双重提交 Cookie
  • SameSite Cookie:浏览器层面的防护
  • 困局的两面性
  • 现代应用的最佳实践
    • 总结
    • 🤔 思考题
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档