简介: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 链路传输 | 缺失 | 明文链路或降级请求中被截获 |
HttpOnly | JS 不可读 | 缺失 | 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; SameSite=Lax; Max-Age=1800` ]);第一,属性之间用分号加空格分隔,这是规范格式,少写空格部分解析器会出错。第二,多个 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 samesite=lax; }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; SameSite=Lax; Max-Age=1800。任何一个属性缺失,就说明那一层没配上。注意-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 下下发一条同名 Cookie,Max-Age=0,把旧项清掉。前端自己写 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 收紧后第三方跳回丢失登录态
现象:从合作方页面点回自己的站点,登录态没了,要重新登录一次。这个场景非常典型,接入第三方登录、支付回调、或者从内部其他系统跳转时都会遇到。
原因在于SameSite=Lax的规则:跨站的 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 顺手写了一个前端可读的临时标识,虽然不含会话信息,但它会让后续所有审计结果变得嘈杂,早发现早清理。
从那以后我每次上线前都会把这一套走一遍,不是因为我信不过流水线,而是因为流水线只检查它被要求检查的东西,而事故往往发生在那之外。希望帮到你。
本文还有配套的精品资源,点击获取