你给面板设的登录限流,一个请求头就能绕过

你花了一个下午把 Hermes 的 Web 面板放到了 nginx 后面,设了密码,开了 HTTPS,然后把端口暴露到公网。你告诉自己是安全的:登录接口每分钟只允许 10 次尝试,暴力破解在数学上不成立。结果同一个接口,攻击者只要在每个请求上换一个 X-Forwarded-For 值,就能拿到无限次尝试机会——因为限流是按“客户端 IP”分的桶,而“客户端 IP”取自攻击者自己写的那一行请求头。
2026 年 10 月 5 日,Hermes 仓库一天之内合并了三个针对面板认证的加固修复(PR #133367、#133369、#133370)。它们不是新功能,而是同一个入口上的三个“没设上限”的地方。下面按用户能感知的顺序讲清楚:每个问题是什么、为什么危险、修好之后是什么行为、你自己的部署要不要动配置。
限流本来是怎么工作的
面板的密码登录接口是 /auth/password-login,按客户端 IP 限流,默认每分钟 10 次,超了返回 HTTP 429;用户不存在和密码错误都返回同一个 401 Invalid credentials,这样接口不能拿来枚举用户名。
这套设计本身没问题,问题出在“客户端 IP 从哪来”。
问题一:客户端 IP 是请求方自己声明的
hermes_cli/dashboard_auth/request_utils.py 里的 client_ip() 过去直接取请求头 X-Forwarded-For 的第一个值返回。这个头本来就是代理链上“由别人写进来”的信息,客户端完全可以自己伪造。于是:
- 每次请求换一个
X-Forwarded-For值,就得到一个全新的限流桶,每分钟 10 次的上限形同虚设; - 原生登录流程里按 IP 计的“待处理请求上限”同样可以被绕开;
- 认证审计日志里记录的来源地址,变成了攻击者想写什么就写什么。
修复方式很干脆:client_ip() 现在只返回 ASGI 层的对端地址(request.client.host),不再读原始请求头。那反代场景怎么办?uvicorn 的 ProxyHeadersMiddleware 会在请求确实来自可信代理(本机回环,或 dashboard.trusted_proxies 里列出的地址)时,才用 X-Forwarded-For 改写这个对端地址,并且取的是“从右往左第一个不可信跳”,所以一个正常的反代会给每个真实客户端分配各自的限流桶。
问题二:一个字段就能把磁盘写满
POST /auth/password-login 是未认证接口。它过去会把请求里的 provider 字符串原样复制进 dashboard-auth.log——未知 provider(404)和被限流(429)两条路径都会写。也就是说,一次请求就能往日志里写任意长度的内容:PR 里的探针显示,一个 10 万字符的 provider 在修复前会在日志里留下约 100 KB,修复后是 336 字符。
现在 provider 被限制为最多 128 字符,超长值在进入处理函数之前就被 FastAPI 以 422 拒掉,因此不会被记录。同时 audit_log() 给每个字符串字段加了 256 字符的上限,嵌套值和非 JSON 值的 repr 也一视同仁——这样将来新增审计字段也不会再把同类问题带回来。副作用只有一个:如果你自建了一个 ID 超过 128 字符的密码 provider,它将无法登录;官方内置的 basic、nous、self-hosted、drain-secret 都远短于这个上限。
问题三:公开认证接口没有请求体大小上限
/auth/ 下三个公开的 POST 路由——/auth/password-login、/auth/native/token、/auth/native/refresh——先通过认证网关,然后 FastAPI 会把整个 JSON body 缓冲起来再做校验。过去没有大小限制,匿名客户端可以让面板在内存里扛住一个很大甚至永不结束的上传。
新增的 AuthBodyLimitMiddleware(一个纯 ASGI 中间件)给公开的 /auth/ POST 定了 64 KiB 预算:
Content-Length超限时,在读取任何 body 之前就返回 413;- 它同时统计实际收到的字节数,所以分块传输(chunked)或故意写小
Content-Length的请求,也会在同一个上限处被截断。
这个中间件注册在应用最内层,外层没有任何中间件会读请求体,所以限制发生在缓冲之前。已认证路由、上传和 WebSocket 都不受影响。
你自己的部署要不要动配置
三个修复都改的是服务端行为,不需要你写代码。唯一需要你确认的是 dashboard.trusted_proxies:只有列在这里的对端(以及本机回环,回环始终被信任)才有资格提供 X-Forwarded-For。*、0.0.0.0/0、::/0 这类无界值会被拒绝。
dashboard:
public_url: "https://dashboard.example.com/hermes"
trusted_proxies:
- "172.20.0.0/24"
如果你没有列出反向代理的地址,会有什么后果?客户端拿不到自己的 IP,于是同一个代理后面的所有客户端共用一个登录限流额度,审计事件记录的也是代理的地址。这本身是安全的一侧(宁可共用桶,也不要被伪造),但会让限流失真,所以推荐做法是给代理一个精确 IP,或者一个“只放代理”的有界网段。
验证方法很土但有效:对着登录接口连打 11 次,第 11 次应该拿到 429;带一个伪造的 X-Forwarded-For 再打 11 次,还是应该在第 11 次拿到 429。如果第二次没有被拦,说明你的实例还是旧行为。
上线状态
这三个修复目前只在 main 上,没有进入任何已发布的 tag(最新发布是 v0.21.5,tag 为 v2026.9.24)。跑 git 安装的用户拉一次最新 main 即可拿到;用 Docker 镜像或托管部署的用户要等下一个 tag 构建。想确认自己实例的版本,用 hermes version;想从整体上了解面板的登录与代理配置,可以参考面板命令参考。
小结
三个修复共享同一个形状:把未认证的输入当作可信数据用。客户端自己声明的来源地址被当成身份、客户端提交的字符串被当成有界字段、客户端发来的 body 被当成合理大小。这类问题在任何对外暴露的服务里都会反复出现,而它们的修法也一致——只相信自己这一侧观察到的信息,对一切外部输入设上限。
顺带一提,同一天还有一批关于凭据与文件边界的修复,如果你在管自己的部署,凭据导出导致密钥外泄的那个坑和Profile 之间的访问边界值得一起看。