Cookie 的行李检查
发车铃一响,Cookie 们扛着箱子集体冲门。有人冒充远房亲戚,有人过期了还不肯退休,免费抽奖页面甚至偷偷推进一辆转账车。检查员踩下闸门:“今天谁也别想蒙混上车!”
发车前,全乱了
本文承接上一篇:《货都到了,海关却不放行》。
“去 api.example.com 的请求车,三十秒后发车!”
广播刚响,出发大厅就炸了。
一群 Cookie 扛着大包小包,从拐角处滚了出来。
“等等我!”
“先让我上,我是登录状态!”
“我管字体大小,也很重要!”
“谁踩到我的巧克力豆了?”
车门只开了一条缝,十几只 Cookie 已经挤成了一张饼。
最前面的 Cookie 被压在玻璃上,艰难地举起工牌:
session_id=8f31a2
“我……快……碎了……”
“都退后!”
行李检查员从值班台上一跃而下,一手拍停发车倒计时,一脚踩下闸门。
“砰!”
Cookie 队伍刹车不及,叠成了一座饼干山。
JavaScript 老大端着咖啡赶来,看到满地饼干屑,默默把吸管盖上了。
“不就是发个请求吗?” 老大问,“都是 Cookie,让它们一起走呗。”
检查员转过头:“你出差会把家里的冰箱、沙发和楼下保安一起托运吗?”
“不会。”
“那请求去一趟服务器,为什么要带上所有 Cookie?”
Cookie 山里传出一个微弱的声音:“因为人多热闹……”
检查员吹响哨子。
“排队。今天一只一只查!”
小八从哪里来
排在第一位的,是刚才差点被挤碎的 session_id。
检查员看了一眼工牌:“名字太长,叫你小八吧。”
“为什么?”
“值是 8f31a2。”
“这名字取得是不是有点草率?”
“后面还有十二只饼,没空给你翻字典。”
小八抱紧工牌。这张牌是服务器刚刚发来的。
用户登录成功后,api.example.com 的服务器在响应里塞进了一句话:
Set-Cookie: session_id=8f31a2; Path=/; Secure; HttpOnly; SameSite=Lax
小八数了数分号后面的吊牌:“Path、Secure、HttpOnly、SameSite……我的配饰比巧克力豆还多。”
“那不是配饰,是你今天能不能上车的检查项目。”
小八的笑容消失了。
浏览器看到 Set-Cookie,便把小八和随身规则一起收进 Cookie 仓库。
等下一辆符合条件的请求车出发,浏览器再把小八安排进请求头:
Cookie: session_id=8f31a2
JavaScript 老大探头看了看:“这行可不是我写的。”
“本来就不是。” 检查员说,“条件符合,浏览器自然会把 Cookie 放进请求头。”
“那我想加哪只就加哪只?”
“不行。”
“自己手写 Cookie 请求头?”
“不行。”
“从响应里把 Set-Cookie 抄出来?”
“也不行。”
JavaScript 老大端起咖啡:“这段对话里,我的权限很稳定。”
“稳定地没有。”
上一回,小八随符合条件的请求抵达服务器。档案员接过工牌一查:
8f31a2 -> 已登录的 42 号用户
“找到了!” 服务器档案员喊道。
小八得意地挺起肚子:“看见没,我里面装着整个用户!”
“胡说。” 检查员在小八脑门上敲了一下,“你通常只是一张难以猜中的会话标识。真正的用户资料和登录状态,常常放在服务器的 Session 里。”
“那我算什么?”
“寄存牌。”
“Session 呢?”
“服务器后面的行李仓库。”
小八的肚子瘪了下去:“原来我不是仓库。”
“你这身材也装不下。”
小八想了想:“所以 Cookie 是浏览器保管的寄存牌,Session 是服务器维护的状态仓库。我们经常搭档,但不是一个东西。”
“答对。”
“也有人把状态直接装进 Cookie,对吧?”
“有。但要防篡改、防泄露、管失效,还得看看你那点肚量够不够。”
小八拍了拍肚子:“解释得很好,最后半句可以删掉。”
冒认亲戚的 Cookie
下一只 Cookie 一上来,就展开一张巨大的族谱。
“检查员好!我是 example.com 家族的,去哪里都算回家。”
检查员接过卡片:
Set-Cookie: language=zh-CN; Domain=example.com
“这张可以发往 example.com,也可以发往匹配的子域名,例如 api.example.com。”
“看吧!” 亲戚饼回头招手,“表哥表弟堂叔二舅,都跟我上车!”
一只小 Cookie 立刻挤过来,卡片上却写着:
Set-Cookie: theme=dark
检查员问:“谁设置的?”
“api.example.com。”
“没写 Domain,那就是仅限主机。你只回 api.example.com,不能跑去 www.example.com 串门。”
“我们都姓 example!”
“同姓的多了。没写进族谱,就别硬认。”
队尾有只 Cookie 眼珠一转,趴在地上偷偷改卡片:
Domain=other-company.com
“改好了!” 作弊饼一跃而起,“我现在是别人家公司的人了!”
检查员看都没看,直接把作弊饼拎出队伍。
“服务器只能把 Cookie 设给自身所在的域,或符合规则的上级域。不能隔空给别的网站塞 Cookie,更不能把 com 这种公共后缀承包下来。”
作弊饼还不服:“那我写 .example.com,前面加个点,显得资历老。”
“现在开头的点会被忽略。”
“白加了?”
“墨水倒是浪费得很有年代感。”
小八在旁边记笔记:“不写 Domain,只认设置自己的主机;写了合适的 Domain,匹配的子域名也可能收到。”
“你的工牌没写 Domain。” 检查员提醒。
小八立刻在胸口贴了一张纸:“api.example.com 原住民,谢绝乱认亲戚。”
检查员满意地点头:“下一只。”
走错门还怪走廊
一只戴着领带的 Cookie 气势汹汹走来。
“我赶时间,去 /shop/checkout,麻烦放行。”
检查员翻过领带饼的背包牌:
Path=/account
“不放。”
“主机匹配!”
“路径不匹配。”
“都是一栋楼!”
“你的门禁只开 /account。去 /account/profile 没问题,跑去 /shop/checkout 做什么?”
“买东西。”
“用账户设置 Cookie 去结账?”
“顺路。”
“出去。”
领带饼抱住闸门:“这是路径歧视!”
检查员一根手指把领带饼推回等候区。
“Path 只是决定请求路径匹配时要不要携带。没写 Path,浏览器会根据设置 Cookie 的请求路径计算默认值;想让整个站点都能带上,通常明确写 Path=/。”
JavaScript 老大突然眼睛一亮:“那我把机密 Cookie 放在 /secret,别的路径就绝对看不见了?”
检查员的眼神瞬间冷下来。
“Path 是分流牌,不是保险柜。别拿它做可靠的安全隔离。”
老大迅速把刚写了一半的方案揉成纸团。
“今天的咖啡可能兑了乐观。”
小八低头看自己的 Path=/:“我这张是整栋楼通行?”
“对。但还是那句话,通行范围不是保险柜。”
小八又添一行笔记:“能进门,不等于能翻抽屉。”
过期饼拒绝退休
一只拄着拐杖的优惠券 Cookie 从回收箱里爬出来,颤颤巍巍挪到检查台。
“年轻人,给个座。”
检查员看了一眼优惠券 Cookie 的工牌:
Set-Cookie: coupon=half-price; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT
“你不是到期,是已经进历史博物馆了。”
“胡说!” 优惠券 Cookie 把工牌上的年份捂住,“我只是长得比较复古。”
检查员掀开优惠券 Cookie 的帽子,里面还藏着一只倒计时器:
Max-Age=3600
“还准备现场续命?”
优惠券 Cookie 抱住倒计时器:“Expires 写到期时刻,Max-Age 写还能活多少秒。我只是想选个对自己有利的。”
“两个同时设置时,Max-Age 优先。可惜这只倒计时器根本不是服务器给你的,没收。”
优惠券 Cookie 急了:“就不能尊老吗?”
“能送你回回收箱。”
“打扰了。”
小八赶紧检查自己的工牌:“我没有 Expires,也没有 Max-Age。难道我长生不老?”
“你是 Session Cookie,通常保留到当前浏览器会话结束。”
“那关掉浏览器,我就消失?”
“不一定。‘会话结束’由浏览器定义,有些浏览器恢复上次会话时,也可能把 Session Cookie 一起恢复。”
小八沉思片刻:“所以我的退休日期由公司解释。”
“终于理解上班了。”
JavaScript 老大指着赖在地上的优惠券 Cookie:“如果服务器想主动让它退休呢?”
“用相同的名称、Domain 和 Path 覆盖,再把 Max-Age 设成 0 或负数。”
Set-Cookie: coupon=; Path=/; Max-Age=0
优惠券 Cookie 抱紧检查台:“工牌都清空了,还不能返聘吗?”
检查员按下清理按钮。
“噗。”
地上只剩一小撮饼干屑。
小八离得太近,被扬起的饼干灰呛得连打三个喷嚏。再睁眼时,大厅前方已经分出了两条隧道。
安全带不是隐身衣
http://example.com
https://example.com
小八系着刚领到的金色安全带,却偷偷往 http 通道挪。
走一步。
停一下。
再走一步。
检查员从背后按住小八的肩膀:“去哪?”
“散步。”
“背着行李散步?”
“这条隧道看起来近一点。”
检查员指了指安全带上的标记:
Secure
“带了 Secure,通常只能通过 HTTPS 等安全通道发送。明文线路不能上。”
小八拍拍安全带:“它会把我加密成别人看不懂的形状吗?”
“不会。Secure 管的是传输通道,不是把 Cookie 自己变成密码箱。”
“那它为什么叫安全带?”
“因为叫‘只准走安全通道但不负责解决其他全部安全问题带’太长。”
话音刚落,小八工牌上的 HttpOnly 闪了一下。一件黑斗篷“唰”地从天花板落下,把小八裹得严严实实。
JavaScript 老大立刻打开控制台:
console.log(document.cookie);
其他 Cookie 的名字都出现了,小八却没留下任何影子。
“忍者?”
“HttpOnly。” 小八躲在斗篷里压低声音。
“JavaScript 不能通过 document.cookie 读取或修改它。” 检查员说。
老大伸手在空气里摸了两下:“看不见,那它还能上请求车?”
小八从斗篷里探出脑袋:“看不见,不耽误我等会儿上车。”
“HttpOnly 是不让脚本接触,不是不让 HTTP 请求携带。” 检查员解释,“其他条件符合时,即使请求由 fetch 发起,浏览器仍可能把它带上。”
“那有了 HttpOnly,XSS 就彻底没用了?”
小八借着斗篷掩护,“唰”地拔出一把玩具短刀。
“它能降低会话 Cookie 被恶意脚本直接偷走的风险,但消灭不了 XSS。” 检查员夺走短刀,“恶意脚本仍可能借当前页面发请求、读取页面内容,或者把大厅搅得一团糟。”
小八小声嘀咕:“斗篷刚穿三秒,道具就被没收了。”
免费抽奖送来一辆转账车
检查进行到一半,大厅屏幕突然弹出一张花花绿绿的广告:
恭喜!点击领取免费大奖
JavaScript 老大条件反射地抬手。
检查员一巴掌拍掉鼠标:“这种东西也点?”
“职业习惯,测试一下。”
“职业生涯差点测试没了。”
广告来自一个陌生页面。页面戴着墨镜,从角落里伸出一只白手套,偷偷推出一辆请求车。
车头挂着“免费领奖”,车尾真正的目的地却是:
https://bank.example/transfer
更奇怪的是,一只银行的登录 Cookie 正迷迷糊糊往车上走。
检查员冲过去,一把揪住登录 Cookie:“谁叫你上车的?”
“浏览器呀。” 登录 Cookie 揉揉眼睛,“目的地是 bank.example,我的主机和路径都匹配。不是说匹配就自动带上吗?”
“可这辆车是从陌生站点发起的!”
登录 Cookie 瞬间清醒:“对方又不知道我的值,怎么利用我?”
“根本不需要。” 检查员指向那只白手套,“陌生页面没打算偷工牌,只想借浏览器的手,把工牌送上这辆坏车。”
登录 Cookie 一旦自动跟车,服务器就可能把伪造请求当成用户本人操作。
“借浏览器的手伪造请求……” JavaScript 老大盯着转账车。
小八从斗篷里探头:“借刀杀 Cookie?”
“少改成语。”
“这就是 CSRF 的基本把戏。”
检查员按下警铃。侧门“哐”地弹开,三位 SameSite 门卫争先恐后地挤了出来。
“别挤!”
“明明是你踩我鞋!”
“上班第一件事,先审你们三个。” 检查员扶住额头。
三个门卫吵起来了
第一位门卫叫 Strict,眉毛比闸门还直。
SameSite=Strict
“跨站来的,一律不带 Cookie!”
JavaScript 老大递给它一张普通链接:“用户只是在别的网站点这个链接,跳进银行查看余额。”
Strict 把链接撕成两半:“不带!”
“用户明明登录过。”
“不带!”
“通融一下?”
“再问,把你咖啡也扣下。”
JavaScript 老大默默后退。
“Strict 最严。哪怕是从外站点击普通链接进入,首次请求也不带这只 Cookie。” 检查员说,“安全,但有时会让用户觉得登录状态突然断了。”
第二位门卫 Lax 看不下去了。
SameSite=Lax
“普通链接是用户自己点的,又是去顶层页面,可以谈。”
Strict 瞪它:“你不严格!”
“我叫 Lax,本来就不严格。”
Lax 在地上画了一扇窄门。
“跨站 fetch、图片等子资源请求,还有偷偷提交的 POST,大多别想带 Cookie。用户点击普通链接这类使用安全方法的顶层导航,通常可以通过。”
小八摸了摸自己的 SameSite=Lax 吊牌:“原来我归你这队。”
Lax 拍拍小八:“放心,我主打一个该严的时候不松,该松的时候不全松。”
Strict 冷笑:“说了等于没说。”
那辆转账车试图挂上 GET 的牌子。
检查员抬脚把牌子踹飞:“服务器也不该让 GET 做转账这类修改数据的事。”
转账车灰溜溜退了回去。
第三位门卫 None 姗姗来迟,胸前挂着“都可以谈”的牌子。
SameSite=None; Secure
“跨站也能带,但必须同时系好 Secure 安全带。”
一只没系安全带的 Cookie 冲过来:“我也选 None!”
None 侧身让开。
Cookie 以为自己获准了,一头撞在后面的安全门上。
“我只是名字叫 None,不是说规则一条都没有。”
队尾又有只 Cookie 弱弱举手:“我没写 SameSite,算哪队?”
检查员看了看空荡荡的胸口:“现代浏览器通常会把你往 Lax 的方向安排,但默认细节可能不同。重要 Cookie 最好自己把策略写清楚,别穿着空白工牌来猜分组。”
那只 Cookie 当场借了支笔。
JavaScript 老大想起上一篇:“如果是跨源 fetch,还要设置 credentials: "include"。服务端的 CORS 响应也得明确允许来源和凭证。”
浏览器海关从远处探出头:“终于有人记住我了!”
“CORS 决定脚本能不能读取跨源响应,Cookie 规则决定某只 Cookie 有没有资格随请求走。” 检查员说,“两边各查各的。别拿一张通行证闯两道门。”
同源双胞胎认错人
浏览器海关听见“跨源”两个字,顺手把两位长得几乎一样的地址推到检查台前:
https://app.example.com
https://api.example.com
JavaScript 老大指着两个地址:“主机不同,跨源!”
“对。”
“那一定跨站!”
“错。”
“怎么又错?”
检查员在地上画了两个不同大小的圈。
“判断‘源’,协议、主机和端口都要对。判断 SameSite 里的‘站点’,圈画得更大,通常围绕协议和可注册域名。”
JavaScript 老大把两个地址分别塞进圈里,又拿出来,再塞进去。
“别玩了。” 检查员夺回粉笔,“结论就是:app.example.com 和 api.example.com 可以跨源,却仍然同站。”
两只地址同时转头:“所以我们到底算不算一家?”
“去 CORS 海关,你们是两家。”
“来 Cookie 检查站呢?”
“SameSite 这件事上,通常算同站。”
两只地址抱头痛哭:“大人的世界为什么不能只有一张登记表?”
检查员又指向大厅上方的隐私总开关。
“还有,就算写了 SameSite=None; Secure,浏览器的第三方 Cookie 策略、存储分区、用户设置或扩展也可能继续拦截。Cookie 上写的是申请条件,不是命令浏览器必须放行。”
None 摘下胸前的牌子,在背面补了一行小字:“最终解释权归浏览器。”
抓住坏车,不等于银行可以睡觉
免费抽奖送来的转账车被拖走后,Cookie 队伍开始欢呼。
SameSite=Lax 站上箱子,清了清嗓子:“感谢大家,是我独自解决了 CSRF——”
检查员把箱子抽走。
Lax 一屁股坐到地上。
“你只是其中一道门。”
“还有谁?”
“服务器。”
服务器档案员已经给自己盖好毯子,听见点名,只好不情不愿地爬起来。
检查员敲着窗口问:“转账该用 GET 吗?”
“不该。”
“CSRF Token 要验吗?”
“要。”
“Origin 等信号要查吗?”
“要。”
“高风险操作呢?”
“按业务再确认。”
“同站点下要是住着不可信的子域名呢?”
档案员把毯子叠了起来:“只靠 SameSite 也未必够。”
“还有 CORS!” 浏览器海关又举起扩音器。
“CORS 也不是 CSRF 保镖。” 检查员喊回去,“攻击页面读不到响应,不代表请求造成的结果会自动撤销。”
服务器档案员从窗口里探出头:“所以浏览器负责少带可疑行李,我负责确认业务到底该不该办?”
“对。”
“那我不能睡了?”
“从来没人批准你睡。”
隔壁仓库来抢生意
服务器档案员刚把“Cookie 不是整个仓库”写进值班记录,门外就传来轰隆隆的声音。
localStorage 推着一座巨大的移动仓库进来了。
后面跟着 sessionStorage,也推着一个小仓库。
“听说这里存数据?” localStorage 把广告牌插到地上,“空间宽敞,存取方便,长期租赁,价格面议!”
Cookie 队伍立刻不高兴了。
“抢生意?”
“你们随匹配请求反复上车,又小又挤。” localStorage 拍拍仓库,“偏好设置、草稿、较大的客户端数据,凭什么都塞你们肚子里?”
小八怒了:“那登录请求你来跟车!”
localStorage 后退两步:“我不跟车。”
“浏览器不会把 Web Storage 自动塞进 HTTP 请求。” 检查员说,“网页脚本要用时,自己读取,自己决定怎么处理。”
localStorage 打开大门:“我的数据按源保存,通常关掉再打开浏览器也还在,直到被清理或受到浏览器存储策略影响。”
sessionStorage 打开自己的小门:“我还按标签页区分。刷新通常还在,关掉对应标签页后,这段会话数据通常结束。”
JavaScript 老大看看小八,又看看两个仓库。
“那把登录凭证全放 localStorage,就没有 Cookie 那些麻烦了?”
localStorage 的笑容僵住了。
检查员敲了敲仓库门:“能在页面里运行的脚本,通常也能读取 Web Storage。真遇到 XSS,门可能还没锁,东西就被搬走了。”
“那到底选谁?” JavaScript 老大问。
“看架构,看威胁模型。谁的广告牌大,不算技术指标。”
localStorage 默默拔掉广告牌。
“今天这个大厅不适合做推广。”
最后点名
广播终于再次响起:
“受饼干堵门影响,本车晚点。由 https://app.example.com 发往 https://api.example.com/account/profile 的请求车,现在重新检票!”
JavaScript 老大把发车单举过头顶:
fetch("https://api.example.com/account/profile", {
credentials: "include"
});
“跨源携带凭证,已经申请!”
浏览器海关也举起服务器发来的许可:“允许来源和凭证的 CORS 手续齐全,我这边放行!”
检查员敲敲桌子:“海关放行只代表外部手续办完。小八能不能上车,还得看自己的工牌。”
检查员拿起名单。
“小八,Domain?”
“没写,只会设置我的 api.example.com,绝不乱认亲戚!”
“Path?”
“/,整栋楼通行,但不负责保管抽屉!”
“有效期?”
“Session Cookie,当前会话还没下班!”
“Secure?”
“目的地是 HTTPS,安全带已系好!”
“HttpOnly?”
“JavaScript 老大看不见我!”
JavaScript 老大四处张望:“谁在说话?”
小八躲在斗篷里笑出了声。
“SameSite?”
“Lax!这趟虽然跨源,但仍然同站!”
闸门终于升起。
小八拖着箱子跳上请求车,又从窗口探出头:“所以 Cookie 存在浏览器里,不代表每次都能上车?”
“对。”
“上了车,也不代表服务器必须相信?”
“对。”
“那我这个工作风险还挺高。”
“别感慨了,后面还有饼排队。”
请求车驶进隧道。
亲戚认错的、路径走错的、车票过期的、没系安全带的,全被留在站台上。浏览器还可能根据隐私策略继续缩小队伍。
JavaScript 老大看着远去的车尾:“原来 Cookie 不是请求一出发就全家搬迁,而是每次都要重新点名。”
“总算听懂了。” 检查员低头扫起饼干屑,“下次让 Cookie 们先排队。”
就在这时,旁边的运输线传来一阵陌生引擎声。
TCP 老司机探出车窗,盯着一辆挂着 HTTP/3 货牌的新车。那辆车没进 TCP 车道,反而拐进了 UDP 的专用通道。
“等等!” TCP 老司机猛按喇叭,“HTTP 运货,怎么不坐我的车了?”
新车没有减速,只从窗口丢下一张名片。
上面写着:
QUIC