Cookie安全自查:HttpOnly、XSS与CSP加固实践 简介cookie-stealer 是一套面向前端安全学习者的轻量级 Cookie 安全演示工具适合正在了解浏览器 Cookie 机制、Web 安全基础与渗透测试入门的人群用于在受控环境中理解 Cookie 被读取与传输的原理及潜在风险。压缩包共 7 个文件约 33KB以 JavaScript 脚本为主体辅以 JSON 配置、HTML 页面、Python 服务端脚本和 Markdown 说明文档同时覆盖浏览器扩展端与本地服务端两部分便于对照阅读工具结构与数据流向。目前已有 186 人学习包体小巧、依赖简单上手门槛较低。读者可通过脚本与说明文档了解 Cookie 的存储位置、跨域携带条件以及前端脚本访问限制等知识点并据此认识会话劫持的常见路径与防护思路为后续 Web 安全学习与代码审计打下基础。1. 一个三行脚本就能读走会话的资源我为什么把它当自查清单用第一次看到 cookie-stealer 这个 JavaScript 工具时我并没有把它当成攻击者的武器来看。它的核心动作只有一件事在当前页面上下文里把document.cookie读出来再证明这条路走得通。换句话说它测的不是某个网站的漏洞而是测你的 Cookie 到底有没有设防。我拿它做的第一件事是把自己维护的几个后台页面挨个跑了一遍结果有三处能直接读到会话标识——那一刻比看任何扫描报告都直观。这篇东西写给两类人一类是负责把自己系统守住的人想搞清楚到底哪一步漏了另一类是想学攻防但不想只会抄工具的人想知道判断依据在哪。下面不谈玄学全是我自己搭环境、逐条验证、再回头加固的过程。2. Cookie 泄露的几条路径先搞清楚入口在哪在动手验证之前得先建立一个基本判断Cookie 泄露从来不是一个工具的问题而是一条链路里某一环没有设防。cookie-stealer 这类脚本本质上只是一个读取器它能不能拿到东西完全取决于三个前置条件是否成立——页面存在脚本注入点、目标 Cookie 没有 HttpOnly 标记、以及脚本运行上下文和 Cookie 的作用域有交集。这三个条件缺一个工具就跑不动。所以真正值得花时间的不是工具本身而是把这三条路径逐个拆开看清楚知道每一条的触发条件和观测方法。我一般会按入口 → 凭据 → 边界的顺序排优先级。入口决定攻击者能不能执行代码凭据决定拿到了能维持多久边界决定影响半径有多大。这个顺序不是拍脑袋来的而是因为绝大多数真实事件里第一环成立之后后面两环往往也成立——开发者往往在同一个页面上同时犯了注入过滤不严和 Cookie 缺属性两个错误。把三条路径分开看是为了在加固时能对应到具体的配置项而不是笼统地说一句加强安全意识。2.1 XSS 注入点为什么是最短的那条路脚本注入是这条链路上最短的一段因为只要攻击者的 JavaScript 能在你的页面域下执行同源策略就反过来变成了帮凶——它挡不住同域脚本读取同域 Cookie。常见的注入形态无非三类反射型参数被原样拼进响应、存储型内容落库后回显、DOM 型前端用innerHTML、document.write、eval之类的危险拼接把不可信数据当代码执行。前两类在服务端能拦第三类经常被忽略因为它根本不经过服务端。DOM 型注入最难查的地方在于它在你抓包时看不到任何异常请求全部发生在浏览器里。我一般会重点看几个位置URL 参数被直接塞进innerHTML的地方、前端路由把 hash 内容当模板渲染的地方、以及富文本编辑器回显链路。这些位置的共同特征是数据从 URL 或本地存储来直接进了解析上下文。判断依据很简单搜代码里所有把变量拼进 HTML 字符串的位置逐个确认这个变量是否可能被外部控制。这里有个容易被低估的细节即使你的主站过滤做得很好只要有一个子域或者一个老旧的营销落地页存在注入点攻击者就能在那个域下执行脚本。而 Cookie 的Domain属性一旦被设为父域比如.example.com所有子域都能读到它。这就是为什么很多团队主站固若金汤却栽在一个三年前上线、没人维护的活动页上。2.2 HttpOnly 缺位一次泄露长期有效HttpOnly 这个属性的作用非常明确标记之后document.cookie读不到这条 Cookie只有 HTTP 请求头里会带上它。就这么一个开关直接决定了 XSS 能不能升级成会话劫持。我在自查时见过最常见的情况是——登录接口返回的会话 Cookie 没有加 HttpOnly理由是前端要读出来做点判断。这种需求十有八九是设计问题因为前端根本不需要知道会话标识是什么它只需要知道当前是否已登录而这个信息完全可以让服务端在响应体里返回一个布尔值或者用户信息对象。比没加 HttpOnly更隐蔽的是加了但加错了地方。比如一个系统同时存在新旧两套会话机制老的 PHP 会话 Cookie 加了标记新接入的 JWT 存在另一个自定义 Cookie 里却没加或者 CSRF Token 单独放在一个可读 Cookie 里然后被当成了会话凭据使用。我在核对时习惯把浏览器开发者工具里 Application → Cookies 那一栏整个展开逐条看每个 Cookie 的 HttpOnly、Secure、SameSite 三列而不是只看名字像会话的那一条。从攻击者视角看HttpOnly 缺位意味着一次注入、长期有效。因为会话 Cookie 通常有较长的有效期攻击者拿到之后可以在自己环境里重放不需要再触发第二次注入。这也是我建议把会话有效期压短、配合刷新令牌轮换的原因——不是为了防住第一次泄露而是把泄露之后的窗口从几个月压到几十分钟。这个成本和收益的比值比任何检测手段都划算。2.3 第三方脚本与子域最容易被忽略的信任边界这条路径被讨论得最少但我在实际排查中遇到的次数不少。页面引入的第三方脚本统计、客服、A/B 测试、广告运行在你的域下拥有和你自己代码完全相同的权限包括读取非 HttpOnly Cookie。你无法审计这些脚本的每一行代码也无法保证它们的 CDN 不被污染。一旦其中任何一个环节出问题等价于你自己的页面出现了注入点。子域的问题同理。很多团队的测试环境、文档站、旧版后台挂在不同子域上安全水位参差不齐。只要其中一个子域存在 XSS而会话 Cookie 的Domain设在父域攻击者就能在那个弱子域上读走主域的会话。我一般的判断依据是把所有子域列出来找出最近半年没有代码变更和没有接入统一的安全响应头的那几个它们就是风险最高的。收敛这条边界的办法有两个方向。一是收紧 Cookie 的作用域把Domain属性去掉让它只对当前主机生效同时用__Host-前缀强制浏览器校验二是给第三方脚本加上integrity属性做子资源完整性校验并尽量把它们放到独立子域下隔离。前者是根治后者是缓解。两个都做成本并不高但对这条路径的封堵效果是决定性的。3. 自建环境复现从能不能读到读完能干什么看懂了路径接下来就是验证。这一步必须严格限定在你自己拥有和控制的环境里——本地起的服务、你自己名下的测试域名、或者一个隔离的容器。我不建议在任何非授权目标上做这件事一是法律上说不清二是你测出来的结果也不可信你不知道对方有没有 WAF、有没有别的检测在干扰。复现的目的不是证明我能黑掉谁而是证明我自己的系统在哪个环节没设防。这套流程我走过很多遍后来固化成了三个步骤搭靶场、最小验证、记基线。第三步最容易被跳过但它恰恰是最有价值的——因为加固之后你要再跑一次没有基线就没法证明加固有效。很多人做完一轮修复就说应该好了结果三个月后同一个问题换个页面又出现了。3.1 搭一个只属于自己的靶场靶场不需要复杂一个能设置 Cookie、能接收 URL 参数并回显的页面就够了。关键是这个页面必须由你自己控制且不接入任何真实数据。我通常会在本地起一个静态服务然后故意留一个把 URL 参数直接拼进innerHTML的位置用来模拟 DOM 型注入再加一个接口返回带和不带 HttpOnly 的两种 Cookie用来做对照。搭的时候有个细节要注意浏览器对Secure属性的校验很严格HTTP 环境下带Secure的 Cookie 会被直接丢弃。所以如果你想验证Secure的行为本地得配一个自签证书走 HTTPS否则你会得到设置了但没生效的假象白折腾半天。这也是我早期翻车最多的地方之一。另外测试用的浏览器配置最好独立——单独的用户目录、关闭所有扩展、不用日常账号。因为你不知道自己装的某个插件会不会顺手改 Cookie 或者注入脚本这些干扰会让你对结果的解读完全跑偏。我一般会开一个干净的用户配置文件专门做这类验证做完就整个删掉。3.2 最小化验证片段只验证可读性不碰外发验证的目标只有一个确认在当前上下文里目标 Cookie 是否可读。读到了就说明这条链路成立读不到就说明 HttpOnly 起了作用。我不做任何外发动作——不发请求、不写远程、不留痕。原因很实际一旦引入外发环节你就得处理跨域、CORS、日志留痕一堆问题而这些和验证可读性这个目标毫无关系。// 仅用于自建环境验证判断会话 Cookie 在当前上下文是否可读 // 不发送任何网络请求结果只输出到本地控制台 function auditCookieReadability(cookieName) { // document.cookie 返回的是 k1v1; k2v2 形式的字符串 const raw document.cookie; // 空字符串有两种可能没有任何可读 Cookie或全部被 HttpOnly 保护 if (!raw) { console.log([audit] 当前上下文无可读 Cookie可能是 HttpOnly 生效); return { readable: false, raw: }; } const pairs raw.split(;).map(s s.trim()); const hit pairs.find(p p.startsWith(cookieName )); if (!hit) { console.log([audit] 未找到名为 ${cookieName} 的可读 Cookie); return { readable: false, raw }; } // 只报告存在且可读不打印具体值避免把凭据落到控制台历史里 console.log([audit] ${cookieName} 可被脚本读取长度 ${hit.length}); return { readable: true, raw }; } auditCookieReadability(sessionid);这段代码的关键点有三个。第一document.cookie拿到的永远只是未被 HttpOnly 标记的那部分所以它本身就是一把尺子——能读到就说明没设防。第二代码里刻意不打印 Cookie 的值只打印长度因为控制台输出会被历史记录、崩溃报告、录屏工具带走这本身就是一个二次泄露渠道。第三cookieName这个参数要换成你系统里真实的会话 Cookie 名一般可以在开发者工具的 Application 面板里直接看到。跑完之后你会得到两种结果。如果返回readable: false且原始串为空说明这条路径已经被 HttpOnly 堵死了可以进入下一项。如果返回readable: true那就得顺着查——这条 Cookie 是谁设置的、为什么没加 HttpOnly、前端有没有真的依赖它。大多数情况下答案是没人依赖只是当初忘了加。3.3 结果记录表把一次验证变成可对比的基线验证做完如果只留在脑子里下次再查就得重来一遍。我的做法是每次都记一张表字段固定加固前后各跑一次做对比。这张表不需要多复杂重点是字段要能对应到具体的配置项这样修复的时候才知道改哪里。检查项观测点加固前加固后判定依据会话 Cookie 是否可脚本读取document.cookie输出可读不可读输出为空或无法匹配到 Cookie 名HttpOnly 标记开发者工具 Cookies 面板缺失已设置HttpOnly 列为勾选状态Secure 标记同上缺失已设置HTTPS 下才下发SameSite 取值同上未设置Lax跨站请求是否携带该 CookieCookie 作用域Domain 属性设为父域未设置 Domain子域能否读取页面是否存在注入点手动输入特殊字符回显存在已转义特殊字符是否被原样解析这张表的价值在于它把感觉安全了变成哪一项从什么变成了什么。我在给团队做复盘时发现凡是能拿出这种前后对比的加固基本都是真做了凡是只有一句已经修复的八成只改了一个页面。表里最后一行页面是否存在注入点是最难自动化的只能手工试但它是整条链路的第一环优先级最高。4. 加固清单把读取 Cookie 的成本抬到最高到这里攻击路径和验证方法都清楚了剩下的就是把口子一个个堵上。加固这件事我不喜欢列一长串最佳实践因为没人看得完。我一般按服务端属性 → 响应头策略 → 检测能力三层来做每层只留最关键的几个动作做完就能覆盖绝大部分场景。需要说明的是这三层不是互相替代的关系而是纵深。属性层做对了注入点存在也读不走会话响应头层做对了注入点本身就很难形成检测层做对了前两层万一有漏你还能及时发现。只做一层就等于只买了一份保险出事的时候还是裸奔。4.1 Set-Cookie 属性怎么组合才不留后门会话 Cookie 的推荐组合是HttpOnlySecureSameSiteLax或StrictPath/ 不设Domain 较短的Max-Age。如果这个 Cookie 是关键凭据再套一层__Host-前缀浏览器会强制要求它必须带Secure、必须Path/、且不能有Domain属性等于把配置错误直接变成不生效这比靠人记住规则可靠得多。// 服务端下发会话 Cookie 的推荐写法Node/Express 风格伪代码 // 关键点属性组合一次性配齐不依赖后续补丁 res.cookie(__Host-sessionid, sessionToken, { httpOnly: true, // 脚本不可读切断 document.cookie 这条读取路径 secure: true, // 仅 HTTPS 传输防止明文链路上被截获 sameSite: lax, // 跨站 POST 不带缓解 CSRF有强需求再评估 strict path: /, // __Host- 前缀强制要求同时限制作用域 maxAge: 30 * 60 * 1000 // 30 分钟把泄露后的可用窗口压到最小 // 注意这里刻意不设置 domain让 Cookie 只对当前主机生效 });参数逐个说一下。httpOnly是这条链路上最关键的一个开关它直接让document.cookie读不到这条记录代价是前端也读不到——如果你的前端逻辑依赖读取会话值那说明设计需要改而不是把这个开关关掉。secure防止在明文 HTTP 请求里带上 Cookie配合 HSTS 效果更好。sameSite的取值要按业务来Lax是默认推荐跨站的 GET 导航会带上POST 表单不带Strict更严但会影响从外部链接跳转进来的登录态需要实测。maxAge是最容易被忽略的一项很多人设成七天甚至三十天等于把泄露后的窗口拉到极限。关于__Host-前缀有个实操上的坑一旦加了前缀Cookie 名就变了服务端读取的时候必须用带前缀的完整名称否则会读不到。这个改动会影响所有依赖该 Cookie 名的代码路径包括网关、鉴权中间件、日志解析规则。所以要么一开始就用要么改的时候全局搜一遍别只改了下发的地方。4.2 CSP 与信任边界收敛内容安全策略的作用是限制页面能加载和执行哪些脚本它是在注入已经发生的前提下做减法——即使攻击者把恶意内容塞进了页面浏览器也不会执行它或者至少不会执行外域脚本。这层防护和 HttpOnly 是互补的HttpOnly 保护的是凭据CSP 保护的是执行环境。// 通过响应头下发 CSP禁止内联脚本和外域脚本执行 // 生产环境先用 Content-Security-Policy-Report-Only 观察一段时间再切换 const csp [ default-src self, // 默认只允许同源资源 script-src self, // 脚本仅同源禁掉内联和 eval object-src none, // 禁掉插件类内容减少老式攻击面 base-uri none, // 防止 base 标签被劫持改写相对路径 frame-ancestors self, // 限制被第三方页面嵌套 report-uri /csp-report // 违规上报用来发现遗漏的合法资源 ].join(; ); res.setHeader(Content-Security-Policy, csp);这里最容易翻车的是script-src self这一条。绝大多数存量项目都有内联脚本、行内事件处理器onclick或者eval调用直接上线会大面积白屏。正确的做法是先只下发Content-Security-Policy-Report-Only把违规上报收集一到两周看看哪些是必须保留的再逐个改成外部文件加 nonce 或者 hash 的方式最后才切到强制执行。这个过程可能要改几十个文件但没有捷径。信任边界收敛是另一条线。把主站、后台、营销页拆到不同主域下而不是挂在同一个父域的子域这样即使营销页被打穿会话 Cookie 也不会被共享。第三方脚本尽量放到独立子域并加integrity校验同时定期审查清单——我见过太多半年前加的一个埋点脚本还在跑负责人已经离职的情况。清单不维护边界就是虚的。4.3 检测侧怎么判断已经发生前三层都是预防这一层是兜底。因为总有你没想到的地方所以需要有能力发现已经发生。会话劫持的检测难点在于攻击者拿到 Cookie 之后的行为和你正常用户几乎一样唯一可能露出破绽的是环境突变——同一个会话在极短时间内从两个地理位置、两个设备指纹、两个网络出口出现。我在日志侧一般看三类信号。第一类是同一会话标识在短时间内出现多个不同的客户端指纹UA、屏幕分辨率、时区、语言这几项组合起来。第二类是 Cookie 值出现在不该出现的地方比如 URL 查询串、Referer 头、错误日志的堆栈里。第三类是异常的成功登录和敏感操作尤其是从陌生 IP 段直接访问后台接口而不是先访问首页的情况。# 日志侧筛查找出同一会话标识对应多个客户端指纹的异常情况 # 字段假设为session_id / ip / user_agent / ts awk -F\t {key$1; ua[key][$3]} END { for (s in ua) { n 0 for (u in ua[s]) n if (n 1) print s, n # 一个会话对应多个 UA值得人工看一下 } } access.log | head -50这段脚本只是把候选集缩小不是判定工具。输出里的会话标识需要人工结合时间、IP、操作序列去判断——正常的 UA 变化可能只是浏览器升级或者用户换了设备而攻击者的特征往往是UA 完全一致但 IP 跨度极大或者操作节奏异常快。参数上-F\t要换成你日志实际的分隔符字段位置也要按你的格式调整不要直接照抄。除了日志还有一个成本很低但效果明显的手段给关键操作加二次验证。比如修改密码、绑定手机、导出数据这类动作强制要求重新输入密码或者走一次短验证。即使会话被劫持攻击者也过不了这一关。这个改动对正常用户的打扰有限但对劫持者的阻断率接近百分之百。5. 避坑与常见问题上线前我自己踩过的五个跟头加固方案写得再漂亮落地时总会遇到各种意料之外的情况。这一章记的都是我自己在验证和上线阶段真踩过的坑按现象 → 原因 → 解决记下来省得下次再翻一遍。5.1 配置层面的三个跟头现象一给 Cookie 加了 Secure 之后本地开发环境登录直接失效所有请求都变成未登录。原因很直接——本地跑的是 HTTP浏览器对带Secure的 Cookie 直接拒收连设置这一步都不生效。解决方法是在配置里按环境区分生产环境强制开启本地环境通过环境变量关闭而不是把这段代码注释掉。注释掉最容易忘我见过有人注释了半年没恢复。现象二加了__Host-前缀之后网关侧的鉴权全部失效但直连后端是正常的。原因是网关的配置里按 Cookie 名做路由和透传写死的是不带前缀的老名字前缀加上之后匹配不上。解决方法是全局搜一遍 Cookie 名的字符串字面量把网关、中间件、日志解析、监控埋点全部改一遍然后再上线。这类改动的风险不在代码本身而在那些没人记得在哪里配过的地方。现象三SameSiteStrict上线后从邮件、合作方页面点进来的链接全部变成了未登录状态。原因是Strict下跨站导航不带 Cookie用户从外部跳进来时服务端认为他是匿名的。解决方法是退回Lax它对顶层 GET 导航是放行的同时仍然能挡住跨站 POST。如果业务确实需要Strict那就得接受这个体验代价或者做一次跳转中继。5.2 验证与上线阶段的两个跟头现象四加固后复测显示不可读但过了一周又被查出可读。查下来发现是新上线的一个活动页用了另一套下发逻辑走的是老代码分支。原因是团队里有多套 Cookie 下发入口改的时候只改了主站那一处。解决方法是把所有Set-Cookie的产生位置列出来做成清单纳入发布检查项每次上线前对一遍。这件事没有技术捷径只能靠流程。现象五CSP 切到强制执行后页面上大量功能静默失效控制台报错但用户看不到。原因是之前Report-Only阶段收集的违规日志没人看直接切了强制模式把还在用的内联脚本全挡了。解决方法是把上报日志接入日常监控设一个阈值告警观察期至少两周确认违规量降到可控范围再切换。这一步急不得急了就是全站故障。这五个坑里前三个是配置细节后两个是流程问题。我的体会是技术方案本身不难难的是保证所有入口都改到了和改完真的验证过。后面这一点只能靠清单和复测没有别的办法。6. 该不该碰这类工具三条判断线和我后来固定的习惯回到最开始那个问题cookie-stealer 这类工具到底该怎么用。我给自己划了三条线。第一条是目标线——只在自己拥有书面授权或者完全自建的环境里跑任何第三方目标一律不碰这条没有灰色地带。第二条是目的线——目的是验证防御是否有效不是收集凭据所以验证脚本里不该有外发逻辑也不该保留任何读到的值。第三条是留存线——测试环境的凭据、日志、截图在验证结束后立即清理不给二次泄露留机会。这三条线看起来是约束实际是保护。我见过有人为了证明漏洞真实存在把读到的会话贴进报告里结果报告在群里转了几手那个会话一直没失效——本来是一次内部自查最后变成了一次真实泄露。这种事出了之后没有后悔药。具体到操作上我现在固定走这么一套流程先列出所有 Cookie 下发入口逐个核对属性再用最小验证片段在自建环境跑一遍记录基线表然后按属性 → 响应头 → 检测三层加固加固完再跑一遍同样的验证对比前后差异最后把入口清单和基线表归档下次改动时直接拿来对照。整个流程走完大概半天比事后排查一次事件的成本低得多。有一个细节值得单独说验证的时候不要只看能不能读到还要看读不到是不是因为真的设了 HttpOnly。这两种情况在控制台里表现是一样的——都是空。区分办法是去开发者工具的 Cookies 面板看那一列有没有勾选或者用服务端的响应头直接确认。我早期就因为只看控制台输出误判过一次以为修好了实际上只是那个页面当时没有下发 Cookie 而已。从那以后我每次做这类验证都强制走一遍看响应头 → 看 Cookies 面板 → 跑验证片段三步三步结果对得上才算数。单看任何一步都可能被骗尤其是单看控制台输出。这个习惯看着笨但它是唯一能让我在复盘时说得清当时到底验证了什么的办法。希望帮到你。本文还有配套的精品资源点击获取