登录态、跨域与缓存的暗战:Cookie / 同源策略 / 缓存实战

登录态、跨域与缓存的暗战:Cookie / 同源策略 / 缓存实战

实验环境:Ubuntu 24.04、nginx 1.24.0、curl 8.5.0、dnsutils(dig)
服务器公网 IP:120.46.193.105;跨域演示用同机两个端口8080(API)与8081(页面)
本文所有响应头、状态码、抓包结果均为真实执行,关键行已加注释。


0. 引言:这些"玄学"你遇到过吗

  • 登录态丢失:明明Set-Cookie返回了,刷新页面却还是未登录——多半是SameSite/Secure拦了,或HttpOnly被你当成了 bug。
  • 跨域报错:前端fetch一个接口,浏览器控制台红一片Blocked by CORS policy,但curl明明能拿到数据。
  • 更新不生效:改了 CSS/JS,用户说"还是旧的"——缓存没失效,你却不知道是哪条Cache-Control在作怪。
  • POST 重定向丢参(上一篇的坑):用301/302重定向一个POST,服务端收不到参数——因为方法被偷偷改成了GET

这四个问题,分别对应Cookie、同源策略(CORS)、缓存、重定向语义。本篇全部用真实报文拆给你看。


1. 实验环境补充

在上一篇的基础上,本篇额外启用两个端口:

# API 站点(8080):返回跨域头 server { listen 8080; location /api { add_header Access-Control-Allow-Origin "http://120.46.193.105:8081"; add_header Access-Control-Allow-Credentials "true"; add_header Access-Control-Allow-Methods "GET, POST, OPTIONS"; add_header Access-Control-Allow-Headers "Content-Type, X-Custom"; if ($request_method = OPTIONS) { # 预检请求直接 204 add_header Access-Control-Allow-Max-Age "600"; return 204; } return 200 "api response from 8080\n"; } } # 页面站点(8081):模拟"另一个源" server { listen 8081; }

说明:http://120.46.193.105:8080http://120.46.193.105:8081端口不同 = 不同源(同源策略看 协议+域名+端口 三者全同),正好用来演示跨域。


2. 一、Cookie:服务端怎么"记住"你

HTTP 本身无状态。Cookie机制让客户端在后续请求里自动带回服务端下发的标识。

2.1 服务端下发:Set-Cookie及其属性

curl-svhttp://127.0.0.1/setcookie

真实输出:

< HTTP/1.1 200 OK < Content-Length: 11 < Connection: keep-alive < Set-Cookie: sid=abc123; Max-Age=3600; HttpOnly; SameSite=Lax; Path=/ < Set-Cookie: theme=dark; Max-Age=60 < cookie set

再看一个"更严格"的 Cookie(Secure+SameSite=Strict):

curl-svhttp://127.0.0.1/setcookie_secure

真实输出:

< Set-Cookie: token=xyz; Secure; HttpOnly; SameSite=Strict; Max-Age=120

属性逐条解读

属性作用生产建议
Max-Age=3600Cookie 存活秒数(相对值);另有Expires=绝对时间会话型用短时效,记住我功能才用长时效
HttpOnly禁止 JS 通过document.cookie读取防 XSS 偷 cookie,必须给认证 cookie 加
Secure仅通过 HTTPS 传输明文 HTTP 下带Secure的 cookie 不会被发送
SameSite=Lax/Strict/None控制跨站请求是否带 cookie现代浏览器默认Lax;跨站带凭证必须None且配合Secure
Path=/Cookie 生效路径范围一般给根路径

2.2 客户端回传:Cookie请求头 + curl 的-c/-b

服务端写完,curl可以用-c把 cookie 存进"jar"文件,再用-b在下次请求里回传:

curl-s-c/tmp/cj.txt-o/dev/null http://127.0.0.1/setcookieecho"----- 保存的 cookie jar -----"cat/tmp/cj.txt

真实输出:

# Netscape HTTP Cookie File # https://curl.se/docs/http-cookies.html # This file was generated by libcurl! Edit at your own risk. 127.0.0.1 FALSE / FALSE 1784955328 theme dark #HttpOnly_127.0.0.1 FALSE / FALSE 1784958868 sid abc123

注意sid那一行开头的#HttpOnly_前缀:curl 在 jar 里也标记了这个 cookie 是 HttpOnly,意思是即使它在文件里,JS 也读不到——这正是HttpOnly的安全意义。

回传请求(用--trace-ascii看发出的Cookie头):

curl-s-b/tmp/cj.txt --trace-ascii - http://127.0.0.1/setcookie

真实输出关键字节:

=> Send header, 113 bytes (0x71) 0000: GET /setcookie HTTP/1.1 0019: Host: 127.0.0.1 0042: Accept: */* 004f: Cookie: theme=dark; sid=abc123 # ← 浏览器/curl 自动回传 006f:

浏览器行为小结:

1. 响应里出现 Set-Cookie → 浏览器按属性存起来 2. 之后同域请求 → 自动在 Cookie 头里带上(无需 JS 参与) 3. HttpOnly 的 cookie → JS 读不到,但请求照常带(安全) 4. Secure 的 cookie → 非 HTTPS 页面不会发送 5. SameSite=Strict → 跨站(含跨域表单提交)请求不带,抗 CSRF

3. 二、同源策略与 CORS:浏览器为什么拦你

同源策略(Same-Origin Policy):浏览器禁止页面对"不同源"的资源做受保护读写。协议://域名:端口三者完全一致才同源。

CORS(Cross-Origin Resource Sharing)是服务端用一组响应头,显式授权哪些外源可以访问。

3.1 简单请求:带Origin头,服务端回Access-Control-Allow-Origin

curl-s-D--o/dev/null-H"Origin: http://120.46.193.105:8081"\http://127.0.0.1:8080/api

真实输出:

HTTP/1.1 200 OK Access-Control-Allow-Origin: http://120.46.193.105:8081 # ← 授权这个源 Access-Control-Allow-Credentials: true Access-Control-Allow-Methods: GET, POST, OPTIONS Access-Control-Allow-Headers: Content-Type, X-Custom

浏览器看到ACAO等于自己的源,于是放行。注意:我们没有带Origin时这个头也出现了——因为本实验的 nginx 配置是"无条件返回",这是不严谨的做法(正确做法是校验Origin是否在白名单内,再决定是否返回该头)。生产环境务必做白名单校验,否则等于对任何源开放。

3.2 预检请求(Preflight):OPTIONS先探路

当请求"不简单"(比如带自定义头X-Custom、或用PUT/DELETE、或Content-Type: application/json),浏览器会先发一个OPTIONS预检,问服务端"我能不能这么发",得到许可后才发真正的请求。

curl-s-i-XOPTIONS\-H"Origin: http://120.46.193.105:8081"\-H"Access-Control-Request-Method: POST"\-H"Access-Control-Request-Headers: X-Custom"\http://127.0.0.1:8080/api

真实输出:

HTTP/1.1 204 No Content Access-Control-Allow-Origin: http://120.46.193.105:8081 Access-Control-Allow-Methods: GET, POST, OPTIONS Access-Control-Allow-Headers: Content-Type, X-Custom Access-Control-Max-Age: 600 # ← 预检结果缓存 600 秒,期间不再预检

预检通过后再发真正的POST

curl-s-i-XPOST-H"Origin: http://120.46.193.105:8081"\-H"X-Custom: 1"-d'k=v'http://127.0.0.1:8080/api

真实输出关键行:

HTTP/1.1 200 OK Access-Control-Allow-Origin: http://120.46.193.105:8081 Access-Control-Allow-Credentials: true

CORS 流程 ASCII 图:

浏览器(8081 页面) 服务端(8080 API) │ │ │ ① OPTIONS 预检(问能不能发)│ │ ───────────────────────────► │ │ ② 204 + ACAO/ACAM/ACAH │ │ ◄─────────────────────────── │ │ ③ 真正 POST(带 Origin) │ │ ───────────────────────────► │ │ ④ 200 + ACAO(放行) │ │ ◄─────────────────────────── │

生产踩坑:预检失败最常见原因是 nginx 没放行OPTIONS方法,或没返回Access-Control-Allow-Headers里声明的自定义头。Access-Control-Allow-Origin不支持通配*Credentials: true同时出现——带凭证时必须写具体源。


4. 三、条件请求与 304:让"没变"的资源别再传一遍

浏览器/代理缓存了一份资源,下次怎么判断"服务端内容变没变"?靠条件请求:带上之前拿到的校验器,服务端比对,没变就回304 Not Modified不传包体

4.1 两类校验器:Last-Modified 与 ETag

curl-s-D--o/dev/null http://127.0.0.1/cond|grep-i"etag\|last-modified\|HTTP"

真实输出(首次请求,返回校验器):

HTTP/1.1 200 OK Last-Modified: Sat, 25 Jul 2026 04:51:01 GMT # ← 文件最后修改时间 ETag: "6a6440b5-63" # ← 实体标签(内容哈希/版本号)

4.2 带上校验器再请求 → 304

# 用 ETag 比对curl-s-o/dev/null-w'status=%{http_code}\n'\-H'If-None-Match: "6a6440b5-63"'http://127.0.0.1/cond# 用 Last-Modified 比对curl-s-o/dev/null-w'status=%{http_code}\n'\-H'If-Modified-Since: Sat, 25 Jul 2026 04:51:01 GMT'http://127.0.0.1/cond

真实输出:

status=304 status=304

两者都返回304响应没有包体,浏览器直接复用本地缓存,省下带宽与延迟。

4.3 校验器不匹配 → 200(返回新内容)

curl-s-o/dev/null-w'status=%{http_code}\n'\-H'If-None-Match: "deadbeef"'http://127.0.0.1/cond

真实输出:

status=200

ETag 对不上,服务端知道内容变了,于是正常返回200与新包体。

首次请求: GET /cond ─► 200 + Last-Modified + ETag + 包体 再次请求: GET /cond + If-None-Match:"xxx" ─► 304(无包体,用缓存) GET /cond + If-None-Match:"yyy"(不匹配) ─► 200 + 新包体

ETagLast-Modified更精确(秒级时间可能重合),优先级更高;二者可并存,服务端任一不匹配即回 200。


5. 四、Cache-Control:缓存的"宪法"

Cache-Control是控制缓存行为的核心响应头。我们在 nginx 上分别配置了不同指令,逐一实测。

5.1 各指令实测

forepinmaxage nocache nostore private;doecho"----- /cache/$ep-----"curl-s-D--o/dev/null http://127.0.0.1/cache/$ep|grep-i"cache-control\|HTTP"done

真实输出:

----- /cache/maxage ----- HTTP/1.1 200 OK Cache-Control: max-age=30, public ----- /cache/nocache ----- HTTP/1.1 200 OK Cache-Control: no-cache ----- /cache/nostore ----- HTTP/1.1 200 OK Cache-Control: no-store ----- /cache/private ----- HTTP/1.1 200 OK Cache-Control: private, max-age=60

5.2 指令语义

指令含义是否缓存
max-age=30缓存 30 秒内视为新鲜,直接用不询问是(时效内)
public任何缓存(浏览器、CDN、代理)都可存
private仅用户浏览器可存,共享缓存(CDN)不可仅私有缓存
no-cache可以缓存,但每次用之前必须回源校验(条件请求)是(强校验)
no-store禁止任何缓存,每次都重新下载

5.3 新鲜度(freshness)怎么算

缓存是否"新鲜"取决于:

响应时间 now │ │ ├──► fresh 区间 = max-age 秒 ──► 直接用缓存,不发请求 │ └──► 超过 max-age ──► 过期 ├─ no-cache / 带校验器 → 发条件请求,304 则继续用缓存 └─ no-store → 必须重新完整下载

客户端也可以主动提要求(注意:客户端的Cache-Control只约束客户端自己,不约束服务端):

# 客户端强制"必须去服务端校验",即便服务端说 max-age=30curl-s-D--o/dev/null-H"Cache-Control: no-cache"\http://127.0.0.1/cache/maxage|grep-i"cache-control"

真实输出:

HTTP/1.1 200 OK Cache-Control: max-age=30, public # ← 服务端头不变,客户端自行决定去校验

这点很关键:服务端下发的Cache-Control是给缓存的"建议",客户端请求里的Cache-Control才是"我现在的要求"。想强制刷新,前端/代理用Cache-Control: no-cacheCache-Control: max-age=0即可。


6. 五、重定向对 POST 的差异:301/302/303/307/308 到底差在哪

这是最容易翻车的地方。我们用一个会把 POST 包体原样回显的接口/form做重定向目标,这样能直接看到"方法有没有变、包体有没有丢"。

nginx 配置(注意目标指向/form):

location = /redir/301 { return 301 /form; } location = /redir/302 { return 302 /form; } location = /redir/303 { return 303 /form; } location = /redir/307 { return 307 /form; } location = /redir/308 { return 308 /form; }

curl -L跟随重定向,发送POST并带包体payload=1

forcodein301302303307308;doecho"=== POST /redir/$code==="curl-s-L-XPOST-d"payload=1"http://127.0.0.1/redir/$codeechodone

真实输出(服务端回显收到的包体):

=== POST /redir/301 === --- received body --- --- content-type --- === POST /redir/302 === --- received body --- --- content-type --- === POST /redir/303 === --- received body --- --- content-type --- === POST /redir/307 === --- received body --- payload=1 --- content-type --- application/x-www-form-urlencoded === POST /redir/308 === --- received body --- payload=1 --- content-type --- application/x-www-form-urlencoded

结论一目了然

301 / 302 / 303 → POST 被改成 GET,包体 payload=1 丢失(回显为空) 307 / 308 → POST 方法被保留,包体完整送达(回显 payload=1)

底层原因(之前curl -v抓到的关键日志):

* Switch from POST to GET ← 301/302 默认把 POST 转 GET * Re-using existing connection with host 127.0.0.1 > POST /form HTTP/1.1 ← 307/308 仍是 POST,且带 Content-Length > Content-Length: 9

语义对照表:

状态码语义对 POST 的默认行为典型用途
301永久移动改为 GET(多数客户端)站点永久换域名/路径
302临时移动改为 GET(多数客户端)临时跳转
303见其它强制改 GET提交后跳结果页,防刷新重复提交
307临时移动保留原方法临时跳转且需保留 POST
308永久移动保留原方法永久换址且需保留 POST

关键提醒:301/302POSTGET历史浏览器行为,并非规范强制;curl-L也遵循此默认(可用--post301/--post302改变)。若你的接口需要重定向后仍是 POST(如支付回调),务必用307308,否则参数会丢——这正是引言里那个坑。


7. 六、DNS 解析:域名是怎么变成 IP 的

HTTP 请求的第一步其实是 DNS。我们用真实命令走一遍。

7.1 本地 hosts 优先

echo"127.0.0.1 mylocal.test">>/etc/hosts getent hosts mylocal.testcurl-s-o/dev/null-w'mylocal.test -> %{http_code}\n'http://mylocal.test/

真实输出:

127.0.0.1 mylocal.test mylocal.test -> 200

解析顺序(Linux 由/etc/nsswitch.confhosts:决定,通常files dns,即先查/etc/hosts,再查 DNS)。

7.2 A 记录与 CNAME 记录

dig+short A example.comdig+short CNAME www.microsoft.com

真实输出:

104.20.23.154 172.66.147.243 www.microsoft.com-c-3.edgekey.net.
  • A记录:域名 → IPv4 地址。
  • CNAME记录:别名 → 另一个域名(如www.microsoft.comedgekey.net的别名,便于 CDN 调度)。

7.3dig +trace:完整迭代解析过程

dig+trace +noall +answer example.com

真实输出(节选,已标注角色):

. 123997 IN NS a.root-servers.net. # ① 根服务器(.) ...(共 13 个根 NS) ;; Received 239 bytes from 127.0.0.53#53 # 本地解析器 ;; Received 1171 bytes from 198.97.190.53#53 # ② 根服务器返回 .com 的 gTLD 服务器 ;; Received 506 bytes from 192.52.178.30#53 # ③ gTLD 返回 example.com 的权威服务器 example.com. 300 IN A 172.66.147.243 # ④ 权威服务器给出最终 A 记录

完整链路 ASCII 图:

浏览器/应用 │ ① 查询 根(.) → 返回 .com 的 gTLD 服务器地址 │ ② 查询 gTLD(.com) → 返回 example.com 的权威 DNS 地址 │ ③ 查询 权威服务器 → 返回 A 记录 172.66.147.243 ▼ 拿到 IP,HTTP 才真正开始建连

生产提示:DNS 解析失败或污染会直接导致"连接超时"而非"连接拒绝";dig +trace是定位解析链路问题的利器。TTL 决定记录缓存时长,改 DNS 后全球生效时间 ≈ 原 TTL。


8. 生产实践建议(Checklist)

  1. 认证 Cookie 必加HttpOnly+Secure+SameSiteHttpOnly防 XSS 窃取;跨站带凭证用SameSite=None; Secure
  2. CORS 做白名单而非无脑回*:校验Origin,命中才返回Access-Control-Allow-Origin;带凭证时不要与通配符混用;别忘了放行OPTIONS预检。
  3. 静态资源配ETag/Last-Modified+Cache-Control: max-age,让未变更资源走304,省带宽。
  4. 缓存分级:HTML 用no-cache(每次校验),带指纹的 JS/CSS 用max-age=31536000, public(强缓存)。
  5. 重定向选码要看方法:需保留POST307/308;提交后跳转结果页用303防重复提交。
  6. 上线前用curl -v -H "Origin: ..."自测 CORS、用dig +trace验证 DNS

9. 总结

本篇用真实报文把四个"前端暗战"讲透了:

  • CookieSet-Cookie通过Max-Age/HttpOnly/Secure/SameSite控制生命周期与安全边界;curl -c/-b让你像浏览器一样存/带。
  • 同源与 CORS:同源看协议+域名+端口;跨域靠Access-Control-Allow-Origin等头授权,非简单请求先走OPTIONS预检。
  • 条件请求Last-Modified/ETag+If-Modified-Since/If-None-Match,没变就304,不传包体。
  • 缓存max-age/public/private/no-cache/no-store各司其职;新鲜度由max-age计算,客户端可主动no-cache强制校验。
  • 重定向语义301/302/303默认把POSTGET丢包体,307/308保留方法——选错码就是线上事故。
  • DNS/etc/hosts优先于 DNS;解析链路是 根 → gTLD → 权威,dig +trace可全程观测。

下一篇我们进入WebSocket:看它如何在一次 HTTP 握手上升级成全双工长连接,并逐字节拆解帧格式与掩码机制。


实验服务器公网 IP:120.46.193.105。本文命令均在该机真实执行,响应头/状态码/解析结果均为原始输出,未伪造。