ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

CSRF防御实战:攻击链条、令牌校验与SameSite落地

CSRF防御实战:攻击链条、令牌校验与SameSite落地

做后端或者做前端的人,早晚都会在一个地方卡住:某个 POST 接口的请求在本地跑得好好的,一上测试环境就被拦成 403,或者反过来,安全扫描报告里给这个接口打上一个红色的csrf标记。这两个场景我这些年都碰过不少次,前者多半是自己写的令牌校验逻辑漏了某条路径,后者往往是某个老接口从设计之初就没考虑过请求来源的问题。csrf 这件事的特殊之处在于,它不是一个“装个组件就完事”的通用漏洞,而是和你整个站点的会话机制、跨域策略、Cookie 属性乃至页面缓存策略都咬合在一起的系统性问题。这篇文章想做的事很明确:把 csrf 的攻击链条拆开讲透,把几种主流防御方案的取舍逻辑摆到台面上,再给出一套可以照着抄的落地实现和一份排查清单。不管你是刚接手一个老项目的后端开发,还是在做前端联调时被 403 反复折腾的工程师,或者只是想在下次安全评审里能说清楚“为什么我们选这个方案”,下面的内容应该都能接得住。

1. csrf 到底在防什么:从一次“莫名其妙”的操作说起

csrf(Cross-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 rel=stylesheet>、<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中较好好部分隐私设置会剥离 Referer;Origin在部分 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(); // 生产环境换成 Redis,key 用 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 type="hidden" name="_csrf" value="{{ token }}">,提交时表单自动带上。缺点是单页应用用不了。
  • 渲染进 meta 标签:<meta name="csrf-token" content="...">,JS 启动时读一次,缓存在内存里,所有请求通过拦截器统一塞进请求头。这是我现在最常用的方式。
  • 单独开一个接口下发:GET /api/csrf,返回令牌。灵活,但多一次请求,且要注意这个接口本身不能被缓存成公共内容。
  • 放在 Cookie 里让 JS 读:这就是双提交的路子,前面说过它的问题,不推荐作为首选。

带回去的位置,首推自定义请求头,比如X-CSRF-Token。原因很直接:跨站的表单提交和no-cors请求都加不上自定义请求头,这一条本身就已经把绝大多数攻击路径堵死了。相比之下,放在 URL 参数里是最差的选择,因为 URL 会进日志、进Referer、进浏览器历史,泄漏面太大。

// 统一拦截器:读一次,全局复用 const meta = document.querySelector('meta[name="csrf-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 里。我接手过一个项目,二十多个接口里有十七个手写了校验,剩下五个漏了,其中两个是删除接口。散写必然有遗漏,这是规模问题,不是态度问题。

中间件的基本逻辑是三步:判断当前请求是否需要校验 → 取令牌 → 比对。第一步最关键,“是否需要校验”的判断条件建议按下面的顺序写:

  1. 只对非安全方法校验:GET、HEAD、OPTIONS直接放行。但前提是这些方法确实没有副作用。
  2. 白名单路径放行:登录、注册、健康检查、静态资源。白名单要写在配置文件里,不要硬编码在代码里,方便审查。
  3. 第三方回调放行,但必须改用签名验证:支付回调、消息推送这类接口天然不可能带你的令牌,也不该带,它们的安全靠的是签名加时间戳加重放窗口。
  4. 其余一律校验。
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"}, status=403) return handler(req)

顺便说一句 Cookie 的属性配置,这是经常被忽略但收益极高的一项:

Set-Cookie: sid=xxxx; Path=/; HttpOnly; Secure; SameSite=Lax

HttpOnly让 JS 读不到会话 Cookie,降低 XSS 后的损失;Secure保证只走加密通道;SameSite=Lax让跨站 POST 不带 Cookie。这三项加起来,等于在没有写一行业务代码的情况下,先垫了一层基础防护。

4. 边界场景与常见坑:那些“看起来防住了”的情况

方案上线之后,真正让人头疼的不是攻击,而是误伤和绕过并存。下面这些坑,绝大多数我都实际踩过或者帮别人排查过。

4.1 缓存把令牌变成了公共物品

这个坑我第一次遇到的时候排查了大半天。现象是:所有用户都能正常登录、正常浏览,但一到提交表单就随机失败,而且失败率不高,大概百分之一二。查了半天网关日志,发现不同用户提交上来的令牌居然是一样的。

根因是页面被 CDN 缓存了。服务端渲染的页面里嵌了用户专属的令牌,但响应头没设Cache-Control: no-store,CDN 认为是静态页面,就缓存了第一个用户的版本,后面所有用户拿到的都是同一个令牌。服务端按 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 id="f" action="http://target-site.local/api/item/delete" method="POST"> <input name="id" value="123"> </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 里加一组“负向用例”,每次发版跑一遍。核心就三条:

  1. 不带令牌的写请求应返回 403。
  2. 带错误令牌的写请求应返回 403。
  3. 用 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 'id=1' # 期望 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 'id=1' # 期望 403

除了 CI,线上还需要一个监控信号:403 中“令牌无效”这一类的占比。正常运行时它应该是一个很低且平稳的数字,如果突然抬升,通常意味着两件事之一——前端缓存策略出了问题导致令牌串了,或者真的有人在扫。这时候去看对应时间段的Referer分布,能快速区分开。

5.3 上线顺序:别一次性全打开

如果是在一个存量系统上补这层防护,我不建议一次性把所有接口都切到强校验,风险太大。我自己用过的节奏是三批:第一批先上“只记录不拦截”的模式,把所有本该被拦的请求记进日志,跑一周看看有多少误伤、都来自哪些路径;第二批把误伤清干净,正式开启拦截,但保留一个可回滚的开关;第三批再加SameSite和Origin校验这些附加层。这个节奏的好处是,任何一次出问题都能定位到具体是哪一层引起的,而不是面对一堆 403 无从下手。


最后分享一个我自己的习惯。每次接手一个新项目,我会先不用任何工具,直接打开浏览器开发者工具,登录之后找一个写操作的接口,把它复制成 curl,然后把 Cookie 和令牌都删掉发一次。如果它返回 200 并且数据真的变了,那我就知道这个项目的防护是从头补起的;如果返回 403,我会再试着把令牌加回去但换一个用户的 Cookie,看会不会过。这两步花不了五分钟,但比读一遍安全文档更能让我心里有数。csrf 这事的麻烦之处在于它没有“报错”,你不主动去扮演一次攻击者,永远不知道那扇门到底锁没锁。

返回列表