💸
想象这样一个场景: 你刚登录了 pay.example.com。另一个标签页里,evil.example 悄悄让你的浏览器发出一笔“确认付款”。你从头到尾没有主动发起过这笔付款,钱却可能已经被扣了。

你只是点开了一个链接,钱怎么就被扣了?

你在 pay.example.com 登录过。浏览器替你记住了一小段登录信息。之后每次再去这个网站,浏览器都会自动出示它,你不用重新登录。
后来你点开了一个链接,打开的是攻击者做的页面:evil.example。这个页面里藏了一个会自动提交的表单——不需要你再做任何操作,它就自己发出去了。目标是 pay.example.com 的“确认付款”地址。
浏览器做了一件“热心”的事:它发现这是发往 pay.example.com 的请求,就自动附上了你的登录信息。
付款服务器收到请求。它看到登录信息,确认是你的账号,也确认这个账号有付款权限。它没有多问一句“这真的是你本人想付的吗”,就执行了付款。
你没有输过密码,没有点过确认,甚至从头到尾没看到任何付款页面。你的钱已经付出去了。
把整个过程按时间顺序摊开,是这样的:
你没授权,钱却扣了。要查清楚,就得把这笔请求走过的路一环一环拆开:先查服务器凭什么认为这个请求是你授权的,再查浏览器为什么没拦住,最后看防线到底该怎么补。

服务器凭什么认为这个请求是你授权的?

答案藏在浏览器的一个“热心”习惯里:它像个替你保管门禁卡的管家,每次去 pay.example.com,都自动替你刷卡。它这么做不是被谁骗了,而是规则本来就是这样设计的。
这张“门禁卡”就是 Cookie。浏览器决定带不带某张卡时,会核对几个条件:
条件
它管什么
host-only / Domain
这张卡能在哪些主机使用;host-only 表示发卡时没指定范围,只在原主机有效
Path
能在哪些路径使用
Secure
只在加密连接里使用
HttpOnly
禁止页面脚本读这张卡,但不阻止浏览器送卡
SameSite
跨站请求里带不带这张卡
⚠️
浏览器的核对清单里没有这一项:它不问“发起请求的页面是谁、可不可信”。HttpOnly 也一样,它只是禁止页面脚本读取 Cookie 的值,并不阻止浏览器在条件满足时把它随请求发出去。
这种“请求自动借用你已有的登录状态,而发起页面根本不需要知道卡号”的机制,叫自动凭据,也叫环境权限(ambient authority)。故事里的攻击者从头到尾没偷到你的 Cookie。他只是让浏览器替他刷了一次你的卡。
上面这张表的最后一行 SameSite 值得展开。它是服务器通过 Set-Cookie 发卡时设置的属性,用来限制这张卡能不能在跨站请求里被带出去。这里的“站”指站点(Site),可以粗略理解为:协议加可注册域名。比如 https://app.example.comhttps://api.example.com 属于同一个站;https://evil.examplehttps://pay.example.com 则是两个站。精确算法还涉及公共后缀列表等细节,但不影响这里的分析。
SameSite 有三个取值:
取值
含义
Strict
只认自己站。 只有同站请求才带这张卡
Lax
你自己点过去的链接可以带;别的页面替你发的表单不带。 同站请求都带;跨站时只有“点链接让整个页面跳过去”才带,且仅限 GET 这类不改状态的方法
None; Secure
明确允许跨站携带。 同站跨站都可能带,但必须走加密连接,还受浏览器第三方 Cookie 策略约束
Lax 的精确条件是什么?
Lax 下,Cookie 随同站请求正常发送。跨站时只有一个例外:使用安全方法的顶层导航。“安全方法”指 GETHEAD 等按约定不改变服务器状态的方法。“顶层导航”指地址栏网址会变化的整页跳转,比如你点击链接、在地址栏输入网址;页面内部脚本发起的请求不算。部分浏览器还有一条短暂的兼容例外:刚设置不久的 Cookie,在很短的窗口期内可能仍被跨站 POST 携带,用来兼容旧的登录流程(这条例外多见于未显式设置 SameSite、被浏览器按默认 Lax 处理的 Cookie)。具体窗口和条件以 RFC6265bis 和各浏览器实现为准。
如果发卡时没写 SameSite 呢?现代浏览器通常按接近 Lax 的方式处理。但“通常”不是保证:不同浏览器、不同版本可能有差异,不能把这个默认值当成永久、统一的服务器安全策略。
到这里,故事里那个攻击终于有了名字:CSRF(Cross-Site Request Forgery,跨站请求伪造)。它利用的就是自动凭据:攻击者诱导已登录受害者的浏览器向目标站点发出非本人意愿的请求,而服务器把随请求到达的登录信息误当成了用户的真实意图。
现在清楚了:请求能带上你的身份,是因为浏览器会按规则自动送卡。但另一个疑问跟着来了——浏览器对陌生网站不是一直有隔离防护吗?它为什么没拦住 evil.example

浏览器不是有安全防护吗,为什么没拦住?

其实浏览器出手了。回头看时序图的最后一步:付款结果返回时,浏览器发现发起请求的是 evil.example,就把结果藏了起来,没给那个页面的脚本看。
这个防护机制叫同源策略(Same-Origin Policy,SOP):一个源里的脚本,默认读不到另一个源的数据。要理解它,先要知道浏览器怎么判断两个页面是不是“一家人”。判断依据叫源(Origin):协议、主机名、端口,三部分拼起来,路径不算在内。
两个地址相比
同源吗
原因
/account/history
同源
只有路径不同
httphttps
不同源
协议不同
pay.example.comapi.pay.example.com
不同源
主机不同,子域也不算
:443:8443
不同源
端口不同
注意别和上一节的“同站”混淆:
同站(Site)
只看协议和可注册域名。app.example.comapi.example.com 同站。
同源(Origin)
还要求主机、端口完全一致,严格得多。上面这对同站,却不同源。
关键是,SOP 划的是一条“读取边界”。它从来没说过“浏览器绝不向别的源发送请求”。点链接、加载图片、提交表单,这些日常操作本来就在跨源往外送信息。
这就形成了故事里那个关键的时间差:服务器先收到并处理请求,浏览器随后才决定要不要把响应给脚本看。就算最后的决定是“不给看”,也不会撤销服务器已经做完的付款。
浏览器确实防了,只是防的是“偷看”,不是“冒名操作”——你的钱照样被扣。

浏览器在“发”这一关,真的一点都不管吗?

也不全是。表单、导航这类传统载体,浏览器确实直接放行;但页面脚本用代码跨源发请求时,就进入另一套机制的地盘——跨源资源共享(Cross-Origin Resource Sharing,CORS)
说穿了,CORS 就是服务器通过响应头表态的一套机制:哪些源的浏览器脚本可以读我的响应,哪些更灵活的跨源请求可以先发出去。它不是身份认证,不是业务授权,也不是默认的 CSRF 防线。
浏览器把跨源 Fetch/XHR(页面脚本发请求的两种方式)分成两类。一类在浏览器眼里“天生无害”,正式名字叫 CORS 安全列出的请求(CORS-safelisted request):方法和请求头满足 Fetch 规范的 safelist(安全清单)。另一类超出清单,要先预检(preflight)
CORS 安全列出的请求
方法是 GETHEADPOSTContent-Type 通常只能是 application/x-www-form-urlencodedmultipart/form-datatext/plain;脚本能设的头也受限。
不先打招呼,实际请求直接发出;服务器再用 CORS 响应头决定脚本能不能读。
需要预检的请求
超出清单,比如 application/json、自定义请求头,或 PUTPATCHDELETE
先发 OPTIONS 问准不准;不准,实际请求就不发。
这三种 Content-Type 恰好都是 HTML 表单能构造的格式。清单之外还有请求头取值和上传行为等更细的限制,满足全部条件才算安全列出。
🚧
这个结论有三个边界:
  • 同源请求不需要过 CORS;
  • 非浏览器客户端(比如命令行脚本、手机 App)根本不受浏览器 CORS 机制约束;
  • HTML 表单和导航不走这套规则:它们永远直接发送,从不预检——故事里的恶意表单就是这样绕开预检的。
预检结果可以缓存。所以“这次开发者工具里没看到 OPTIONS”不等于“这个请求从没经过预检规则”。
预检缓存:为什么 DevTools 里看不到 OPTIONS?
浏览器会为预检结果建缓存。缓存条目按源、URL、方法、请求头等条件匹配。如果已有仍然有效、且与本次请求条件匹配的缓存条目,浏览器直接复用上次的授权,不再发新的网络 OPTIONS。所以 DevTools 网络面板里这一次没有 OPTIONS,只说明这次命中了缓存或不需要预检,不代表预检规则被跳过。想观察真实的预检,可以清空缓存、缩短服务器声明的缓存时长,或改动请求头使缓存条目失配。
凭据在 CORS 里是独立的一层。用 fetch() 写代码时,跨源请求默认不带凭据——credentials mode(凭据模式)默认是 same-origin。改成 credentials: include,也只是告诉浏览器“可以考虑带”,不是保证一定带:Cookie 的主机、PathSecureSameSite 和浏览器策略,每一项仍要分别通过。
而且“凭据可能随请求发出”和“响应可以被脚本读”是两件事。当请求的 credentials mode 是 include 时,浏览器要把响应暴露给发起脚本,服务器必须同时返回两样东西:
  • 与请求源精确匹配的 Access-Control-Allow-Origin
  • Access-Control-Allow-Credentials: true
这时写 Access-Control-Allow-Origin: *(通配符)是没用的,浏览器不会把凭据化响应交给脚本。反过来也成立:就算服务器没返回正确的 CORS 响应头,浏览器也不会因此撤销一个已经发出、且已被服务器处理的无需预检请求。
还有一个细节:跨源预检请求本身不带凭据。但如果后续实际请求的 credentials mode 是 include,预检响应仍须返回与凭据化请求相容的 Access-Control-Allow-OriginAccess-Control-Allow-Credentials: true,浏览器才会继续。
小结:CORS 和预检管的是“特定跨源浏览器脚本的发送路径与读取权限”。它不是通用的服务器授权系统,也不是覆盖所有载体的 CSRF 防线。

每道防线分别切断哪个失败条件?

到这里,“发”和“看”都清楚了。但最根本的一环在服务器:它为什么不多问一句,就收下了这笔付款?
回到故事。假设 pay.example.com 有一个处理付款的接口,并且错误地接受跨站 HTML 表单能构造的 POSTevil.example 页面里的全部内容,其实就这么点:
action 指向付款接口;隐藏字段是攻击者预填好的参数;submit() 让表单一加载就自动提交。整段代码不需要读目标站的任何内容——它只负责让浏览器把请求发出去。这是攻击载体的演示,不是“任何站点都会被攻破”的证明:能否造成伤害,取决于下面条件链的每一环。
一次 CSRF 按下面这条条件链发生:
  1. evil.example 用表单等载体让浏览器发出请求。这一步不依赖攻击者读取目标页面。
  1. 受害者的会话 Cookie 各项条件(主机、PathSecureSameSite)都允许它随当前请求发送,浏览器策略也没拦。
  1. 服务器凭 Cookie 认出受害者,确认账号有付款权限。
  1. 服务器没有验证请求是否体现受害者的真实意图——没有有效 Token(一种服务器可验证的意图凭证)、没有来源检查、没有严格 API 载体约束、没有高风险确认——于是接受请求,执行付款。
  1. 响应返回后,SOP 或失败的 CORS 检查阻止 evil.example 的脚本读取内容。
第 5 步改变不了第 4 步。 攻击者从头到尾什么都读不到:你的 Cookie、发出去的请求内容、返回的响应,他一样都看不到。但他也不需要——他只要服务器执行。
这也说明这个场景是有条件的,不是“所有 Cookie 登录的站点都必然有漏洞”。比如,会话 Cookie 的 SameSite=Lax 按预期拦下跨站 POST 时,服务器收到的只是未登录请求;服务器不接受表单编码、要求攻击者构造不出的有效 Token、或执行前拒绝不可信来源,攻击链同样会断。
预检也不是普遍的 CSRF 防护。表单能表达的请求和其他无需预检的请求,不会先征求 CORS 许可。对需要预检的请求,有效缓存只是在源、URL、方法、请求头都匹配的条件下复用上次的成功授权;少发一次网络 OPTIONS 不等于跳过授权检查。只有当服务器主动拒绝所有简单替代形式、把“必须使用非安全列出能力”当成强制协议条件时,预检路径才成为特定 API 防护的一部分。
那么防线该怎么选?原则是:按接口的认证方式和合法客户端来选,而不是给所有 API 机械地叠同一种 CSRF Token。
传统 Cookie 会话应用,通常优先用框架内建保护或同步器 Token:服务器生成一个不可预测、且与会话关联的值,每次状态变更时从请求参数或请求头里验证它。不想在服务器保存 Token 状态的,可以用签名且绑定当前会话的双重提交 Token。注意,只比较“两个值相不相等”的朴素双重提交方案,可能被 Cookie 注入攻破:攻击者若能往受害者浏览器里种一个自己知道的 Cookie,两个值就都是他控制的了。
严格的浏览器 JSON API 可以用另一种 OWASP 模式:服务器拒绝表单编码、text/plain 等简单替代形式,要求自定义请求头或非安全列出媒体类型,并且只为精确列出的受信源放行 CORS。这种模式下不一定需要额外的随机 Token,但所有条件都必须由服务器强制执行。任何被允许的源,都进入了你的信任边界。
Origin 检查应该优先用完整的协议、主机、端口精确匹配;Origin 缺失时,再从 Referer 提取源并精确匹配。不能用字符串后缀或模糊包含来判断。两个头都缺失时要有明确策略:敏感接口通常直接拒绝;兼容性迁移阶段可以先记录并观测,而不是无条件放行。
🛑
跨站脚本(Cross-Site Scripting,XSS) 会把前面多数防线打穿。如果攻击者的脚本通过 XSS 跑进了你自己的站点,它就能读取 Token、设置请求头、调用合法客户端逻辑——上面这些建立在“攻击者在站外”假设上的防线,大多会失效。
把整条链路摊开,每道防线卡住的是不同的环节:
防线
切断的失败条件
边界
安全方法不改状态
链接、顶层 GET 直接触发变更
合法 POST/PUT/PATCH/DELETE 仍需其他防护
同步器 Token,或签名且会话绑定的双重提交
伪造请求拿不出可验证的意图证明
必须服务端校验;朴素双重提交怕 Cookie 注入;XSS 可绕过
严格 API 载体 + 精确 CORS allowlist
不可信源构造不出服务器接受的请求
须拒绝所有简单替代形式;被允许的源进入信任边界;非浏览器客户端不受约束
SameSite Cookie
适用请求中会话 Cookie 不跨站附带
纵深防御;同站兄弟主机、顶层导航例外、浏览器差异需另行分析
Origin 优先、Referer 回退
拒绝非允许源的状态变更
精确匹配 + 缺失头策略;反代后用可信配置确定目标源
认证、授权与高风险重新认证
未登录、越权、缺确认的操作不被接受
普通授权不证明意图;重新认证不替代基础 CSRF 防护
防受信源 XSS
攻击者拿不到同源脚本能力
一旦失守,多数同源能力防线失效

上线前,用哪六个问题检查接口?

这个接口是否保证 GETHEAD 等安全方法不改变服务器状态?
表单、导航、Fetch/XHR 分别能构造出哪些请求?服务器是否拒绝了所有不该接受的简单替代形式?
在真实的 host、PathSecureSameSite 和浏览器策略下,哪些 Cookie 或凭据会自动附带?
服务器执行前强制验证了哪种意图信号?OriginReferer 缺失时的策略是什么?高风险操作要不要重新认证?
如果启用了 CORS:允许源是否精确、凭据配置是否明确、每个被允许的源是否都按“可调用该 API 的信任边界”审查过?
我有没有把“攻击者不知道接口地址或参数”当成防护?隐藏 URL、JSON 请求体、参数保密都不是 CSRF 防线。
🧭
因果回顾: SOP 与 CORS 管的是恶意页面能不能偷看结果;Cookie 与载体规则管的是浏览器会不会替你发请求、带上登录信息;身份认证、授权和 CSRF 防护管的是服务器该不该当真执行。把这四件事拆开,才能看清:你没看到付款页面,不等于钱还在。

来源与延伸阅读

DeepSeek V4 Pro、Grok 4.6、Claude 与 ChatGPT:能力、价格和场景怎么选?Redis 分布式缓存架构深度解析:策略模式、一致性模型与高可用性工程实践
Loading...