☰
Tomcat多版本下Session Cookie安全属性缺失排查与修复指南
2026/10/21 17:48:18 网站建设 项目流程

上个月接手了一套混布了不少年的老系统,安全扫描报告里挂了一串很眼熟的条目:Session Cookie Missing Secure Attribute、Cookie Without HttpOnly Flag、SameSite Cookie Not Set,点名的组件统一是Tomcat,而且是7到9好几个版本混在一起。这类问题在扫描器里通常只算中危,优先级排得并不高,但真要顺着攻击链往下想,每一处都够让人冒冷汗:会话标识能被脚本读走、能被跨站请求顺手带走、还能跟着明文HTTP裸奔。这篇把我在多版本Tomcat环境里排查和修复会话Cookie安全属性缺失的完整过程写出来,包括不同版本之间的行为差异、每个版本该改哪里、老版本不支持SameSite时怎么兜底,以及改完之后怎么验证才能不误伤业务。正在处理安全扫描报告或者打算自查会话Cookie加固策略的同学,可以直接把它当操作手册用。

1. 漏洞全景:缺的是三个属性,漏的是三类攻击

1.1 三个属性各自管住哪一段路

先说Secure。这个属性告诉浏览器:这条Cookie只能在HTTPS连接里带着走。一个站点如果同时开放了HTTP和HTTPS入口,或者攻击者和用户处在同一个局域网里,就有机会在用户发起请求时把流量降级到HTTP,或者诱导浏览器去加载一个HTTP资源。这时候,没有标Secure的会话Cookie会老老实实跟着明文请求发出去,网络里任何能抓包的人都能直接读到JSESSIONID。很多人觉得“我只要全站都跳HTTPS就够了”,但实际上很多系统的TLS是在Nginx这一层终结的,到后端Tomcat时走的还是HTTP,外部访问的强制跳转也并不能覆盖所有入口和所有历史客户端。Secure属性从根上约束了浏览器,让会话Cookie绝不降级到明文链路,这是它无法被HSTS替代的地方。

然后是HttpOnly。这个属性跟XSS是配套关系,它禁止脚本通过document.cookie读取Cookie,但并不会阻止XSS注入本身。也就是说,你的站点里只要有一个能执行脚本的注入点,攻击者下一步自然就会去试着读Cookie。没有HttpOnly,一次本来只影响前端的XSS漏洞,可以直接升级成会话劫持;加了HttpOnly,脚本拿到的是一串空值,攻击者要继续偷会话,就得换DOM劫持、钓鱼、或者更复杂的链路,攻击成本立刻上来。Tomcat对容器自己创建的Session Cookie默认会输出HttpOnly,但在老版本、被改过配置、或者应用自己创建Cookie的场景下,并不保证自动带上。

SameSite是三者里最容易被忽视的。它控制浏览器在跨站请求时要不要携带这条Cookie。Lax模式下,只有用户从外部站点点链接进来这类顶层导航GET请求会带Cookie,跨站表单POST、图片请求、iframe子页面都不会带;Strict连顶层导航都不带;None放开限制,跨站请求愿意带就带,但浏览器通常要求必须同时有Secure属性。没有SameSite时,攻击者可以在自己的页面上放一个自动提交的表单,指向你系统里的修改密码接口,浏览器会把未设SameSite的会话Cookie一起带过去,服务端看到的就是一个合法的登录用户在自己发请求。CSRF的经典打法,很大程度就是靠这一下。

1.2 为什么扫描结果经常是三个属性一起缺

我处理过很多次类似的扫描报告,发现这几个条目经常是成批出现,背后其实有一套固定的版本和配置原因。Tomcat对Session Cookie的默认策略大概是这样:HttpOnly默认开,Secure默认不开,SameSite在老版本里完全没有实现,在新版本里默认值也是不输出。所以,一个没有做过任何加固的Tomcat实例,抓下来的Set-Cookie响应头通常只有Path=/,最多再带一个HttpOnly,Secure和SameSite基本是铁的缺失。如果以前为了兼容某个老系统,有人显式把useHttpOnly关掉,那三个属性一起缺也很正常。

更隐蔽的一个场景是会话不由Tomcat自己管。很多项目用了Spring Session、Shiro这类框架,Session Cookie由框架生成,或者直接在业务代码里通过response.addCookie()写自定义Cookie。这种时候,Tomcat的CookieProcessor、web.xml里的session-config都对它们不生效。很多团队在Tomcat配置里折腾半天,回头一抓包发现响应头纹丝不动,原因多半就在这里:你修的这条Cookie根本不是容器创建的。

1.3 风险定级:这类中危为什么值得认真对待

扫描器把Cookie安全属性缺失标记成中危是有道理的,单个属性缺失并不等于系统已经被打穿。但它是一个非常典型的放大器型问题。一个后台管理系统,如果同时还存在一处存储型XSS,管理员会话Cookie又没有HttpOnly,攻击者只需要让管理员打开一个带payload的链接,Session ID就能被脚本读走;如果Cookie没有Secure,攻击者又能和用户在同一个内网,再配一次协议降级,会话就能在明文链路里被嗅探。系统越重要、登录态价值越高,这个组合拳的杀伤力就越接近高危。尤其在外网可达、多租户、承载交易这类场景下,别因为扫描器给的是中危就一直挂账。把Secure、HttpOnly、SameSite三个属性补齐,投入其实非常小,换来的是整个认证链路最基础的一道护栏。

2. 多版本差异:先定位你的Tomcat到底支持到哪一步

2.1 版本能力对照速查

动手之前先花两分钟确认Tomcat版本和运行模式,这一步能省掉后面大量的无效排查。不同版本的默认行为和可配置项差距非常大,我按实际见到的主流版本整理了一张速查表,最终判断以你手里那台实例的响应头和catalina.log为准。

Tomcat版本默认HttpOnly默认Secure是否支持配置SameSite
7.0.x是否不支持,需过滤器兜底
8.0.x是否不支持,需过滤器兜底
8.5.x早期是否不支持,需过滤器兜底
8.5.x后期是否视具体小版本而定,以实测为准
9.0.x(9.0.40之前)是否不支持,需升级或过滤器兜底
9.0.40及以上是否支持,通过CookieProcessor配置
10.x / 11.x是否支持,通过CookieProcessor配置

这里的“默认HttpOnly是”,指的是容器生成的Session Cookie大多默认带HttpOnly,但如果你在web.xml的cookie-config里显式关过,或者管理员改过useHttpOnly,结果就不一样了。Secure在所有版本里默认都是关的。SameSite是最容易踩坑的点,不要说Tomcat 7了,就是Tomcat 8.5早期,你在context.xml里写一个samesite属性,容器压根不认识,严重的直接启动报错。所以先认清版本能力,再选方案,别看到配置示例就抄。

2.2 属性到底由哪一层决定

一条Session Cookie最终长成什么样,不是单点配置能决定的。Tomcat内部大致是这样一条链路:Connector负责协议层,Context维护useHttpOnly这类通用开关,真正拼装Set-Cookie响应头的是CookieProcessor。容器创建JSESSIONID的时候,会把Context的配置交给CookieProcessor,由它决定最终输出哪些属性。应用自己创建的Cookie,则是通过HttpServletResponse.addCookie()把Cookie对象丢给同一个CookieProcessor,而Cookie对象自带的属性优先级要高于容器级默认值。

所以排查顺序一定要反过来。先拿一个真实的登录响应,看Set-Cookie到底长什么样,判断这条Cookie是谁创建的;再决定改容器配置还是改应用代码;最后根据版本选可行路径。我见过太多人上来就照着某篇博客往conf/context.xml里塞配置,塞完发现不生效,就开始怀疑Tomcat坏了。其实多半要么是版本支持不了,要么是配置写在了优先级更低的地方,要么根本就是业务框架自己发的Cookie。

2.3 用一条命令快速判断当前状态

找一个能返回Set-Cookie的登录或登录页接口,直接把响应头拉出来看一眼。前后端分离项目就找登录成功返回Header的接口,传统页面应用直接访问登录页就行,只要后端创建了Session,响应头里一定会有Set-Cookie。

curl -sk -i https://your-host/your-app/login \ -H "Content-Type: application/json" \ -d '{"username":"readonly","password":"temp"}' | grep -i set-cookie

正常场景下拿到的JSESSIONID后面跟着什么属性,一眼就能判断缺口。然后再对照一下conf/context.xml和应用自己的META-INF/context.xml,确认全局配置和应用级配置谁覆盖了谁。如果你用的是内嵌Tomcat,比如Spring Boot项目,那两个XML文件都不存在,得去application.yml里翻server.servlet.session.cookie.*这一段。

另外顺手分享一个排查技巧:整套系统如果有很多台Tomcat,别一台一台去敲命令。写个几行的Shell循环,把所有节点的Set-Cookie响应头抓下来横向对比,哪台机器配置漂移基本当场现形。我在实际排查中经常发现,同一个应用在不同版本的Tomcat下,Cookie属性居然不一样,这就是版本差异在背后慢慢积累出来的坑。

3. 修复实操:按版本选路径,按环境定参数

3.1 全局配置优先:Tomcat 9.0.40及以上的标准做法

如果确认实例是Tomcat 9.0.40以上,或者10.x/11.x,最省事的方案是直接改conf/context.xml,让这台实例上的所有应用都吃到同一套配置。在<Context>节点里加一个CookieProcessor,像这样:

<Context useHttpOnly="true"> <CookieProcessor className="org.apache.tomcat.util.http.Rfc6265CookieProcessor" sameSiteCookies="lax" /> </Context>

sameSiteCookies取值可以是unset、none、lax、strict,默认是unset,表示不输出SameSite属性。选lax作为默认最稳妥,它在安全和体验之间最均衡:用户从外部链接点进来这种顶层导航GET请求不受影响,跨站表单、fetch这类自动请求不会带Cookie。如果业务明确没有跨站跳转场景,可以上strict。none要非常谨慎,它允许跨站携带Cookie,等于自己把CSRF的防御面拉大了,而且浏览器要求None必须配合Secure,也就是说你这条链路必须能确保全程HTTPS。

如果你用的是Spring Boot内嵌Tomcat,没有context.xml这个文件,配置落在application.yml里。新版本的Boot已经映射了对应的Session Cookie属性,大致是这样:

server: servlet: session: cookie: http-only: true secure: true same-site: lax

这里有一个实操中很容易掉进去的优先级问题:应用自带的META-INF/context.xml优先级高于全局的conf/context.xml。你明明改了全局配置却没效果,先去看看对应应用目录下有没有这个文件,里面是不是把useHttpOnly关掉了,或者又写了一个别的CookieProcessor。这种“改了不生效”的案例,十有八九出在这个优先级上。

3.2 老版本处理:能升级就升级,升不了就上过滤器兜底

Tomcat 7、8.0、8.5早期没有内置的SameSite配置能力,这里要分两种情况处理。一种是你允许做小版本升级,比如Tomcat 7升到比较新的7.0.x补丁版,8.5升到后期支持SameSite的小版本,升级完以后再尝试配置Rfc6265CookieProcessor和sameSiteCookies。这种升级范围小、兼容风险可控,值得优先做。另一种是升级牵扯到太多应用依赖,短期内动不了版本,那就必须在应用层兜底。

应用层通用方案是写一个Filter,用HttpServletResponseWrapper包住响应,拦截所有Set-Cookie响应头,逐个追加缺失的Secure、HttpOnly、SameSite。这个方案基于Servlet标准接口,不依赖Tomcat内部API,所以在老版本上一样能跑。我用的示例代码大致是这样:

package com.example.security; import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponseWrapper; import java.io.IOException; import java.util.regex.Pattern; public class CookieSecurityFilter implements Filter { private static final Pattern HAS_ATTR = Pattern.compile( "(?i)(^|;\\s*)(secure|httponly|samesite)(\\s*=|\\s*;|$)"); @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { if (response instanceof HttpServletResponse) { HttpServletResponse httpResponse = (HttpServletResponse) response; chain.doFilter(request, new CookieSecurityResponseWrapper(httpResponse)); } else { chain.doFilter(request, response); } } private String upgrade(String headerValue) { if (headerValue == null || headerValue.isEmpty()) { return headerValue; } if (HAS_ATTR.matcher(headerValue).find()) { return headerValue; } StringBuilder sb = new StringBuilder(headerValue); sb.append("; Secure"); sb.append("; HttpOnly"); sb.append("; SameSite=Lax"); return sb.toString(); } static class CookieSecurityResponseWrapper extends HttpServletResponseWrapper { CookieSecurityResponseWrapper(HttpServletResponse response) { super(response); } @Override public void addHeader(String name, String value) { if ("set-cookie".equalsIgnoreCase(name)) { value = upgrade(value); } super.addHeader(name, value); } @Override public void setHeader(String name, String value) { if ("set-cookie".equalsIgnoreCase(name)) { value = upgrade(value); } super.setHeader(name, value); } } }

upgrade里的正则判断,是为了避免对已经带属性的Cookie重复追加。比如某个Cookie本身已经有SameSite=None,你无脑再拼一个SameSite=Lax上去,重复的属性会导致浏览器解析行为不可预期,有的浏览器直接拒绝这条Cookie。实际生产里如果只想对JSESSIONID做处理,可以把匹配逻辑收到更细,只针对特定Cookie名操作;如果像我这样统一处理,那就对所有Set-Cookie一视同仁。

Filter在web.xml里注册到/*,让它在所有响应链路的最前端包住业务代码:

<filter> <filter-name>cookieSecurityFilter</filter-name> <filter-class>com.example.security.CookieSecurityFilter</filter-class> </filter> <filter-mapping> <filter-name>cookieSecurityFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>

需要注意,这个Filter是在给所有Cookie加Secure,如果你的实例既有HTTPS访问又有HTTP访问,HTTP环境下浏览器会直接丢弃Secure Cookie,导致Session来回漂移。所以上线前务必把访问链路理清楚:是不是所有入口都确保HTTPS,是不是代理已经终结了TLS但Tomcat不知道外部是HTTPS。

3.3 别忽略web.xml本身的能力

不要因为能配置CookieProcessor就放弃web.xml,两者配合使用效果更好。对容器自己创建的Session Cookie,Servlet规范从3.0开始就提供了标准配置项,只要容器实现了规范都认。在应用的web.xml里写上:

<session-config> <session-timeout>30</session-timeout> <cookie-config> <http-only>true</http-only> <secure>true</secure> </cookie-config> <tracking-mode>COOKIE</tracking-mode> </session-config>

这段配置只对Session Cookie有效,业务代码里response.addCookie()创建的自定义Cookie不受控。需要再次强调<secure>true</secure>的副作用:如果应用在本地或内网用HTTP调试,Secure Cookie不会被浏览器保存,每次请求都会服务端重新创建Session,表现就是页面一直登录不上,排查起来很容易误判成“改坏了”。我自己的习惯是正式环境部署包开Secure,测试开发环境用独立的环境变量或构建参数关掉,不要把开发测试和正式放到同一份配置模板里写死。

3.4 面对多种Cookie诉求,别企图用一把钥匙开所有锁

有的系统里会有好几类Cookie,一部分要求SameSite=Strict,另一部分必须是None,全局一个CookieProcessor根本接不住这种粒度。这种场景下,我的建议是先梳理这些Cookie到底都是谁创建的,哪些是框架自动生成的,哪些是业务代码写的。适合的方式是让框架层各扛各的:Spring Session有专门的cookie-same-site配置,Shiro在Session Cookie属性上也有接口,优先用框架自带的开关去调,比在Tomcat层做一个无差别全量修改干净得多。Tomcat配置用来兜住容器默认Session Cookie这条基线,框架配置负责业务自定义Cookie,两边各归各,后面维护才不会互相打架。

4. 改完以后:验证、回归、排雷

4.1 打一个裸请求,别被浏览器缓存骗了

修复后第一件事,重新抓登录接口的Set-Cookie。我习惯用curl,因为可以完全控制不带任何旧Cookie:

curl -sk -i https://your-host/your-app/login \ -H "Content-Type: application/json" \ -d '{"username":"readonly","password":"temp"}' | grep -i set-cookie

正常结果应该是类似:

Set-Cookie: JSESSIONID=6D9D8C...; Path=/; HttpOnly; Secure; SameSite=Lax

顺序不重要,关键看三个属性都在。浏览器端也可以顺手验证,打开开发者工具的Network面板,选中登录请求,翻Response Headers里的Set-Cookie。这里最容易犯的错误是用一个已经登录过的浏览器去访问页面,页面靠着旧Cookie跑得一切正常,响应头即使已经变了你也完全察觉不到。所以每次改完配置,都用一个无痕窗口或者curl做一次不含任何旧Cookie的裸请求。

4.2 改了没生效,按这个优先级排查

碰到Set-Cookie仍缺属性,别先怀疑自己写错,按下面顺序走一遍。第一,应用自身有没有META-INF/context.xml覆盖了全局配置,或者web.xml里把http-only显式关成了false。第二,访问链路确实是HTTPS吗,Secure属性有没有因为代理层是HTTP而根本没被添加。第三,Session Cookie真是Tomcat创建的吗,项目里如果有自定义会话框架,容器配置管不到它。第四,确认版本支持你写的配置属性,老版本遇到未知属性可能直接启动失败,去catalina.log里找找有没有class not found或解析异常。第五,如果你是Spring Boot,检查外部化配置到底加载了哪个application.yml,别改了一个不生效的空文件。

我用的一张速查表分享出来:

现象最可能原因优先排查点
配置了但响应头完全没变应用级配置覆盖全局或Session由框架接管META-INF/context.xml,框架配置
只有Secure不一致代理层HTTP回源,Tomcat不知道外部HTTPSConnector的scheme/secure/proxyPort
SameSite没生效版本太老,属性不被识别升级版本或上Filter
改完以后登录失效Secure导致HTTP下Cookie被浏览器丢弃开发环境关闭Secure,正式确认全链路HTTPS
只有部分接口生效Filter映射顺序或后缀没覆盖检查web.xml的url-pattern和filter顺序

4.3 开启Secure后登录失效,多半是代理链路问题

这个坑在真实环境里反复出现。Nginx做了HTTPS终结,转发给后端Tomcat时用的是HTTP,Tomcat的request.isSecure()直接返回false,于是它生成的Session Cookie就不会带Secure。如果你在context.xml里强行开了secure="true",浏览器又只在HTTPS页面上保存Cookie,于是整个Session在来回跳转中就丢了。

解决方向是让Tomcat感知到外部连接其实是HTTPS。可以在Connector上加scheme="https" secure="true" proxyPort="443",或者用RemoteIpValve配合Nginx传来的X-Forwarded-Proto头。改动之后看一眼Tomcat访问日志里客户端isSecure的状态,配合浏览器状态确认到底是哪层在起作用。如果只是正式环境需要Secure,开发测试环境不需要,把这个差异放到部署环节去处理,比在Tomcat配置里写死要干净得多。

4.4 SameSite调整后的业务回归点

SameSite从无到有,对绝大多数页面应用无感,但两种场景容易踩坑。一种是跨站单点登录或第三方回调,另一种是内嵌iframe的页面。Lax模式下,第三方站点通过POST表单回跳到你的系统,浏览器不会带Session Cookie,用户会发现自己明明已经登录第三方了,跳回来却不认登录态。Strict更明显,用户从外部App里点一条链接进站都可能丢失会话。如果业务确实需要这类跨站会话保持,优先把回调路径改成顶层导航GET;实在改不了,再在风险评估后对回调域名相关Cookie单独设置SameSite=None; Secure,并且服务端一定要配合CSRF Token和来源校验。不要为了省事把所有Cookie都设成None,那等于把CSRF的门重新打开了。

4.5 把验证做成自动化,别等下一次扫描报告

手动抓一次响应头解决当下扫描,但下次上线呢,会不会又缺?很可能会。所以我的建议是写进发布CI的一个小脚本,对每个环境跑一遍同样的curl,检查所有Set-Cookie里是否包含Secure、HttpOnly、SameSite,缺一个就fail掉这次发布。脚本很简单,本质上就是把上面的grep换成循环判断。另一个思路是在运维侧统一管配置,用配置管理工具下发统一的context.xml模板,应用侧在项目骨架里默认带上Cookie安全Filter。无论走哪条路,目标是让“Cookie三个属性齐全”成为系统默认值,而不是入职一个新同事、开一台新服务器、搭一个新环境都要手工补一遍。安全修复最怕的就是修完当天没事,三个月后有人用老模板开了新环境,问题原样复活。

5. 从修复到加固:一些更长期的经验

5.1 别只修Tomcat这一层,整条链路都得看

Tomcat把属性补齐只是后端这道门,前面还有反向代理、负载均衡、网关这一层层设施。如果代理层会重写Set-Cookie头,或者存在某条旧链路还在用HTTP回源,那Tomcat加了Secure也保不齐在入口处被改掉。我在修完Tomcat之后,一定会拿公网入口的域名再抓一次全链路响应头,确认和直连后端拿到的属性一致。同时顺手检查一下站点有没有启用HSTS,避免用户第一次访问还停在HTTP时就先暴露一次Cookie。Cookie安全属性不是单点问题,从入口到出口全链路保持一致,才算真正修完。

5.2 升级Tomcat大版本时,把历史包袱一起清掉

Tomcat 7到Tomcat 9或10,除了Cookie安全属性,还会遇到Servlet API包名从javax.servlet变成jakarta.servlet这种大变动、TLS配置方式变了、默认Session Cookie路径规则也不同。如果本来就在计划从7或8.5升级,不要只盯着Cookie属性修复,先把部署包、依赖库、框架版本全部列出来,统一做一轮回归再切生产。升级过程中顺手把useHttpOnly这种显式开关查一遍,很多旧配置是从远古时代抄下来的,翻出来一看写着useHttpOnly="false"也不奇怪。这种东西升级之前不清,升级之后又会成为下一份扫描报告里的老面孔。

5.3 安全修复的日常心态

处理这类漏洞和修功能Bug最大的区别在于,修完你什么都感觉不到。页面还是正常加载,数据库还是正常读写,没有报错也没有变化,很容易让人认为“白改了”。但一旦哪天真的遇上一次XSS或者CSRF被利用,这几个属性就是能不能守住最后一道线的关键。所以我一直把它们当成基础设施:像灭火器、消防通道一样,平时用不上,关键时候能救命。安全上的小修小补,恰恰是最不能因为“看不见效果”就拖着不管的部分。

最后分享一个我自己的习惯:不管最后选了哪种修复方案,都留一个能一键回到修复前状态的对比方式。比如在CI里跑一个对比脚本,把修复前后的Set-Cookie输出贴到同一份记录里,下次给审计看或者做漏洞复测时,能直接把“属性已补齐”这句话变成肉眼可见的证据。这个改动本身不大,但它是判断一个系统整体健壮性最直观的体检项之一。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询