同一秒登录,两把一模一样的钥匙:Hermes 网页版的会话隔离漏洞是怎么补的

同事要借你的电脑,你打开浏览器里的 Hermes 工作台,点了退出登录,看到登录页弹出来,才把电脑推过去。你觉得这件事结束了。
它没有。十月二十六日合入的一个修复(PR #145108)说清楚了漏在哪:如果你在点退出的那个系统里,刚好还有另一条登录路径在同一秒完成登录,两条会话拿到的 token 是一模一样的。你退掉的是浏览器里的那份 cookie,而同一把“钥匙”还在别处能用——甚至在服务重启之后还能用。
先把这套登录讲明白
Hermes 的网页版(webapp)有个“登录门”:任何浏览器访问时,必须先登录。它用的是 dashboard 的账号体系,内置的密码 provider 在你登录成功后签发两个 token——
- access token:短命,用来证明“我是这个请求的主人”;
- refresh token:寿命更长,用来换新的 access token。
这两个 token 是签名过的字符串,里面装着三件事:sub(你是谁)、kind(这是 access 还是 refresh)、exp(什么时候过期)。服务端验签就能确认内容没被改过,不需要每次去查数据库。
这个设计没问题,问题出在“内容”上。签名的输入只有那三个字段,而 exp 的精度是秒。于是在同一秒内、为同一个用户签发的两条会话,算出来的签名输入完全一致——两条会话得到的是逐字节相同的 token。
撞车为什么真的会造成影响
如果两条会话互不相干,token 相同也只是理论问题。但网页版有一个明确的契约:**在 webapp 监听上登录过的原生应用(比如桌面端)不随 hermes webapp --sign-out 退出,只有浏览器退出。**这是刻意设计的,理由是“你在浏览器里点退出,不该把桌面 App 也踢下线”。
为了让这个契约成立,监听端会把给原生应用的那对 token 的摘要记在 webapp/native-sign-in-tokens.json 里,并在退出登录时保留这条记录——正因为保留,桌面端重启后仍然能凭它免登录。
问题就在这里闭环了:如果浏览器的登录和原生应用的登录落在同一秒,两者 token 相同 → 浏览器那份 token 的摘要自然也匹配那条“原生应用记录” → 于是你点“退出”,浏览器 cookie 被清掉,但同一串 token 作为 bearer 令牌依然有效,它的 refresh token 也依然能在 /auth/native/refresh 上续期。测试里写得很清楚:退出前、退出后、重启后,都还能用。
说白了:那一次退出,前台看起来生效了,后台没生效。
修复只有五行,但修的是根
改动落在 plugins/dashboard_auth/basic/__init__.py 的 _mint_session:给每条会话加一个随机 jti(JWT ID,JWT 标准里给每个令牌编号的字段),并同时签进 access 和 refresh 两个 token。
jti = secrets.token_urlsafe(16)
_sign({"sub": user, "kind": "access", "exp": exp, "jti": jti}, secret)
_sign({"sub": user, "kind": "refresh", "exp": now + TTL, "jti": jti}, secret)
secrets.token_urlsafe(16) 给出 128 位随机量,足以保证“同一秒、同一用户、两次登录”也拿到两对不同 token。验证逻辑一行没改——验签、查过期,流程照旧;变的只是签名内容多了一个不可预测字段。
测试也很直白:把时钟冻住,连着做两次登录加一次续期,断言拿到三对互不相同的 token,并且三对都能正常验证。另一个测试跑完整的 RFC 8252 原生登录流程(系统浏览器打开授权页、密码登录、拿 code 换 token),然后在同一秒里做一次浏览器登录,验证浏览器那份 token 在退出前、退出后、重启后都不能单独当 bearer 用,而原生应用照旧保持登录。
从这件事能学到的三件事
第一,凡是“内容相同 ⇒ 令牌相同”的签名方案,都需要一个不可预测字段。 只签 sub、kind、exp 这类业务字段,等于让时间戳当唯一性来源——秒级精度下,并发就是碰撞。JWT 里的 jti 存在的意义就是这个;自己造 token 时,随机指纹要么加,要么改用能保证唯一的方式。
第二,安全检查要覆盖“同一身份的多个入口”。 这次不是验签写错了,而是两条入口(浏览器 cookie 和原生 bearer)共用了一套“识别凭证”的办法:比摘要。摘要比对本身没错,错在被比对的两把钥匙可能一样。判断一个会话是不是“那个已经退出的会话”,本质上需要会话身份,而不是凭证内容。
第三,退出登录要有明确的语义边界。 现在这套契约说得清楚:hermes webapp --sign-out 只退浏览器。有人提过让原生应用也一起退(issue #144668),维护者的结论是“当前契约下这是预期行为”,所以没有改。这是产品决定,不是漏洞——但你要知道自己点的那一下到底退了什么。
你需要做什么
这个修复在 10 月 26 日合入 main,v0.22.5(10 月 23 日发布)里没有,所以下一个版本才会有。在那之前,如果你的机器是多人共用、或者你会临时把浏览器借给别人:
- 别只依赖“点退出”。真正的隔离是关掉整个 webapp 监听(改回
webapp.enabled: false并重启 gateway),或者把监听绑回127.0.0.1; - 需要共享机器时,给不同的人用不同的 profile,而不是同一个 profile 换登录;
- 借出前重启一次 gateway,让内存态的会话记录一起失效。
场景细节可以接着看这两篇:一次性登录链接是怎么交接的,以及 webapp 到底是什么、怎么开。如果你的 dashboard 也对外,登录加固那篇里的思路可以一起用。
最后一句话总结这次的教训:**“退出登录”要退的是“这个会话”,不是“这串字符”。**当两者不是一对一的时候,退出就只是把门关上,钥匙还在别人口袋里。