Your dashboard login limit was spoofable with one header


You spent an afternoon putting the Hermes web dashboard behind nginx, set a password, turned on HTTPS, and exposed the port to the internet. You told yourself it was fine: the login endpoint allows ten attempts per minute, so brute force is mathematically hopeless. Then it turns out the attacker only has to send a different X-Forwarded-For value on every request to get an unlimited number of attempts — because the rate limit is bucketed by “client IP”, and the client IP came from a header the attacker wrote.

On October 5, 2026, the Hermes repository merged three hardening fixes for dashboard authentication in a single day (PRs #133367, #133369, #133370). None of them is a feature. All three are the same class of mistake on the same entry point: input with no ceiling, trusted because it arrived. Here is what each one was, why it mattered, what the fixed behaviour looks like, and whether your own deployment needs a config change.

What the rate limit was supposed to do

The dashboard’s password login lives at /auth/password-login. It is rate-limited per client IP, ten attempts per minute by default, returning HTTP 429 past that. An unknown user and a wrong password both return the same generic 401 Invalid credentials, so the endpoint cannot be used as a username-enumeration oracle.

The design is fine. The problem was where “client IP” came from.

One: the client IP was whatever the client claimed

client_ip() in hermes_cli/dashboard_auth/request_utils.py used to return the first value of the X-Forwarded-For request header. That header is, by construction, information written into the chain by someone else — and a client can forge it. So:

  • sending a different X-Forwarded-For value on each request produced a fresh rate-limit bucket, making the ten-per-minute cap meaningless;
  • the per-IP pending cap on the native sign-in flow could be bypassed the same way;
  • the source address recorded in the auth audit log became whatever the attacker felt like writing.

The fix is blunt: client_ip() now returns only the ASGI peer (request.client.host) and never reads the raw header. What about the reverse-proxy case? Uvicorn’s ProxyHeadersMiddleware rewrites that peer from X-Forwarded-For only when the request actually arrives from a trusted proxy — loopback, or an address listed in dashboard.trusted_proxies — and it takes the rightmost hop that is not trusted, so a normal reverse proxy still gives each real client its own bucket.

Two: one field could fill your disk

POST /auth/password-login is unauthenticated. It used to copy the request’s provider string verbatim into dashboard-auth.log, on both the unknown-provider (404) path and the rate-limited (429) path. One request could therefore write a log entry of any size: the PR’s own probe shows a 100,000-character provider leaving about 100 KB in the log before the fix, and 336 characters after it.

provider is now capped at 128 characters. FastAPI rejects longer values with a 422 before the handler runs, so nothing is logged. On top of that, audit_log() caps every string value at 256 characters — including nested values and the repr of non-JSON ones — so a future audit field cannot reintroduce the same unbounded write. There is exactly one side effect: a custom password provider whose ID is longer than 128 characters will no longer be able to log in. The shipped provider IDs (basic, nous, self-hosted, drain-secret) are far shorter.

Three: the public auth routes had no body size limit

The three public POST routes under /auth/ — /auth/password-login, /auth/native/token, /auth/native/refresh — pass the auth gate, after which FastAPI buffers the entire JSON body before validating it. There was no size limit, so an anonymous client could make the dashboard hold a large or never-ending upload in memory.

A new pure-ASGI AuthBodyLimitMiddleware puts a 64 KiB budget on public /auth/ POSTs:

  • if Content-Length is over the limit, it answers 413 before reading a single body byte;
  • it also counts the bytes it actually receives, so a chunked body, or one with an understated Content-Length, is cut off at the same limit.

The middleware is registered innermost — no outer HTTP middleware reads request bodies — so the limit applies before anything buffers. Authenticated routes, uploads and WebSockets are unchanged.

What your own deployment needs to change

All three fixes are server-side behaviour; there is no code for you to write. The one thing worth checking is dashboard.trusted_proxies: only peers listed there (plus loopback, which is always trusted) are allowed to supply X-Forwarded-For. Unbounded entries like *, 0.0.0.0/0 and ::/0 are rejected.

dashboard:
  public_url: "https://dashboard.example.com/hermes"
  trusted_proxies:
    - "172.20.0.0/24"

If you do not list your reverse proxy, clients behind it cannot present their own address, so every client behind that proxy shares one password-login budget and audit events record the proxy’s address. That errs on the safe side — a shared bucket beats a forgeable one — but it makes the rate limit imprecise, so prefer an exact proxy IP, or a bounded, proxy-only network.

There is a crude but effective way to check: hit the login endpoint eleven times and expect 429 on the eleventh. Then do it again with a forged X-Forwarded-For on every request; the eleventh should still be 429. If the second run sails through, your instance still has the old behaviour.

Release status

All three fixes are on main and in no published tag yet (the newest release is v0.21.5, tag v2026.9.24). Git installs get them by pulling the latest main; Docker images and hosted deployments get them when the next tag is built. You can confirm what you are running with hermes version, and the full picture of dashboard login and proxy configuration is in the dashboard command reference.

The pattern behind all three

These three fixes share one shape: unauthenticated input used as if it were trustworthy. A client-declared source address treated as identity, a client-submitted string treated as a bounded field, a client-sent body treated as a reasonable size. The same class shows up in every service that faces the internet, and the fix is always the same: trust only what your own side observed, and put a ceiling on everything that arrives.

For completeness, the same day also brought fixes about credential and file boundaries — the profile export that could ship your secrets and the access boundary between profiles are worth reading alongside these.