做后端或者做前端的人早晚都会在一个地方卡住某个 POST 接口的请求在本地跑得好好的一上测试环境就被拦成 403或者反过来安全扫描报告里给这个接口打上一个红色的csrf标记。这两个场景我这些年都碰过不少次前者多半是自己写的令牌校验逻辑漏了某条路径后者往往是某个老接口从设计之初就没考虑过请求来源的问题。csrf 这件事的特殊之处在于它不是一个“装个组件就完事”的通用漏洞而是和你整个站点的会话机制、跨域策略、Cookie 属性乃至页面缓存策略都咬合在一起的系统性问题。这篇文章想做的事很明确把 csrf 的攻击链条拆开讲透把几种主流防御方案的取舍逻辑摆到台面上再给出一套可以照着抄的落地实现和一份排查清单。不管你是刚接手一个老项目的后端开发还是在做前端联调时被 403 反复折腾的工程师或者只是想在下次安全评审里能说清楚“为什么我们选这个方案”下面的内容应该都能接得住。1. csrf 到底在防什么从一次“莫名其妙”的操作说起csrfCross-Site Request Forgery跨站请求伪造这个名字容易让人误解成“伪造了一个请求内容”其实它伪造的不是请求参数而是请求的发起人身份。攻击者并不需要知道用户的密码也不需要窃取用户的任何凭证他只需要做一件事让已经登录了目标站点的浏览器去发出一个用户本人并不知情的请求。浏览器有个沿用了几十年的行为习惯——只要请求的目标域名匹配它就会自动把该域名下的 Cookie 一并带上。这个设计在单站点时代非常方便但在多站点并存的今天它等于给攻击者留了一扇门门禁卡一直挂在用户身上攻击者只要想办法把用户忽悠到某扇门前门自己就开了而门卫只认卡不认人。我见过一个特别典型的场景某公司内部的一个管理系统删除数据的接口是POST /api/item/delete鉴权完全依赖会话 Cookie。有个员工在登录状态下点开了一封邮件里的链接那个页面里藏着一个自动提交的表单表单的 action 指向这个删除接口参数也填好了。页面加载的瞬间浏览器带着他的会话 Cookie 把这个请求发了出去服务端收到的是一个“合法”的请求数据就这么没了。整个过程中没有任何漏洞利用代码没有恶意脚本被执行只有一个 HTML 表单。1.1 攻击链条拆解三个条件同时成立才会出事把上面这个过程拆细一点csrf 能够成立必须同时满足三个条件缺一个都不行。这三个条件也正好对应了防御的三个切入点理解它们比记住任何一个具体方案都重要。条件一用户处于已登录状态且身份凭证会被浏览器自动携带。主流做法是 Cookie 里存 session id只要Domain匹配、Path匹配、Expires没过期浏览器就会带上。如果身份凭证是放在 JavaScript 内存里、手动塞进Authorization请求头的浏览器不会自动带攻击者就发不出有效请求——这也是为什么纯 Token 鉴权Bearer 模式天然对 csrf 免疫。条件二攻击者能够让浏览器向目标站点发起请求。这一点被很多人低估。同源策略限制的是“一个源读取另一个源的响应内容”它并不限制“一个源向另一个源发送请求”。form提交、img src、script src、link relstylesheet、iframe、window.open这些全都可以跨站发起。所以“跨域读不到响应”不等于“跨域发不出请求”。条件三服务端仅凭 Cookie 判定身份不校验请求来源或用户意图。这是最容易被忽视的一环。很多接口既不校验Origin/Referer也没有一次性令牌服务端看到会话有效就直接执行了它没有任何办法区分这个请求是用户自己点的还是别的页面替他点的。注意第三个条件里“不校验用户意图”这句话值得多想一层。即使加了Origin校验如果一个接口本身不幂等、且允许被简单的 GET 请求触发那风险依然存在。防御的第一优先级其实应该是“让所有写操作都不接受 GET”这一条不花钱收益却最大。1.2 为什么同源策略挡不住 csrf刚接触这个概念的人十个里有八个会问既然浏览器有同源策略为什么攻击者还能发请求答案在于同源策略的目标是保护数据不被跨站读取而不是阻止跨站通信。想象一下同源策略是一座图书馆的规则它规定你不能去别的阅览室拿书来看但它不禁止你往别的阅览室的还书箱里丢一本书。csrf 利用的正是后者——攻击者往目标站点的“还书箱”里丢一个请求不需要看结果。事实上攻击者根本不需要知道响应是什么只要服务端把操作执行了他的目的就达成了。还有一个常见误区是“我用的是 POST 加 JSON 提交JS 跨域发不出去所以安全”。这个判断只对了一半。浏览器确实会对Content-Type: application/json的跨域请求触发预检preflight而目标站点如果没有正确放开 CORS预检会失败请求发不出去。但这有一个大前提服务端只接受application/json。很多框架是宽容的会同时接受application/x-www-form-urlencoded、multipart/form-data、text/plain这三种“简单请求”的 Content-Type而这三个恰恰是跨站表单和fetch在no-cors模式下可以直接发出的。我在做安全评审时有一条固定动作把接口的 Content-Type 白名单拉出来看一眼只要有text/plain在列这个接口就要按“可被 csrf”来对待。1.3 和 XSS 的区别别把两件事混成一件csrf 和 XSS 经常被一起提但它们的防线完全不同。XSS 是攻击者的脚本跑在了你的站点里它拥有和你完全一样的权限能读页面、能读 Cookie如果没设 HttpOnly、能发任意请求。csrf 则是攻击者的代码跑在他自己的站点上只是借用了受害者的浏览器来“代办”一次请求。这个区别决定了一件事csrf 令牌对 XSS 没用。如果站点已经存在 XSS攻击者可以直接读走页面里的令牌再发请求令牌形同虚设。所以 csrf 防护从来不是“有了令牌就安全了”它是建立在“站点没有 XSS”这个前提上的。反过来说如果你的站点有一个存储型 XSS那你要修的首先是 XSS而不是往所有接口上堆令牌。2. 防御方案选型为什么令牌是主力SameSite 是底座在过去这些年里csrf 防御方案其实没有大的变化真正变化的是浏览器的默认行为——Cookie 的SameSite属性默认值从None变成了Lax这一改动直接让很多老站点“被动”获得了一层防护。但这并不意味着可以躺平因为Lax的语义是“顶层导航的 GET 请求带 Cookie跨站 POST 不带”它挡的是跨站 POST挡不住同站不同子域、也挡不住那些确实需要跨站 POST 的合法业务场景。所以我的基本判断是SameSite是必须配的底座但它不能作为唯一防线。2.1 五种主流方案的横向对比先上一张表把几种方案放在一起过一遍。这张表是我自己在做技术选型时用的维度是按“能不能扛住真实攻击”和“维护成本”两个角度切的不是按教科书上的分类切的。方案防护强度对老环境兼容前后端分离友好度主要短板仅校验 Referer/Origin中较好好部分隐私设置会剥离 RefererOrigin在部分 GET 请求中不带CDN 改写可能导致误判同步令牌Session 存储高好一般需要服务端有状态分布式部署要共享存储双提交 Cookie令牌存 Cookie中高好好子域可写 Cookie 导致“Cookie 投毒”绕过必须加签名自定义请求头校验中好好单独用不够本质是“跨站带不上自定义头”这一特性的利用SameSite 属性中一般老浏览器忽略好无法覆盖同站子域场景跨站合法跳转需额外处理从表里能看出来同步令牌是防护强度最高的一档代价是要在服务端维护状态。而双提交 Cookie之所以被大量单页应用采用是因为它不需要服务端存储但它有一个非常隐蔽的坑后面第 4 节会专门讲。至于Origin/Referer校验我的定位是“兜底层”而不是“主防线”。它实现成本极低一个中间件几十行就够能拦掉 90% 的低成本扫描器。但它不能作为唯一手段原因很实在Referer会因为用户的隐私设置、企业代理、图片加载策略等原因丢失你如果配成“缺失就拒绝”线上一定会有零星用户报 403配成“缺失就放行”那攻击者只要让请求不带Referer就绕过了。2.2 同步令牌与双提交 Cookie到底怎么选这两个方案的选择本质上是在回答一个问题你的服务端有没有地方存会话状态如果答“有”——比如已经在用 Redis 存 session或者服务本身是单体应用、token 就挂在 session 对象上——那毫不犹豫选同步令牌。它的逻辑最干净令牌是服务端生成的随机串和当前会话绑定用户提交时带回来服务端比对一下。攻击者的页面拿不到这个令牌因为同源策略不让他读所以他发不出合法请求。分布式部署时把令牌跟着 session 一起放 Redis 就行成本几乎为零。如果答“没有”——典型的无状态 API 网关、或者服务端只认 JWT、不想引入 Redis——那可以用双提交 Cookie。它的思路是服务端下发一个随机令牌同时写进 Cookie 和页面里用户提交时把页面里的令牌放进请求头或表单字段服务端比对“Cookie 里的”和“请求里带的”是否一致。因为攻击者能自动带上 Cookie条件一但他读不到 Cookie 的值同源策略所以他能带上一半带不上另一半比对就失败了。提示双提交 Cookie 有一个必须做的加固——给令牌加签名。因为 Cookie 是可以被同级或上级域名写入的如果一个攻击者控制着a.example.com他完全可以往.example.com上写一个自己知道的 Cookie 值然后让受害者带着这个值去访问www.example.com两边一对就通过了。加签名的做法是服务端用密钥对令牌做 HMAC比对时先验签这样即使攻击者能写 Cookie也造不出合法签名。我个人的偏好是只要是“浏览器 会话 Cookie”这种组合优先同步令牌。双提交 Cookie 看似省事但它把安全性建立在“Cookie 不能被第三方写入”这个不太牢靠的假设上一旦站点有任意一个子域被拿下防线就塌了。而这个风险在稍有规模的项目里是真实存在的——多业务线共用一个主域的情况太常见了。3. 落地实现一套可复用的令牌方案下面这部分是我在多个项目里反复用过的结构语言上用 Python 和 Node 的伪代码示意换成任何框架思路是一样的。3.1 令牌的生成、存储与生命周期先生成。令牌必须是密码学安全的随机数不能用时间戳、不能用自增 ID、不能用Math.random()。长度上 32 字节的随机数转成十六进制是 64 个字符足够了。import secrets, hmac, hashlib def generate_csrf_token(session_id: str, secret: bytes) - str: raw secrets.token_hex(32) # 32 字节随机 - 64 字符 sig hmac.new(secret, raw.encode(), hashlib.sha256).hexdigest()[:16] return f{raw}.{sig} # 令牌自带签名防篡改 def verify_csrf_token(token: str, secret: bytes) - bool: try: raw, sig token.rsplit(., 1) except ValueError: return False expect hmac.new(secret, raw.encode(), hashlib.sha256).hexdigest()[:16] return hmac.compare_digest(sig, expect) # 恒定时间比较别用 关于生命周期有两个策略要选清楚。第一个是令牌粒度会话级还是请求级。请求级令牌每个表单一个、用完即焚安全性最高能防重放但它会破坏多标签页——用户在两个标签页里各开一个表单其中一个提交后令牌失效另一个必然失败。我的做法是默认用会话级令牌只在极敏感操作改密码、转账、删除账号上叠加一层“二次确认”重新输密码或者短信验证码这样既兼顾体验又把高风险动作单独拎出来了。第二个是过期与轮换。令牌跟着 session 走session 失效令牌跟着失效这是基础。但有一件事必须做登录成功后重新生成令牌。如果登录前后令牌不变就可能被会话固定攻击利用。同理登出时要把服务端的令牌记录清掉。// 会话级令牌的存取示意Node 环境 const tokenStore new Map(); // 生产环境换成 Rediskey 用 sessionId function issueToken(sessionId) { const token crypto.randomBytes(32).toString(hex); tokenStore.set(sessionId, { token, expireAt: Date.now() 2 * 60 * 60 * 1000 }); return token; } function checkToken(sessionId, submitted) { const rec tokenStore.get(sessionId); if (!rec || rec.expireAt Date.now()) return false; // 长度不同直接返回避免 compare 抛错 if (submitted.length ! rec.token.length) return false; return crypto.timingSafeEqual(Buffer.from(submitted), Buffer.from(rec.token)); }3.2 前端怎么拿到令牌、怎么带回去这一步是前后端联调最容易出问题的地方我把几种方式列一下并说明为什么推荐其中一种。渲染进表单隐藏域传统服务端渲染页面最省事模板里直接input typehidden name_csrf value{{ token }}提交时表单自动带上。缺点是单页应用用不了。渲染进 meta 标签meta namecsrf-token content...JS 启动时读一次缓存在内存里所有请求通过拦截器统一塞进请求头。这是我现在最常用的方式。单独开一个接口下发GET /api/csrf返回令牌。灵活但多一次请求且要注意这个接口本身不能被缓存成公共内容。放在 Cookie 里让 JS 读这就是双提交的路子前面说过它的问题不推荐作为首选。带回去的位置首推自定义请求头比如X-CSRF-Token。原因很直接跨站的表单提交和no-cors请求都加不上自定义请求头这一条本身就已经把绝大多数攻击路径堵死了。相比之下放在 URL 参数里是最差的选择因为 URL 会进日志、进Referer、进浏览器历史泄漏面太大。// 统一拦截器读一次全局复用 const meta document.querySelector(meta[namecsrf-token]); const CSRF meta ? meta.getAttribute(content) : ; async function request(url, options {}) { const headers { Content-Type: application/json, ...(options.headers || {}), }; // 只对写操作带令牌GET 没必要也避免缓存被污染 if (options.method ![GET, HEAD, OPTIONS].includes(options.method.toUpperCase())) { headers[X-CSRF-Token] CSRF; } return fetch(url, { ...options, headers, credentials: same-origin }); }提示credentials这个参数一定要显式写。fetch的默认值是same-origin看起来没问题但如果后端和前端不同源比如分两个子域部署就必须改成include否则 Cookie 根本不带你会以为是令牌校验失败其实是身份都没传过去。3.3 服务端统一拦截别在每个接口里手写令牌校验一定要做成中间件而不是散落在各个 handler 里。我接手过一个项目二十多个接口里有十七个手写了校验剩下五个漏了其中两个是删除接口。散写必然有遗漏这是规模问题不是态度问题。中间件的基本逻辑是三步判断当前请求是否需要校验 → 取令牌 → 比对。第一步最关键“是否需要校验”的判断条件建议按下面的顺序写只对非安全方法校验GET、HEAD、OPTIONS直接放行。但前提是这些方法确实没有副作用。白名单路径放行登录、注册、健康检查、静态资源。白名单要写在配置文件里不要硬编码在代码里方便审查。第三方回调放行但必须改用签名验证支付回调、消息推送这类接口天然不可能带你的令牌也不该带它们的安全靠的是签名加时间戳加重放窗口。其余一律校验。SAFE_METHODS {GET, HEAD, OPTIONS} WHITELIST {/api/login, /api/register, /healthz} def csrf_protect(req, handler): if req.method in SAFE_METHODS: return handler(req) if req.path in WHITELIST: return handler(req) if req.path.startswith(/callback/): return verify_signature(req) and handler(req) submitted req.headers.get(X-CSRF-Token) or req.form.get(_csrf, ) session_id req.cookies.get(sid) if not session_id or not submitted or not check_token(session_id, submitted): return json_response({code: 403, msg: invalid csrf token}, status403) return handler(req)顺便说一句 Cookie 的属性配置这是经常被忽略但收益极高的一项Set-Cookie: sidxxxx; Path/; HttpOnly; Secure; SameSiteLaxHttpOnly让 JS 读不到会话 Cookie降低 XSS 后的损失Secure保证只走加密通道SameSiteLax让跨站 POST 不带 Cookie。这三项加起来等于在没有写一行业务代码的情况下先垫了一层基础防护。4. 边界场景与常见坑那些“看起来防住了”的情况方案上线之后真正让人头疼的不是攻击而是误伤和绕过并存。下面这些坑绝大多数我都实际踩过或者帮别人排查过。4.1 缓存把令牌变成了公共物品这个坑我第一次遇到的时候排查了大半天。现象是所有用户都能正常登录、正常浏览但一到提交表单就随机失败而且失败率不高大概百分之一二。查了半天网关日志发现不同用户提交上来的令牌居然是一样的。根因是页面被 CDN 缓存了。服务端渲染的页面里嵌了用户专属的令牌但响应头没设Cache-Control: no-storeCDN 认为是静态页面就缓存了第一个用户的版本后面所有用户拿到的都是同一个令牌。服务端按 session 去比对自然对不上。修法有两个层面一是页面响应加Cache-Control: no-store, private二是如果一定要缓存页面外壳就把令牌改成通过独立接口异步获取那个接口单独设成不可缓存。我现在的习惯是凡是响应体里带用户身份信息的页面一律显式加no-store不做例外。4.2 单页应用刷新后令牌失效典型表现用户在页面上待一会儿点提交返回 403刷新一下再提交就好了。这通常不是令牌过期而是令牌只在应用初始化时取了一次之后 session 轮换或者服务端重启导致内存里的令牌丢了前端还拿着旧的。如果服务端把令牌存在进程内存里不是 Redis多实例部署时这个问题会变成“时而成功时而失败”因为请求被负载均衡打到了不同实例上。我的处理方式是三点令牌存共享存储前端拦截到 403 且错误码是令牌失效时自动拉一次新令牌并重试一次只重试一次避免死循环服务端令牌过期时间不要设得太短两小时起步配合滑动续期。4.3 子域之间的 Cookie 投毒前面提过双提交 Cookie 的这个风险放到表格里更清楚。现象根因处理方式双提交方案下令牌校验“总是能过”攻击者从可控子域往主域写了 Cookie两边值一致令牌加 HMAC 签名服务端验签主域下某子域被拿下后整个站点失守会话 Cookie 设在.example.com会话 Cookie 的Domain尽量收窄敏感业务用独立域老浏览器忽略 SameSite浏览器版本过低不能只依赖 SameSite必须有令牌兜底4.4 那些“不该带令牌”的接口这部分经常被写成事故。三类接口要特别注意文件上传multipart/form-data的表单提交走的是简单请求路径如果前端用form直传而没有走 JS令牌要么放在隐藏域里要么就根本带不上。统一改用 JS 配合FormData上传把令牌塞进请求头最省心。Webhook 与第三方回调不能带、也不该带你的令牌改用“签名 时间戳 重放窗口”的组合验证。时间戳窗口建议 5 分钟超过就拒。极高频的埋点与日志上报这类接口大量走navigator.sendBeacon带不上自定义头通常直接放进白名单。但要确认它们真的没有副作用只是写日志。4.5 一个自查清单这张表我放在项目文档里每次发版前扫一眼能挡掉大部分回归问题。检查项期望结果常见错误所有写操作方法是否只接受 POST/PUT/PATCH/DELETE是用 GET 做删除、审核是否有接口接受text/plain或通配 Content-Type否框架默认宽容解析登录成功后令牌是否轮换是沿用旧令牌带令牌的页面是否可被公共缓存否漏设no-store令牌是否出现在 URL 中否图省事拼在查询串令牌校验是否恒定时间比较是用逐字符比CORS 是否配置了具体来源而非通配是*配合credentials被误设登出后服务端令牌记录是否清理是只清了 Cookie注意CORS 那一条要多说一句。Access-Control-Allow-Origin: *在响应带credentials的情况下浏览器会直接拒绝所以正规项目不会出现这个配置。真正危险的是“反射 Origin”——服务端把请求里的Origin原样回写。这种配置看起来兼容性最好实际上等于对所有来源开放一旦同时开了credentials攻击者的页面就能读到你的响应了。这一条我在评审里见过不止一次。5. 验证与回归怎么证明你的防护真的生效写完代码不等于防住了。csrf 防护的一个特点是它失效的时候往往不报错只是静默地不生效。中间件里判断条件写反了、白名单范围开大了、令牌比对永远返回 true这些在正常运行下都看不出来只有主动去攻击才能发现。5.1 手工验证自己当一次攻击者最直接的验证方法是构造一个跨站页面模拟攻击者能做的事。在本机起一个不同端口的静态服务端口不同就算不同源写一个 HTML!-- 攻击者页面模拟跨站提交 -- form idf actionhttp://target-site.local/api/item/delete methodPOST input nameid value123 /form script document.getElementById(f).submit(); // 自动提交用户无感 /script先在目标站点登录然后访问这个页面。如果接口返回 403 或者操作没有生效说明防线有效如果操作真的执行了那就要回去看白名单是不是开大了。再测一轮“带自定义头”的情况用 fetch 试试fetch(http://target-site.local/api/item/delete, { method: POST, credentials: include, headers: { Content-Type: application/json }, body: JSON.stringify({ id: 123 }) });这一发大概率会被预检拦下返回的会是 CORS 错误而不是 403。这也是为什么我一直强调——别把 CORS 当成 csrf 防线它挡住的是“读响应”而真正的防护要靠服务端校验令牌。你要确认的是即使预检通过了比如服务端确实对某个来源放开了 CORS令牌校验这一关还能不能拦住。5.2 自动化回归把校验写进 CI手工测一次容易长期守住难。我的做法是在 CI 里加一组“负向用例”每次发版跑一遍。核心就三条不带令牌的写请求应返回 403。带错误令牌的写请求应返回 403。用 A 会话的令牌配 B 会话的 Cookie应返回 403这条能验证令牌是会话绑定的不是全局的。# 负向用例示意 curl -s -o /dev/null -w %{http_code} -X POST http://localhost:8000/api/item/delete \ -b sid$SESSION_A -d id1 # 期望 403 curl -s -o /dev/null -w %{http_code} -X POST http://localhost:8000/api/item/delete \ -b sid$SESSION_A -H X-CSRF-Token: $TOKEN_B -d id1 # 期望 403除了 CI线上还需要一个监控信号403 中“令牌无效”这一类的占比。正常运行时它应该是一个很低且平稳的数字如果突然抬升通常意味着两件事之一——前端缓存策略出了问题导致令牌串了或者真的有人在扫。这时候去看对应时间段的Referer分布能快速区分开。5.3 上线顺序别一次性全打开如果是在一个存量系统上补这层防护我不建议一次性把所有接口都切到强校验风险太大。我自己用过的节奏是三批第一批先上“只记录不拦截”的模式把所有本该被拦的请求记进日志跑一周看看有多少误伤、都来自哪些路径第二批把误伤清干净正式开启拦截但保留一个可回滚的开关第三批再加SameSite和Origin校验这些附加层。这个节奏的好处是任何一次出问题都能定位到具体是哪一层引起的而不是面对一堆 403 无从下手。最后分享一个我自己的习惯。每次接手一个新项目我会先不用任何工具直接打开浏览器开发者工具登录之后找一个写操作的接口把它复制成 curl然后把 Cookie 和令牌都删掉发一次。如果它返回 200 并且数据真的变了那我就知道这个项目的防护是从头补起的如果返回 403我会再试着把令牌加回去但换一个用户的 Cookie看会不会过。这两步花不了五分钟但比读一遍安全文档更能让我心里有数。csrf 这事的麻烦之处在于它没有“报错”你不主动去扮演一次攻击者永远不知道那扇门到底锁没锁。