CORS 究竟在防住什么跨域风险?

掘金热榜文章《CORS 到底在保护谁?90% 的前端理解错了跨域的本质》将争议的焦点引向一个常见误区:把“接口能否被访问”和“浏览器是否允许页面脚本读取结果”混成一件事。标题中的“90%”是文章自己的观点性表达,不应当被当成经过验证的统计比例。

CORS 是一套协作机制:服务器显式授权,浏览器据此决定跨源响应能否被页面脚本读取。它不是接口能否访问的开关。通过 Postman 或 curl 发出的请求能正常得到响应,只能证明接口可达;简单跨源请求可能已经到达服务器,但浏览器拒绝把响应交给页面脚本,预检失败时则不会继续发送实际请求。

浏览器拦下的究竟是哪一步

“源”由协议、主机和端口共同组成,路径和查询参数不属于其中。假设当前页面是 https://app.example.com/dashboard,可以这样判断:

当前页面:https://app.example.com/dashboard
同源:https://app.example.com/api/items?page=1
跨源:http://app.example.com/api/items
跨源:https://api.example.com/items
跨源:https://app.example.com:8443/items

路径从 /dashboard 变成 /api/items 不影响同源判断;协议、子域名或端口发生变化则会形成跨源。判断同源时,应把 URL 归一化为“协议 + 主机 + 端口”这三个维度,而不是只看域名。

同源策略也不等于禁止页面发出一切跨源请求。它主要限制脚本读取跨源资源,以及访问受来源约束的浏览器数据。资源加载与响应读取是两条不同的权限链:图片、普通脚本和样式表可以被跨源引用,服务器也可以正常返回它们,但这不代表页面的 fetch 或 XHR 能读取响应内容。

浏览器处理一条需要 CORS 检查的请求时,大致经过以下链路:浏览器在请求中携带 Origin,服务器检查这个来源并返回 Access-Control-Allow-Origin 等访问控制响应头,浏览器再判断是否允许把响应交给页面脚本。服务器是否返回数据、状态码是否正常,与页面脚本能否读取响应,是两个层面的问题。

这也解释了 Postman 正常、浏览器失败的现象。Postman 和 curl 不受浏览器同源策略约束,即使手工添加 Origin,客户端也不会代替浏览器执行最终的 CORS 放行检查。它们适合验证网络、参数和服务端逻辑,不能替代浏览器环境下的验证。

CORS 的控制重点是“跨源响应读取”。服务器未授权某个来源时,外部页面不能读取另一个源返回的私有数据。但它不证明调用者身份,也不替代服务端鉴权,更不能单独阻止所有 CSRF 风险:某些简单跨源请求仍可能到达服务器并产生副作用,服务端是否允许这些操作,仍要校验会话、权限和 CSRF Token。预检机制还增加了另一层约束:浏览器没有获得预检许可,就不会继续发送对应的非简单实际请求。

什么时候会多出一次 OPTIONS

是否触发预检,主要看请求方法、请求头和 Content-Type。Cookie、凭据模式本身不是预检判定条件;credentials: "include" 也不会自动让一个原本简单的 GET 变成预检请求。

请求形式 是否自动预检 原因
GET,且没有自定义请求头 否 GET 属于可安全列举的方法
POST,使用表单编码、multipart/form-data 或 text/plain 否 方法与 Content-Type 均符合简单请求条件
PUT、PATCH、DELETE 是 方法不在可安全列举范围内
GET 携带 Authorization 是 Authorization 不是可安全列举的请求头
POST 使用 application/json 是 JSON 类型不属于可安全列举的 Content-Type

这组划分决定的是“浏览器是否先发 OPTIONS”,不是请求最终能否成功。“简单请求”只是不需要预检,并不等于自动获得跨源读取许可。

浏览器会自动生成预检请求,前端通常不应手动编写 OPTIONS 请求:

OPTIONS /api/items HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type

其中,Access-Control-Request-Method 是浏览器准备使用的方法,Access-Control-Request-Headers 是浏览器准备携带的非安全列举请求头。服务器需要返回与它们匹配的放行信息,例如:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: PUT, OPTIONS
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 600

这里的 600 只是示例缓存时长。预检通常以 2xx 状态返回,但状态正常不等于检查通过:允许来源必须与请求中的 Origin 一致,允许方法必须包含 Access-Control-Request-Method,允许请求头必须覆盖 Access-Control-Request-Headers 中的内容。任一项不匹配,浏览器都会终止预检流程,实际的 PUT、PATCH 等请求也不会发送。

预检通过后,实际请求仍会独立经过 CORS 检查:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Content-Type: application/json

简单请求虽然没有预检阶段,实际响应同样必须包含匹配的 Access-Control-Allow-Origin。少了这个响应头,接口可以返回 200,服务器也可以完成业务处理,但页面脚本仍然拿不到可读结果。

凭据型请求还要增加一层限制。例如:

fetch("https://api.example.com/profile", { credentials: "include" });

此时不能使用 Access-Control-Allow-Origin: *,而应返回明确的来源,并启用凭据许可:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true

预检和实际响应都应按凭据模式正确配置。即使浏览器允许携带凭据,服务端仍要独立验证会话或 Token,CORS 响应头不能替代身份认证与权限判断。

另外两个响应头也经常被混淆。Access-Control-Allow-Headers 控制预检阶段允许发送哪些请求头;Access-Control-Expose-Headers 控制实际响应中哪些响应头可以暴露给 JavaScript。响应体已经可读,但 X-Total-Count 之类的自定义头看不到时,需要检查的是后者。

如果服务器根据 Origin 动态选择允许来源,还应同时返回:

Vary: Origin

否则代理或缓存可能把针对某个来源生成的放行结果复用给其他来源。Access-Control-Max-Age 则控制浏览器可以缓存预检结果多久。它影响后续请求是否还需要重新预检,不改变对来源、方法和请求头的匹配要求。

从控制台报错追到根因

排查跨域报错,应按 Network 面板里的请求顺序检查,而不是先添加通配符。通配符可能掩盖错误,也可能把本应隔离的来源一并放开。

第一步,确认是否出现了 OPTIONS。没有 OPTIONS,说明浏览器本次没有发起预检,这通常发生在简单请求中。此时检查实际响应:即使状态码是 200,只要缺少 Access-Control-Allow-Origin、来源不匹配,或者出现多个允许来源响应头,页面仍会报跨域错误。如果 OPTIONS 存在,则需要继续区分“预检失败”与“预检通过后实际响应被拦截”。

第二步,检查预检响应。返回 404 或 405,通常是 OPTIONS 路由缺失、框架 CORS 配置没有覆盖当前接口,或者网关没有转发预检请求;返回 401、403,则要检查认证或安全中间件是否在 CORS 处理之前就把 OPTIONS 拦截了。处理预检时,应在认证或安全中间件拦截前完成 CORS 检查并返回预检结果,而不是让预检请求也要求登录。

即使预检返回 2xx,也要逐项比对请求头和响应头:请求中的 Origin 是否与 Access-Control-Allow-Origin 完全一致,允许方法是否包含 Access-Control-Request-Method,允许请求头是否包含 Access-Control-Request-Headers,凭据请求是否返回了 Access-Control-Allow-Credentials: true。允许来源应精确到协议、主机和端口,不包含路径,也不能带路径末尾的斜杠。

第三步,检查实际响应。预检成功、实际请求也已经发出,但页面仍然报错,通常说明问题出在实际响应:允许来源缺失或不一致、响应中出现多个 Access-Control-Allow-Origin、4xx/5xx 错误响应遗漏了 CORS 头,或者重定向后的响应没有重新附加访问控制头。浏览器有时只显示统一的 CORS 错误,掩盖原始业务状态,因此不能只看 Network 中的红色提示。

如果响应体可以读取,只有自定义响应头不可见,则检查 Access-Control-Expose-Headers。它与预检使用的 Access-Control-Allow-Headers 作用不同:前者决定脚本能读取哪些响应头,后者决定浏览器允许携带哪些请求头。

第四步,再对照 Postman、curl 与浏览器结果。下面的命令可以查看服务器是否返回了预期的预检头:

curl -i -X OPTIONS "https://api.example.com/api/items" -H "Origin: https://app.example.com" -H "Access-Control-Request-Method: PUT" -H "Access-Control-Request-Headers: authorization, content-type"

实际请求也可以单独检查:

curl -i "https://api.example.com/api/items" -H "Origin: https://app.example.com"

curl 能显示服务器返回了什么,却不会像浏览器一样阻止页面读取不合规的响应。因此,curl 只能帮助核对接口可达性和服务器返回的响应头,不能证明浏览器已经放行。

安全配置上,公开且不携带凭据的读取接口可以使用 Access-Control-Allow-Origin: *;其他场景应维护明确的来源列表,不应把任意 Origin 原样反射并同时允许凭据。CORS 不能替代身份认证、权限校验和 CSRF 防护。

如果跨源边界本身可以调整,同源反向代理可以让浏览器只看到一个前端入口,适合统一 API 出口;JSONP 只适合旧式 GET 数据获取;postMessage 适用于窗口或 iframe 之间的消息通信;服务端直接调用第三方接口则不涉及浏览器同源策略。它们解决的问题不同,不能把所有跨域场景都归入 CORS 配置。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容