简介cookie-stealer 是一份面向JavaScript初学者与Web安全入门者的轻量示例包围绕浏览器端Cookie读取与本地服务配合展开可用于受控实验环境中的原理演示、代码阅读与前后端交互练习。压缩包共7个文件以3个JavaScript脚本为核心辅以JSON配置、HTML页面、Markdown说明和Python服务端脚本整体约33KB体积小巧便于快速解压查看目录结构与各模块职责。已有186人学习适合作为小型安全实验素材。读者可从中了解浏览器扩展清单配置、前端脚本组织、简单本地服务启动方式及README中的使用说明并据此梳理Cookie相关机制在示例项目中的实现脉络为后续安全编码与风险防范提供参考。1. 拆开一份“Cookie 窃取”脚本二十行 JavaScript 为什么就够用拿到这类仓库的第一反应往往是“就这”因为核心逻辑确实只有一行把document.cookie读出来再发往别处。它能成立的前提只有一个——浏览器会把所有非 HttpOnly 的 Cookie 完整暴露给当前页面上的任意 JavaScript不管这段脚本是你写的还是被别人塞进来的。换个角度说这份代码的真正价值不在于“拿去用”而在于当一个反面样本它把“哪些 Cookie 在前端是裸奔的”这个问题用最直接的方式摆到了台面上。所以接下来的内容不写怎么偷写的是为什么能被偷、在哪一层堵、堵完怎么验证。适合三类人看写业务前端但从来没细看过Set-Cookie属性的、做后端会话管理的、以及需要给内部做安全培训却缺一个能跑起来的样本的。只背三个属性名不够调试的时候照样翻车理解攻击面才知道每个参数为什么必须那么配。2. document.cookie 的可见性边界同源、Path 与 Domain 到底放行了什么2.1 同源策略管得住跨域读取管不住被注入的脚本很多人对同源策略有一个模糊的安心感A 站的脚本读不到 B 站的 Cookie嘛。这句话本身没错但它描述的只是“跨源读取”这一个方向。真正出事的时候攻击者的脚本根本不需要跨源——它通过 XSS、被篡改的第三方 CDN 脚本、或者一个权限过宽的浏览器扩展直接跑在目标页面的上下文里。这时候它和目标站点自己的业务代码没有任何区别document.cookie对它来说就是本源的。这就是为什么 HttpOnly 的设计不是“加密”而是“不给你”。浏览器在 API 层面直接把这个 Cookie 从document.cookie的返回串里摘掉脚本连看都看不到谈不上往外发。理解这一点很关键后面所有的防护配置本质上都是在回答“这个值有没有必要让前端看见”。会话标识没有理由让前端 JS 读所以一律 HttpOnly而某些确实需要前端读的偏好项才考虑放行。还有一个容易被忽略的行为细节document.cookie是访问器属性读取时返回的是当前文档可见的全部 Cookie 拼成的字符串同名但 Path 或 Domain 不同的 Cookie 会同时出现在里面。排错时这条信息极其有用——如果你在某个路径下看到两个同名的sid那基本可以断定历史版本和当前版本的 Path 配重了。2.2 Path、Domain、Expires 三个属性怎样悄悄放大可见范围这几个属性单独看都很简单组合起来才是事故现场。下面这张表是我在审计里最常用的对照表左边是属性语义右边是实际踩过的误配。属性语义常见误配直接后果Path仅该路径前缀下的页面可读图省事一律写/全站任何页面含静态页、错误页都能读到Domain允许哪些主机共享该 Cookie写成.example.com让子域共享任一子域被拿下主站会话跟着走Max-Age/Expires持久化时长直接给一年泄露后的有效窗口被拉长到不可接受Secure仅 HTTPS 链路传输缺失明文链路或降级请求中被截获HttpOnlyJS 不可读缺失document.cookie直出SameSite跨站请求是否携带设None却没配Secure被浏览器直接拒绝登录态莫名丢失Domain这一行值得单独展开。把 Cookie 写在父域上本意是让a.example.com和b.example.com共享登录态省掉单点登录的改造。但共享是双向的任何一个子域上的老旧系统、测试环境、临时上线的活动页被打穿攻击者拿到的就是父域会话。我见过某内部系统里一个几乎没人维护的报表子域用的是三年前的框架版本最后成了整个域名的突破口。反过来如果每个子域各写各的 Cookie虽然要处理跨域登录但故障半径被压到了单个子域。Max-Age同理。会话 Cookie不设过期时间在浏览器关闭后失效窗口天然短一旦设成 30 天等于给泄露出去的凭据发了张月卡。业务上确实有“记住我”的需求那就把这个长有效期拆成独立的、权限受限的 refresh 凭据而不是把主会话本身延长。2.3 一段自检脚本先看清自己站点上能被 JS 读到的到底有哪些在动手改配置之前先摸清现状。下面这段脚本可以直接粘到浏览器开发者工具的 Console 里跑它做的事只有一件列出当前文档下 JavaScript 能读到的 Cookie 名称和值长度。// 用途盘点当前页面下 JavaScript 可读取的 Cookie即未被 HttpOnly 保护的部分 // 运行位置浏览器 DevTools Console或内部审计插件的注入脚本 (function auditReadableCookies() { const raw document.cookie; // 只包含非 HttpOnly 项HttpOnly 的根本不会出现在这里 if (!raw) { console.log([audit] 当前文档没有 JS 可读的 Cookie); return []; } const list raw.split(;).map(pair { const trimmed pair.trim(); const idx trimmed.indexOf(); // 只记录名称和值的字符长度绝不输出明文 return idx -1 ? { name: trimmed, valueLength: 0 } : { name: trimmed.slice(0, idx), valueLength: trimmed.length - idx - 1 }; }); console.table(list); console.log([audit] 可读 Cookie 数量:, list.length); // 返回名称数组方便在后续脚本里做白名单比对 return list.map(item item.name); })();逻辑说明document.cookie的返回值本身就已经过滤掉了 HttpOnly 项所以这份列表天然就是一份“风险清单”——出现在里面的每一项都意味着它有可能被注入脚本带走。用split(;)切分再定位第一个是因为 Cookie 值里可能含有等号比如 base64 编码的 token必须只切第一个。参数与注意点这段脚本没有任何外部依赖不发起网络请求也不改变任何状态。唯一需要克制的是输出内容——只打名称和长度不要console.log(document.cookie)。不少前端项目接了日志采集 SDK会把这些 console 输出一并上报审计动作本身反而成了泄露源这个坑在第 5 章还会再提一次。建议在测试环境的测试账号下执行避免在生产环境留下痕迹。2.4 读不到不等于安全还有四条侧路把 HttpOnly 配齐之后很多人会松一口气觉得document.cookie读不到就万事大吉。实际上会话凭据流出页面上下文的路径远不止这一条下面四条在真实事件里都出现过。第一条是服务端日志。请求进来时为了排查问题把整个 Cookie 头打进 access log日志再进集中采集平台能看日志的人就都能看会话。第二条是 URL 传参。把 token 拼在查询串里页面里任何一个外链、任何一张图片、任何一个第三方统计脚本都能通过 Referer 把它带出去。第三条是第三方脚本对fetch/XMLHttpRequest的包装Authorization 头一样能读。第四条是浏览器扩展权限足够时能读的范围比页面脚本大得多。所以 HttpOnly 是必要不充分条件。真正稳的做法是分层传输层有 Secure页面层有 HttpOnly 和 CSP会话层有绑定和轮换日志层有脱敏。任何一层单独拿出来都不够。3. 服务端把三件套配到位HttpOnly、Secure、SameSite 的落地与验证3.1 三个属性的作用边界与不可替代性这三个属性经常被当成一组来背但它们解决的是完全不同层的问题配错任何一个都不会报错只会静默失效。HttpOnly是浏览器层面强制执行的硬开关一旦设置任何脚本途径都拿不到这个值没有绕过方法。它防的是“页面上下文被控制”这一类问题包括 XSS、恶意第三方脚本、扩展注入。它不防传输层截获也不防服务端自己把值写进日志。Secure是传输层要求浏览器只会在 HTTPS 请求里带上这个 Cookie。它防的是明文链路和协议降级。需要注意的是它不保证服务端到上游之间也是加密的那一段要靠反向代理的配置来管。SameSite是跨站请求策略控制的是“从别的站点发起的请求要不要带这个 Cookie”。它主要针对 CSRF但对会话劫持没有直接作用。三个取值里Strict最严任何跨站请求都不带Lax是当前主流浏览器的默认值顶层导航的 GET 会带None必须配合Secure否则浏览器直接丢弃。优先级上HttpOnly和Secure应该无条件开启SameSite则要根据业务里的跨站回调场景来选。后面第 5 章会专门讲SameSite收紧之后回调掉登录态这件事。3.2 应用层落地把属性写进会话中间件以 Node.js 生态里最常见的会话中间件为例配置项集中在cookie对象里。下面这段是可直接抄的骨架。// Node.js Express 示例统一为会话 Cookie 设置安全属性 const session require(express-session); app.set(trust proxy, 1); // 部署在反向代理之后时必须设置否则 req.secure 判断会出错 app.use(session({ name: sid, // 不使用框架默认名减少指纹识别 secret: process.env.SESSION_SECRET, // 从环境变量注入禁止硬编码进代码库 resave: false, // 未修改的会话不重复写回降低存储压力 saveUninitialized: false, // 未初始化的空会话不下发 Cookie cookie: { httpOnly: true, // 切断 document.cookie 读取路径 secure: true, // 仅 HTTPS 传输本地纯 HTTP 调试需临时关闭 sameSite: lax, // 默认 lax纯内网 API 可收紧到 strict path: /, maxAge: 1000 * 60 * 30 // 30 分钟缩短泄露后的有效窗口 } }));参数说明与踩坑预告secure: true在本地http://localhost调试时会直接导致登录后拿不到 Cookie因为浏览器拒绝在非安全上下文里保存带 Secure 的项。常见做法是按环境分支——生产强制true本地通过环境变量关掉绝不允许“为了本地方便把生产也关掉”。trust proxy这一行经常被漏掉。部署在 Nginx 或负载均衡后面时Node 进程看到的是代理的 IP 和 HTTP 协议req.secure会是false会话中间件据此判断“当前不是安全连接”于是拒绝下发 Secure Cookie表现就是登录接口返回 200 但后续请求全 401。把trust proxy设为 1 并让代理正确传递X-Forwarded-Proto这个问题就消失了。如果不使用框架手工拼Set-Cookie也行但格式上有两个细节必须注意// 原生写法手工构造 Set-Cookie 响应头 res.setHeader(Set-Cookie, [ sid${sid}; Path/; HttpOnly; Secure; SameSiteLax; Max-Age1800 ]);第一属性之间用分号加空格分隔这是规范格式少写空格部分解析器会出错。第二多个 Cookie 必须用数组形式下发也就是多个Set-Cookie头不要把两个 Cookie 用逗号拼在一个头里——Expires属性的值本身就含逗号拼在一起会产生歧义不同浏览器的解析结果不一致。这个坑排查起来非常费时间因为浏览器开发者工具里看着像是一条实际只生效了一半。3.3 网关层兜底老系统改不动怎么办现实情况是有些存量服务的代码没人敢动或者改动要走很长的发布流程。这时候可以在网关层统一改写。# 对上游返回的 Set-Cookie 统一追加安全属性 # 注意add_header 对已有的 Set-Cookie 不生效必须用 proxy_cookie_flags location / { proxy_pass http://app_backend; proxy_cookie_flags ~ secure httponly samesitelax; }proxy_cookie_flags从 1.19.3 版本开始提供~表示对所有 Cookie 生效后面跟要追加的标记。低于这个版本只能用proxy_cookie_path去重写 Path 属性功能有限强行拼字符串容易把原有的Expires搞坏不建议在生产上这么干。网关兜底有一个前提它只能“追加”属性不能“删除”上游已经写错的东西。如果上游把Domain写成了父域网关这边是改不掉的还是得回到代码层。所以这套方案适合作为过渡期的临时措施不能当成长期方案。3.4 验证一条 curl 命令看清真实下发的属性配置改完之后不要靠肉眼看浏览器面板直接用 curl 拿原始响应头这是唯一可信的来源。# -s 静默-o /dev/null 丢弃响应体-D - 把响应头打到标准输出 # 用 POST 模拟真实登录因为很多站点只在登录接口下发会话 Cookie curl -s -o /dev/null -D - -X POST https://app.test.local/api/login \ -H Content-Type: application/json \ -d {user:audit,pass:audit} \ | grep -i ^set-cookie期望看到的输出形如set-cookie: sid...; Path/; HttpOnly; Secure; SameSiteLax; Max-Age1800。任何一个属性缺失就说明那一层没配上。注意-I发的是 HEAD 请求很多登录接口不接受 HEAD会返回 405看起来像是“没有 Set-Cookie”实际上是方法用错了这个误判我见过不止一次。把这条命令包一层判断就能直接塞进 CI#!/usr/bin/env bash # 校验登录响应里的会话 Cookie 是否带齐三个关键属性 set -euo pipefail HEADERS$(curl -s -o /dev/null -D - -X POST $LOGIN_URL \ -H Content-Type: application/json \ -d {\user\:\$AUDIT_USER\,\pass\:\$AUDIT_PASS\}) COOKIE_LINE$(echo $HEADERS | grep -i ^set-cookie: | head -n 1) if [ -z $COOKIE_LINE ]; then echo FAIL: 登录响应里没有 Set-Cookie; exit 1 fi for FLAG in HttpOnly Secure; do echo $COOKIE_LINE | grep -qi $FLAG || { echo FAIL: 缺少 $FLAG; exit 1; } done echo $COOKIE_LINE | grep -qi samesite || { echo FAIL: 缺少 SameSite; exit 1; } echo PASS这段脚本的逻辑很直白取第一条Set-Cookie逐个检查关键标记缺一个就返回非零。参数上需要注意LOGIN_URL、AUDIT_USER、AUDIT_PASS都从 CI 的环境变量注入不要写在脚本里。另外它只校验了第一条如果站点有多个会话 Cookie需要改成遍历。4. 会话层的兜底设计Cookie 被复制走之后还剩什么防线4.1 假设凭据已经泄露系统还认不认得出异常前面三章讲的都是“不让它被读走”。但安全设计里有一个更实际的心态假设某一层已经失守后面还能不能兜住。会话层的价值就在这里——即使攻击者完整拿到了一条有效的会话 Cookie只要他没法复现登录时的客户端特征系统就还有机会发现。这个思路不是万能的攻击者如果同时控制了同一台设备、同一个网络出口绑定就失效了。但绝大多数情况下凭据被拷到另一台机器上使用环境特征一定会变。把这部分变化量化成一个风险分比“一变就踢下线”要实用得多因为误伤率会低很多。4.2 会话绑定把会话和设备与网络环境关联起来先解决登录时的重建问题。会话固定是一个老问题攻击者先拿到一个未登录的会话 ID诱导受害者用它登录之后这个 ID 就成了已认证会话。防御方式是登录成功后销毁旧会话、重新生成 ID。// 登录成功后重建会话避免会话固定 app.post(/api/login, async (req, res) { const user await verifyCredentials(req.body.user, req.body.pass); if (!user) return res.status(401).end(); req.session.regenerate(err { // 关键销毁旧 ID生成新 ID if (err) return res.status(500).end(); req.session.uid user.id; req.session.ctx fingerprint(req); // 记录登录时的客户端上下文 req.session.save(() res.status(204).end()); }); });regenerate这一步不能省。很多示例代码直接用req.session.uid user.id就完事了这样登录前后的会话 ID 是同一个会话固定的口子一直开着。接下来是上下文指纹的生成以及每个请求的一致性校验。const crypto require(crypto); // 生成客户端上下文指纹UA 哈希 IPv4 /24 前缀 function fingerprint(req) { return { ua: crypto.createHash(sha256) .update(req.headers[user-agent] || ) .digest(hex) .slice(0, 16), // 只取前 16 位够比对不存原始 UA ipPrefix: (req.ip || ).split(.).slice(0, 3).join(.) // 取前三段容忍同网段切换 }; } // 受保护接口的一致性校验不一致时累加风险分不直接拒绝 function sessionGuard(req, res, next) { const now fingerprint(req); const ctx req.session.ctx; if (!ctx) return res.status(401).end(); const uaChanged now.ua ! ctx.ua; const netChanged now.ipPrefix ! ctx.ipPrefix; if (uaChanged netChanged) { req.session.risk (req.session.risk || 0) 40; // 两条同时变风险陡增 } else if (uaChanged || netChanged) { req.session.risk (req.session.risk || 0) 10; // 单条变化常见只小幅累加 } if ((req.session.risk || 0) 40) { req.session.pendingVerify true; // 标记需要二次验证由接口层返回挑战 } next(); }参数说明UA 只存哈希的前 16 位既够区分又避免存储原始字符串带来的隐私问题。IP 取前三段是为了容忍同一运营商网段内的切换——移动网络下完整 IP 变化非常频繁如果按完整 IP 做严格绑定正常用户一天能被踢十几次。阈值 40 是根据“UA 和网段同时变化”这一组合定的单条变化10不会触发验证避免浏览器自动升级导致的误判。这个数值需要按自己业务的误伤率调上线前先跑一段观察模式只看日志不拦截统计一下正常用户的触发比例再决定要不要收紧。4.3 风险信号与权重把判断从布尔变成打分单一信号太容易误判实际落地时一般会同时收集多个信号做加权。下面这张表是我在一套内部系统里用过的初始权重可以直接拿来当起点。信号权重说明UA 哈希变化10浏览器自动升级会误报不能单独判定为异常IP /24 前缀变化10移动网络、NAT 环境下很常见出口网段所属机构变化20需要 IP 归属库跨运营商时触发登录后 5 分钟内出现上述变化×2 放大刚登录就换环境可疑度更高同一会话短时间多网段并发40最强的异常信号基本可以确定凭据被复制最后一条是需要额外实现的在会话里维护一个最近活跃网段的滑动窗口如果 10 分钟内出现两个以上不同网段的请求直接判定高风险。这个判断几乎不会误伤因为正常用户不可能同时从两个网络出口访问。风险分累积到阈值之后不要直接踢下线而是走二次验证。踢下线的问题在于用户会一脸茫然地重新登录然后一切照旧二次验证则是让他重新确认一次身份同时把这个事件记进安全日志。对于内部系统一次验证码足够对外系统可以考虑要求重新输入密码但对体验的影响要评估。4.4 缩短窗口轮换与主动失效前面所有措施都建立在“会话有效期有限”这个前提上。如果一个会话能活 30 天再多的检测也只是延长了被发现的时间。所以最后一步是主动压缩窗口。常规做法是双凭据短期的访问凭据15 到 30 分钟加长期的刷新凭据刷新凭据每次使用后立即作废并换新也就是轮换。这样即使某条刷新凭据被拿走攻击者用一次正常用户下次刷新就会发现凭据失效两端必然有一端异常系统可以据此把整条会话链全部吊销。轮换的实现要处理一个并发细节用户在多个标签页同时刷新时会拿着同一个旧凭据发多次请求如果严格一次性作废第二次请求就会失败。常见做法是给旧凭据一个几秒的宽限期或者把同一凭据下的多次刷新合并处理。这个宽限期不能太长否则轮换的意义就打折了。5. 五个真实现场从现象倒推到根因的排查清单5.1 配了 HttpOnly前端依然能读到会话值现象配置改完、服务重启开发者工具里执行自检脚本sid依然出现在document.cookie的返回串里。原因基本只有三种。一是同名 Cookie 存在多条一条是旧版本应用下发的没有 HttpOnly一条是新版本下发的Path 不同导致两条共存浏览器读取时都会带上。二是前端代码里自己用document.cookie sid...写了一份覆盖不了 HttpOnly 但会新增一条同名的非 HttpOnly 项。三是中间的某个代理或网关重新生成了一份 Cookie把属性丢掉了。解决路径是打开开发者工具的 Application 面板按 Domain 和 Path 把所有同名项列出来逐条看属性。如果有 Path 不同的历史遗留项服务端要主动删一次在对应的 Path 下下发一条同名 CookieMax-Age0把旧项清掉。前端自己写 Cookie 的地方也要搜一遍document.cookie 这个字符串全文检索就能找全。5.2 开启 Secure 之后本地调试登录一直失败现象本地起服务登录接口返回 200但后续所有请求 401开发者工具的 Cookie 面板里压根没有会话项。原因有两个层面。最直接的是浏览器拒绝在纯 HTTP 页面里保存带 Secure 的 Cookie。第二个更隐蔽部署在代理后面时req.secure为false会话中间件据此不下发带 Secure 的 Cookie表现和第一种一模一样但原因完全不同只在本地查会一头雾水。解决方式按环境分开本地调试阶段通过环境变量把secure关掉生产环境强制开着这两个分支要写死不要留“手动改代码”的口子。代理场景下设置trust proxy并确认代理传了X-Forwarded-Proto: https。验证方法还是在本地用 curl 直接请求代理看响应头里到底有没有Secure别猜。5.3 SameSite 收紧后第三方跳回丢失登录态现象从合作方页面点回自己的站点登录态没了要重新登录一次。这个场景非常典型接入第三方登录、支付回调、或者从内部其他系统跳转时都会遇到。原因在于SameSiteLax的规则跨站的 POST 请求不携带 Cookie只有顶层导航的 GET 才带。而绝大多数回调是 POST。很多人第一反应是把SameSite改成None但这要求同时配Secure而且等于把 CSRF 防护面完全打开不是个好交易。更稳的做法有两个方向。一是把回调改成 GET 加顶层导航用一个一次性的、短时效的state参数在服务端换取会话不依赖跨站携带 Cookie。二是给回调单独开一条不受 SameSite 限制的路径用服务端票据完成会话建立。改造成本都不高但比放开None安全得多。5.4 网关层改 Set-Cookie 完全不生效现象在 Nginx 里写了add_header想追加 HttpOnly用 curl 看响应头Set-Cookie和改之前一模一样。原因是add_header的语义是“新增一个响应头”不是“修改已有的同名头”。上游已经返回了Set-Cookie再 add 一个同名的等于两个头并存浏览器怎么处理是不确定的多数情况下只会用其中一个。另一个隐藏问题是add_header在location块里有继承覆盖规则写在server层的会被location层的覆盖掉很容易配了个寂寞。正确做法是用proxy_cookie_flags显式改写并且确认 Nginx 版本在 1.19.3 以上。改完之后不要相信浏览器缓存和已存在的 Cookie 会干扰判断一律用curl -D -看原始响应。如果版本不够就老老实实改应用代码别在网关层拼字符串。5.5 一个子域写错父域全站跟着遭殃现象安全扫描报告主站会话 Cookie 缺少 HttpOnly但主站代码翻遍了每一行明明设了。原因是某个子域的应用把 Cookie 的Domain设成了父域并且用了和主站一样的关键字。浏览器在请求主站时会同时带上主站自己下发的那条和子域写到父域的那条服务端读取时拿到哪一条取决于实现很可能是没有保护的那条。扫描器和真实攻击者看到的也是这两种都存在的状态。解决需要两步先把所有子域的下发策略统一审计一遍找出写父域 Cookie 的地方改成只写本域然后在全域名范围内做一次响应头扫描确认没有遗留。这类问题靠单个团队自查是查不出来的必须把域名下的所有服务都纳进同一份检查清单。6. 把 Cookie 审计做进流水线三项自动检查和一个改不掉的习惯临时做一次审计容易难的是三个月后新来的同事加了个新接口悄悄把属性又改回去了。我的做法是把检查变成流水线里的固定环节人可以不记得流水线不会忘。第一项是响应头检查就是第 3 章那段 curl 脚本接在部署后的冒烟测试之后。第二项是静态扫描在代码仓库里匹配所有document.cookie 的赋值和所有Set-Cookie的构造点命中之后要求人工确认是否带齐属性。第三项是依赖检查第三方库和中间件版本也要纳入因为我们踩过一次中间件升级后默认值变化的坑。检查项手段失败判定会话 Cookie 属性部署后 curl 抓真实响应头缺少 HttpOnly 或 Secure前端写入 Cookie仓库全文检索document.cookie 命中且无豁免注释父域 Cookie 下发扫描所有子域的响应头出现Domain.前缀代理层改写生效对比应用直连与经代理的响应头两者属性不一致这四项里最容易漏的是最后一项。应用直连和经过代理之后响应头可能完全不同尤其是中间加了一层鉴权网关的时候。所以对比检查必须两边都抓只抓一侧等于没抓。# 对比应用直连与经过代理后的 Set-Cookie属性必须一致 DIRECT$(curl -s -o /dev/null -D - -X POST $APP_DIRECT_URL/api/login \ -H Content-Type: application/json -d $LOGIN_BODY | grep -i ^set-cookie:) PROXIED$(curl -s -o /dev/null -D - -X POST $PUBLIC_URL/api/login \ -H Content-Type: application/json -d $LOGIN_BODY | grep -i ^set-cookie:) # 归一化后比较去掉会话值本身只比属性部分 norm() { echo $1 | sed -E s/[^;]*;/VALUE;/; } if [ $(norm $DIRECT) ! $(norm $PROXIED) ]; then echo FAIL: 代理层改变了 Cookie 属性 echo 直连: $DIRECT echo 代理: $PROXIED exit 1 fi这段脚本的关键在norm函数把会话值本身替换成固定串只比较属性部分否则每次会话值都不同永远比不相等。参数上APP_DIRECT_URL是绕开代理的内网地址PUBLIC_URL是外网入口两者用同一份请求体。这个检查跑一次只要几秒但拦下过两次因为代理配置迁移导致属性丢失的事故。还有一个改不掉的习惯任何涉及 Cookie 的改动我都会在提交前手动跑一遍自检脚本看一眼document.cookie里到底还有什么。这个动作花不到十秒但它能立刻暴露出“我以为设了 HttpOnly 其实没有”这类问题。有一次就是这么发现某个新加的埋点 SDK 顺手写了一个前端可读的临时标识虽然不含会话信息但它会让后续所有审计结果变得嘈杂早发现早清理。从那以后我每次上线前都会把这一套走一遍不是因为我信不过流水线而是因为流水线只检查它被要求检查的东西而事故往往发生在那之外。希望帮到你。本文还有配套的精品资源点击获取