CORS 和 CSRF 分别解决什么问题

两个词经常一起出现,却不是一层防线

接口联调时,浏览器控制台抛出跨域错误,有人会说再加一个 CSRF 令牌。表单被外部站点提交,又有人说把 CORS 配成任意源就安全了。这两种说法把两件不同的事拧在了一起。

CORS 和 CSRF 分别解决什么问题
CORS 和 CSRF 分别解决什么问题放大图解 ↗

浏览器的同源策略限制的是脚本如何读取其他源的响应,并不等于跨站请求发不出去。CORS 是服务器显式放宽这个读取限制的办法。CSRF 针对的是另一件事:浏览器会自动带上 Cookie 这类环境凭证,别的网站可以诱使你的浏览器向你的网站发请求,从而在你已登录的前提下触发改数据的操作。一个管脚本能不能读到响应,一个管凭着自动附带的登录态去改状态。配置其中一个,不会自动完成另一个的工作。

源由什么组成

源由协议、主机和端口组成。https://shop.example 和带有 HTTPS 默认端口的同一主机,在浏览器看来是同一源。https://api.shop.example 是另一个源,因为主机不同。http://shop.example 也是另一个源,因为协议不同。路径不参与源的判断,同一主机上的两个路径仍然同源。

同源策略下,商店页面上的脚本不能读取接口源返回的正文,也不能读取非同源响应的某些头。这不是加密,也不是服务器拒绝连接。请求有时已经发出,只是结果不给脚本看。页面里的图片、脚本、样式和表单提交,长期以来就可以指向其他源,否则网页无法显示第三方图片,也无法跳转到支付页。

理解这一点,后面的差异才站得住。攻击者的页面不一定要读到你的接口响应才算得逞。如果请求本身会扣款、改地址或改邮箱,副作用在服务器处理请求时已经发生。读不到响应,只说明攻击者不知道你的服务器回了什么,不说明动作没有执行。

CORS 放宽的是读取

当前端页面和接口不在同一源,例如页面在 https://shop.example,接口在 https://api.shop.example,浏览器会按 CORS 规则决定这个页面的脚本能否读取响应。服务器通过响应头回答。

Access-Control-Allow-Origin 指定允许的页面源。可以是具体源。在不带凭证的情况下,也可以使用星号表示任意源。带凭证时不能用星号,必须回显具体源,并加上 Access-Control-Allow-Credentials: true。

这里的凭证主要指 Cookie、客户端证书,以及一部分由浏览器管理的认证信息。它不等于你放在 Authorization 头里的令牌。令牌是脚本自己加上去的,不会因为「允许凭证」就自动出现。把这两件事混在一起,会导致人们在该允许具体源的时候写成星号,或在不该带 Cookie 的接口上打开凭证。

简单请求和预检请求不一样。方法是 GET、HEAD 或 POST,并且头部和内容类型都落在浏览器规定的简单集合里时,浏览器直接发请求,再用响应头决定脚本能否读取。内容类型若是 JSON,就不在这个简单集合里。不满足简单条件时,浏览器先发 OPTIONS 预检,询问服务器是否允许该方法、这些头和这个源。服务器用允许的方法、允许的头来回答,并用 Access-Control-Max-Age 让浏览器记住一段时间。预检没有通过,真正的业务请求不会发出。

预检有一个常被借用的副作用。自定义头会使跨站脚本的请求先过预检。若接口不对攻击者的源放行预检,攻击者的脚本就发不出带这个头的请求。这可以成为防护的一部分,但前提是接口确实要求这个头,而且同一个地址不接受「简单请求」的写法。表单提交不会发预检,也不能随意加自定义头。所以只对 JSON 生效的预检,保护不了仍接受表单编码的同一个地址。

CORS 头是给浏览器看的。用命令行或服务端程序调用接口时,不会因为缺少允许源的响应头就被拦住。把 CORS 当成接口认证,等于对非浏览器客户端完全没有约束。认证仍然要靠会话或令牌。

配错 CORS 时,读取范围会大到不该大

危险的配置是:请求里带了什么 Origin,响应就原样回显,并且允许凭证。这样任意网站上的脚本都可以带着用户的 Cookie 调用你的接口,并读取响应正文。这比经典的跨站伪造更严重,因为对方不仅能触发副作用,还能把订单、地址和个人资料读回去。

星号配合允许凭证,会被浏览器拒绝,不能当成「既能共享又能带 Cookie」的写法。于是有人改成回显来源。回显必须配合允许清单。清单来自你自己的前端域名,不要把用户提供的来源无条件写回去。比较时用完整源,包含协议和端口。不要做成「字符串里包含 example.com」,那会被 notexample.com 或带伪装前缀的主机命中。以后缀判断时,点号要算进去,避免把 evilexample.com 当成 example.com 的子域。

不要为了本地调试把允许范围设成任意源,然后把同一份配置带进生产。本地可以列出开发用的具体源,由环境配置注入。生产配置里不包含本机地址,也不包含内网随意端口。

Access-Control-Expose-Headers 决定脚本能读到哪些非简单响应头。没有业务理由时,不要把内部追踪头暴露给允许的源。允许的源本身就应该很窄。若追踪标识对前端排障有用,可以暴露那一个头,而不是把全部响应头都放开。

允许凭证时,缓存要小心。不同来源得到的允许头不同,响应若被共享缓存存下来,可能把「允许甲源」的结果发给乙源的访客。响应的 Vary 应包含 Origin,让缓存按来源分开。这是缓存正确性,不是 CSRF 令牌能补上的。

CSRF 利用的是自动带上的身份

用户登录 bank.example 之后,浏览器保存 Cookie。此后只要请求发往这个主机,浏览器就按 Cookie 的范围附上它,无论请求是银行页面发起的,还是另一个标签页里的陌生网站发起的。服务器若只看 Cookie 就认为是用户本人在操作,陌生网站就能让浏览器替用户提交修改。

这就是跨站请求伪造。它不需要对方读到响应,也不需要攻破同源策略。典型情况是一张自动提交的表单,方法为 POST,字段为目标接口需要的参数。用户只要打开那个页面,浏览器就会发出这个 POST。若 GET 请求也会改数据,甚至资源加载就可能触发,所以改数据的接口不要用 GET。

这种攻击依赖浏览器自动附带的凭证。接口若使用 Authorization: Bearer 令牌,并且令牌只放在不会自动附带的位置,由脚本显式设置,浏览器就不会在跨站表单里自动带上它。这类接口的主要风险转向跨站脚本:一旦你的页面能跑不可信脚本,脚本就能读取令牌并自己调用接口。所以「用了令牌就没有 CSRF」这句话,只在令牌不会被自动附带、也不会被同源以外的脚本读到时成立。把令牌放进 Cookie,又没有其他约束,问题会回到桌面上。

同一个接口若同时接受 Cookie 和令牌,对方会走 Cookie 那条更自动的路。要么拆成两套入口,要么对 Cookie 路径仍做完整的 CSRF 防护。不要因为移动端用了令牌,就认为网站这条 Cookie 路径可以不管。

常见防护各自约束什么

同步令牌由服务器为会话生成随机值,渲染表单时放进隐藏字段,同时保存在服务端会话里。提交时比较两者。对方的页面无法读取银行源上的令牌,也就构造不出正确字段。令牌要与会话绑定,熵要足够,不要用可猜测的时间戳。比较时使用不提前泄露匹配前缀的比较方法。令牌是否在每次提交后更换,取决于你能否接受多个标签页同时提交。若更换,要让旧令牌在很短的重叠窗口内仍然可用,避免把正常用户挡在外面。

双重提交把令牌同时放在 Cookie 和请求字段里,服务器比较这两个值是否一致,从而不必在服务端存储会话。对方可以在自己的表单里放入任意字段,但读不到银行域下的 Cookie,因此无法让字段值和 Cookie 中的令牌一致。若对方控制了你的某个子域,就有机会写入父域 Cookie,裸的双重提交会被削弱。Cookie 应限制在具体主机上,并考虑使用主机绑定的名称前缀。实现时要对照浏览器对前缀的规则做测试,包括路径和安全标记。

校验 Origin 或 Referer 是另一条路。浏览器在跨站请求上会带上来源信息。服务器要求来源属于自己的站点清单。有的客户端会省略 Referer,所以要定义缺失时如何处理,不要为了少误伤,对所有空来源放行写操作。Origin 一般更适合这个判断。这个校验是 CSRF 防护,它不依赖 Access-Control-Allow-Origin。两者名字都带来源的意思,配置位置完全不同:一个是你检查请求,一个是你告诉浏览器谁可以读响应。

SameSite 改变 Cookie 在跨站请求中的发送规则。Lax 在其他网站发起的 POST 等子请求上不发送 Cookie,但在用户点击链接造成的顶级 GET 导航上仍会发送。Strict 更紧,从外部链接点进来的首次请求也不带 Cookie,可能影响从邮件回到已登录站点的体验。None 允许跨站发送,并且必须同时标记安全连接。现代浏览器对未标记的 Cookie 多按 Lax 处理,这减少了经典表单 POST 的风险,但不是全部。顶级 GET 若会改数据,仍然危险。同站的范围还受公共后缀列表影响,shop.example 和 payments.example 是否同站不能凭直觉,要用实际域名核对。

自定义头加预检,适合纯 JSON、不接受简单表单内容类型的接口。它必须和服务端拒绝「缺少该头的写请求」一起用。只在前端加上头,服务端不检查,等于没有防护。网关若把该头剥离了,应用却以为网关检查过,中间会出空洞。检查放在真正执行写操作的那一层,网关可以额外做,但不能只有网关做而应用在内网直连时不做。

哪些接口要防

判断标准是:浏览器会不会在用户无感的情况下自动带上足以授权的凭证,并且这个请求会改变状态或造成敏感副作用。

会话 Cookie 认证的网站要防,包括传统表单,以及前后端分离但仍用 Cookie 的写法。改口令、改邮箱、下单、转账、授权第三方,都在范围内。登出也值得防。对方虽不能因此进入账号,但可以不断把用户踢下线。把登出做成带防护的 POST。

纯 Bearer 令牌、由脚本放在不会自动附带的位置、且接口不接受 Cookie 登录时,CSRF 不是主要矛盾。精力应放在令牌的存储、过期、轮换,以及页面上的脚本注入防护。令牌不要放进会随请求自动发出的 Cookie,除非你同时做好 CSRF 防护,并接受这是会话方案而不是纯令牌方案。

只读的 GET 一般不是 CSRF 的目标。但 GET 若返回敏感数据,并且能被其他源的脚本读走,那是 CORS 放得过宽,或是旧式的动态脚本回调,不是把 CSRF 令牌加长就能解决的。反过来,只读接口若被加上破坏性副作用,预加载和链接都会触发它,防护会非常别扭。先把写操作改成 POST、PUT 或 DELETE,再谈豁免清单。

框架的 CSRF 中间件常常对所谓安全方法免检。你的 GET 若会改数据,中间件帮不上忙。豁免必须逐个路径审查,不要对整段 /api 关闭。新增路由时默认纳入防护,而不是默认豁免。

四个容易混在一起的场景

页面与接口不同源,认证用 Cookie。你需要 CORS 允许你的页面源,并且允许凭证,否则脚本读不到响应,应用无法工作。与此同时,Cookie 仍会按 SameSite 的规则在跨站请求里发送,所以写接口仍要 CSRF 防护。允许清单里不要出现你不控制的源。两者都要,职责不同。本地开发若页面端口和接口端口不同,它们就是不同源,也会走进这个场景。不要为了省掉开发环境的 CORS 而在生产打开任意来源。

接口只给移动应用和服务器调用,认证用 Bearer,不使用 Cookie。浏览器的 CORS 对原生应用不适用。没有浏览器前端时,可以不发送允许跨源读取的头,浏览器脚本自然读不到响应。这不能代替令牌校验。任何人拿到令牌仍能调用,所以签发、过期、收口和权限范围才是认证问题。若将来要加浏览器前端,再按具体源打开 CORS,不要提前写成回显任意来源。

页面与接口同源,传统服务端渲染。不需要 CORS,因为没有跨源读取。CSRF 仍然存在。表单要带同步令牌,Cookie 设置 SameSite。人们在这种架构里很少看到跨域报错,于是忘了 CSRF,直到外部站点能够提交表单。给所有会改状态的表单统一加令牌,包括后台里那些「只有管理员能打开」的页面。管理员浏览器同样会被其他标签页驱动。

为了图方便把允许源设成星号,认证却放在 Cookie 里。带凭证的请求不会被这个组合放行,开发者接着改成回显任意来源并允许凭证,于是任意网站都能读到登录用户的数据。正确做法是列出真实前端源。若你希望任意网站都能调用,就不要使用 Cookie,改为调用方显式提供的令牌,并接受这是一个公开接口,用速率限制和令牌权限控制它,而不是用浏览器的跨域提示来代替认证。

实现时容易漏掉的细节

Cookie 的域设得过宽,例如设到父域,所有子域都会带上它。子域若有用户可上传的内容,或有独立的、安全性较弱的应用,会话就扩大了暴露面。能不共享父域 Cookie,就不共享。上传内容和主站不同源,正是为了避免用户内容上的脚本读到主站会话;父域 Cookie 会把这件事抵消。

预检结果会被浏览器缓存。你收回某个允许的方法之后,客户端仍可能在 Max-Age 期间沿用旧答案。紧急收紧时把这段时间设短,或接受缓存窗口。不要在生产把预检缓存设成许多天,然后以为改配置会立刻生效。

前端把 CSRF 令牌放进页面的 meta 标签时,要保证页面本身不会被注入不可信标记。令牌防的是跨站,不防同源脚本注入。注入一旦发生,对方在你的源里读取令牌是自然结果。转义、避免把不可信 HTML 插进页面、以及内容安全策略,仍是基础。令牌出现在 URL 里还会进访问日志和引用页,应放在表单字段或请求头里。

跨源的 Cookie 若必须使用 SameSite=None,例如嵌入在合作方页面里的支付组件,范围要尽量窄:单独的 Cookie 名、较短的寿命、只覆盖支付路径,并且仍校验来源和同步令牌。不要因为一个嵌入场景,把主会话 Cookie 也设成跨站发送。

排查时先分清失败发生在哪

浏览器控制台写预检失败,看 OPTIONS 响应是否包含对方请求里的源、方法和头。这是读取许可问题。同时确认服务器没有在预检里错误地要求认证,导致正常前端也过不了。预检请求通常不带你后来才加上的业务体,认证逻辑要允许这种探测,但不能因此放行随后的写操作。

请求已经改变了数据,但用户并没有在你的网站上点击,按 CSRF 查。看 Cookie 的 SameSite,写接口是否接受简单的表单内容类型,令牌是否真的校验,来源是否检查。不要只在前端断点里看「我们的页面会加令牌」,要看服务器对缺少令牌的请求是否拒绝。

对方能在自己的网站上展示你接口返回的 JSON,优先查 CORS 是否回显了任意来源且允许凭证,或是否仍留着动态脚本回调。这不是把 CSRF 令牌再加长就能解决的。先把允许源收紧到清单,确认凭证开关只对清单内的源打开。

测试也要分开写。CORS 测试断言:清单之外的源读不到正文,清单之内的源可以,并且任意来源加凭证的组合不会出现。CSRF 测试用一次不带令牌的表单提交,断言状态不变;再用来源不对的请求断言状态不变。不要用一个「跨域请求返回 200」概括两种结论。返回 200 可能只说明服务器执行了操作,浏览器却没有把正文交给脚本,那恰好是 CSRF 会成功、CORS 仍算「挡住了读取」的情况。

CORS 回答哪些页面上的脚本可以读响应。CSRF 防护回答只有我们自己的页面能借用户已经登录的凭证改数据。记住这个分工,配置时就知道该改响应头,还是该改令牌、Cookie 和请求方法。两者可以同时存在于一个前后端分离的系统里,但它们不会互相替代。

见字如晤

图解