Cookie 的行李检查

发车铃一响,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

小八数了数分号后面的吊牌:“PathSecureHttpOnlySameSite……我的配饰比巧克力豆还多。”

“那不是配饰,是你今天能不能上车的检查项目。”

小八的笑容消失了。

浏览器看到 Set-Cookie,便把小八和随身规则一起收进 Cookie 仓库。

等下一辆符合条件的请求车出发,浏览器再把小八安排进请求头:

Cookie: session_id=8f31a2

JavaScript 老大探头看了看:“这行可不是我写的。”

“本来就不是。” 检查员说,“条件符合,浏览器自然会把 Cookie 放进请求头。”

“那我想加哪只就加哪只?”

“不行。”

“自己手写 Cookie 请求头?”

“不行。”

“从响应里把 Set-Cookie 抄出来?”

“也不行。”

JavaScript 老大端起咖啡:“这段对话里,我的权限很稳定。”

“稳定地没有。”

上一回,小八随符合条件的请求抵达服务器。档案员接过工牌一查:

8f31a2 -> 已登录的 42 号用户

“找到了!” 服务器档案员喊道。

小八得意地挺起肚子:“看见没,我里面装着整个用户!”

“胡说。” 检查员在小八脑门上敲了一下,“你通常只是一张难以猜中的会话标识。真正的用户资料和登录状态,常常放在服务器的 Session 里。”

“那我算什么?”

“寄存牌。”

“Session 呢?”

“服务器后面的行李仓库。”

小八的肚子瘪了下去:“原来我不是仓库。”

“你这身材也装不下。”

小八想了想:“所以 Cookie 是浏览器保管的寄存牌,Session 是服务器维护的状态仓库。我们经常搭档,但不是一个东西。”

“答对。”

“也有人把状态直接装进 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:“如果服务器想主动让它退休呢?”

“用相同的名称、DomainPath 覆盖,再把 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.comapi.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