编程 预检返回 204、真请求还是被拦:带 Cookie 的 CORS 与 Set-Cookie 排查笔记

2026-09-27 00:04:45

预检返回 204、真请求还是被拦:带 Cookie 的 CORS 与 Set-Cookie 排查笔记

跨域请求失败时,浏览器控制台只给一句 blocked by CORS policy。真正要看的不是这句话,而是 Network 面板里 OPTIONS 的响应头,以及 Set-Cookie 有没有被浏览器收下。下面按排查顺序过一遍。

携带凭据的跨域请求(fetch(..., { credentials: 'include' })、XMLHttpRequest.withCredentials = true)要求服务端回具体 origin,* 会被直接拒绝:

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

做法是按白名单动态 echo 请求里的 Origin,同时补 Vary: Origin,否则 CDN / 反向代理会把某个 origin 的响应缓存给别的 origin 用。响应里同时出现 Access-Control-Allow-Origin: * 和 Access-Control-Allow-Credentials: true 是最常见的错配。

二、预检「过了」但真请求被拦

非简单请求(自定义头、PUT/DELETE、Content-Type: application/json 等)浏览器会先发 OPTIONS:

OPTIONS /api/orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, x-trace-id

服务端响应必须逐项覆盖:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: content-type, x-trace-id
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 7200

两个高频坑:

  • Allow-Headers 漏配。Access-Control-Request-Headers 里列了几个头,响应就得覆盖几个。OPTIONS 返回了 204,看起来「预检通过」,但浏览器判定预检失败后根本不会发出真正的 POST,DevTools 里只留下一条 OPTIONS。
  • Max-Age 被浏览器截断。Chromium 把上限压在 7200 秒,Firefox 压在 864 秒。填 86400 不会报错,只是不生效,Firefox 下等于每 14 分钟左右重新预检一次。

另外,CORS 是浏览器强制的,curl / Postman 不检查。所以「Postman 能通、浏览器不行」基本可以直接锁定 CORS。

Set-Cookie: session=abc123; Max-Age=86400; Path=/; Secure; HttpOnly; SameSite=Lax
  • Secure:只走 HTTPS 发送。
  • HttpOnly:JS 读不到,挡 document.cookie 窃取。
  • SameSite:Strict 只在同站发送;Lax 同站 + top-level navigation,是常用默认;None 用于跨站,必须同时带 Secure,否则浏览器直接拒收这条 cookie。

Chrome 对未声明的 cookie 按 Lax 处理。跨站场景下写了 SameSite=None 却忘了 Secure,表现是登录接口返回 200,但下一个请求依旧未登录 —— cookie 从没进过 Cookie Store。

四、Sec-Fetch-*:服务端侧补一层 CSRF 防护

浏览器自动带上,服务端可读,脚本改不了:

头取值
Sec-Fetch-Sitesame-origin / cross-site / none
Sec-Fetch-Modecors / navigate / no-cors
Sec-Fetch-Destdocument / script / image / style
Sec-Fetch-User?1

可以据此拒绝跨站 POST,例如 Sec-Fetch-Site: cross-site 的写操作直接 403。这是 SameSite 之外的兜底,不能拿来替代 CORS。

五、Nginx add_header 的继承坑

同一层级里父块的 add_header 会被子块整体覆盖,不是叠加:

server {
    add_header X-Content-Type-Options nosniff always;

    location /api/ {
        # 只加了这一行,上面的 nosniff 在本 location 内失效
        add_header Access-Control-Allow-Origin $http_origin always;
    }
}

要在子块里把需要的头重新写全,或者把 CORS 头统一放到 server 层。always 保证 4xx / 5xx 响应也带这些头 —— 不写的话,错误响应上的 CORS 头会缺失,前端拿到的只有网络错误。

六、用 curl 自检

只看响应头:

curl -I https://example.com

手动模拟一次带 Cookie 的预检:

curl -v -X OPTIONS https://api.example.com/orders \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: content-type'

-v 会打印完整的请求头与响应头,对照检查 Allow-Origin 是否等于发出的 Origin、Allow-Credentials 是否为 true、Allow-Headers 是否覆盖了 Access-Control-Request-Headers。检查 Set-Cookie 用 -I 看响应头即可。

其余常用 header 速查

分类header要点
GeneralDate、Host、User-AgentHost 决定 virtual hosting,HTTP/2 里是 :authority;User-Agent 可伪造,不做安全判断
内容协商Accept、Accept-Language、Accept-EncodingAccept 带 q 优先级;现代浏览器送 gzip, deflate, br, zstd
PayloadContent-Type、Content-Length、Content-Encoding、Content-Disposition不设 Content-Type 接收端会猜;streaming 时 Content-Length 被 Transfer-Encoding: chunked 取代
缓存Cache-Control、ETag、Last-Modified带 hash 静态资源 public, max-age=31536000, immutable;HTML 用 no-cache;API JSON 用 private, max-age=60;If-Match 不符返回 412
连接Connection、Keep-Alivekeep-alive 为默认,HTTP/2 已废弃
安全Strict-Transport-Security、Content-Security-Policy、X-Frame-Options、Referrer-Policy、Permissions-PolicyHSTS 示例 max-age=63072000; includeSubDomains; preload;CSP 建议先上 Report-Only;X-Frame-Options 新版由 CSP frame-ancestors 接管
认证Authorization、WWW-AuthenticateBearer / Basic 只在 HTTPS 下使用

上线前 5 件事

  1. 带 Cookie 的跨域接口,Access-Control-Allow-Origin 回具体 origin + 白名单 echo,绝不写 *,并补 Vary: Origin。
  2. 逐项核对 Access-Control-Allow-Headers 与前端实际发出的 Access-Control-Request-Headers;Max-Age 按 Chromium 7200 / Firefox 864 的实际上限设置。
  3. 登录 cookie 一律 Secure; HttpOnly; SameSite=Lax;需要跨站的场景改为 SameSite=None; Secure。
  4. HSTS、CSP、X-Content-Type-Options: nosniff 在 Nginx 各 location 里用 always 显式写全,避免被 add_header 覆盖。
  5. 用 curl -I / curl -v 分别验证响应头与预检响应,静态资源确认 Cache-Control: public, max-age=31536000, immutable,API 响应确认 Content-Type 存在。

参考

复制全文 生成海报 HTTP Header HTTP头 响应头 跨域 Cookie CORS

推荐文章

程序员茄子在线接单