预检返回 204、真请求还是被拦:带 Cookie 的 CORS 与 Set-Cookie 排查笔记
跨域请求失败时,浏览器控制台只给一句 blocked by CORS policy。真正要看的不是这句话,而是 Network 面板里 OPTIONS 的响应头,以及 Set-Cookie 有没有被浏览器收下。下面按排查顺序过一遍。
一、带 Cookie 的跨域请求:Access-Control-Allow-Origin 不能用 *
携带凭据的跨域请求(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 的三个属性
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-Site | same-origin / cross-site / none |
Sec-Fetch-Mode | cors / navigate / no-cors |
Sec-Fetch-Dest | document / 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 | 要点 |
|---|---|---|
| General | Date、Host、User-Agent | Host 决定 virtual hosting,HTTP/2 里是 :authority;User-Agent 可伪造,不做安全判断 |
| 内容协商 | Accept、Accept-Language、Accept-Encoding | Accept 带 q 优先级;现代浏览器送 gzip, deflate, br, zstd |
| Payload | Content-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-Alive | keep-alive 为默认,HTTP/2 已废弃 |
| 安全 | Strict-Transport-Security、Content-Security-Policy、X-Frame-Options、Referrer-Policy、Permissions-Policy | HSTS 示例 max-age=63072000; includeSubDomains; preload;CSP 建议先上 Report-Only;X-Frame-Options 新版由 CSP frame-ancestors 接管 |
| 认证 | Authorization、WWW-Authenticate | Bearer / Basic 只在 HTTPS 下使用 |
上线前 5 件事
- 带 Cookie 的跨域接口,
Access-Control-Allow-Origin回具体 origin + 白名单 echo,绝不写*,并补Vary: Origin。 - 逐项核对
Access-Control-Allow-Headers与前端实际发出的Access-Control-Request-Headers;Max-Age按 Chromium 7200 / Firefox 864 的实际上限设置。 - 登录 cookie 一律
Secure; HttpOnly; SameSite=Lax;需要跨站的场景改为SameSite=None; Secure。 - HSTS、CSP、
X-Content-Type-Options: nosniff在 Nginx 各location里用always显式写全,避免被add_header覆盖。 - 用
curl -I/curl -v分别验证响应头与预检响应,静态资源确认Cache-Control: public, max-age=31536000, immutable,API 响应确认Content-Type存在。