Repository navigation
fix(auth): key the TOTP lockout on client_id + source IP - #45
Conversation
POST /oauth/authorize takes a TOTP guess with only a client_id, which is not a secret, and had no per-IP limit. Five wrong codes locked that client_id out everywhere (dashboard login, /app/refresh, token minting, client_credentials, self-update) for five minutes, so anyone who knew a client_id could keep its owner locked out. - New ratelimit.TotpLockout: 5 wrong codes from one IP lock that IP out for the client; 20 from any mix of IPs lock the client (still stops a guesser spreading attempts over many addresses). Strikes older than the cooldown no longer accumulate, and a success clears only the caller's own per-IP counter. - The source IP (trusted_proxies aware) is passed to every lockout check: /oauth/authorize, passkey sign-in, /oauth/token and the dashboard. - POST /oauth/authorize gets the same per-IP limiter as /app/login. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lxm6BaCA2kUHH1f6xvh8kS
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lxm6BaCA2kUHH1f6xvh8kS
Ailcope
left a comment
There was a problem hiding this comment.
@Showdown76py keying the lockout on IP is a real step up, but the per-client cap brings back part of the problem it fixes:
- The owner can still be locked out, passkeys included.
/oauth/authorizeonly needs aclient_id. 5 wrong codes from each of 4 IPs reachper_client=20(ratelimit.py:148). That blocks TOTP, the dashboard,/oauth/token, and the OAuth passkey endpoints (__main__.py:1683,:1716calltotp_locked). The attacker can repeat it every 5 minutes, and IPv6 makes new IPs free. - Brute force gets 4x easier. A guesser now gets 20 codes per 5-minute window instead of 5, roughly a 1.7% chance per day of hitting a valid code.
- Reverse proxy without
trusted_proxies. Everyone shares the proxy's IP, so the per-IP lockout and the authorize limiter become one budget an attacker can spend for everyone. Nothing tells the operator about it.
Fix commit eaee3bf (pushing right after this comment):
- The OAuth passkey endpoints no longer check the TOTP lockout. A passkey can't be guessed, so it stays the owner's way in while someone trips the TOTP cap. The per-IP
_authorize_passkey_guardlimit still applies. per_clientgoes from 20 to 10. That's a compromise; feel free to pick another number.- A one-time warning is logged when
X-Forwarded-Forarrives whileserver.trusted_proxiesis empty.
Full suite: 521 passed, ruff clean.
Generated by Claude Code
|
Correction to my review: the push of From eaee3bf59cc2d375054d03e3b0fe4c1afccafa36 Mon Sep 17 00:00:00 2001
From: Claude <noreply@anthropic.com>
Date: Sun, 11 Oct 2026 15:25:46 +0000
Subject: [PATCH] fix(auth): keep passkeys out of the TOTP lockout, lower the
per-client cap
- The per-client cap can be tripped on purpose from a few IPs, which
also blocked OAuth passkey sign-in. Passkeys can't be guessed, so the
OAuth passkey endpoints no longer check the TOTP lockout (per-IP rate
limiting still applies). The owner keeps a way in.
- per_client 20 -> 10: 20 gave a distributed guesser 4x the old budget.
- Warn once when X-Forwarded-For arrives with no trusted_proxies set:
every caller then shares the proxy's IP for limits and the lockout.
---
src/beaconmcp/__main__.py | 39 ++++++++++++++++++++++++--------------
src/beaconmcp/ratelimit.py | 6 ++++--
tests/test_totp_lockout.py | 9 +++++++++
3 files changed, 38 insertions(+), 16 deletions(-)
diff --git a/src/beaconmcp/__main__.py b/src/beaconmcp/__main__.py
index 23a4c38..9cc7aa3 100644
--- a/src/beaconmcp/__main__.py
+++ b/src/beaconmcp/__main__.py
@@ -1179,15 +1179,33 @@ def _run_http(mcp, host: str, port: int):
auth.register_token_store(token_store)
# In-memory TOTP brute-force guard, keyed on (client_id, source IP): 5
- # wrong codes from one IP lock that IP out for 5 minutes, and 20 from any
- # mix of IPs lock the client. Keyed on client_id alone, anyone who knew a
- # client_id could lock its owner out of every 2FA path.
- totp_lockout = TotpLockout(per_source=5, per_client=20, lockout_seconds=300)
+ # wrong codes from one IP lock that IP out for 5 minutes, and 10 from any
+ # mix of IPs lock the client's TOTP paths. Keyed on client_id alone, anyone
+ # who knew a client_id could lock its owner out of every 2FA path. The
+ # per-client cap can still be tripped on purpose, so passkey sign-in (which
+ # can't be guessed) is not gated by it and stays open to the owner.
+ totp_lockout = TotpLockout(per_source=5, per_client=10, lockout_seconds=300)
totp_locked = totp_lockout.locked
totp_record_failure = totp_lockout.record_failure
totp_record_success = totp_lockout.record_success
+ _proxy_warned = False
def _ip(request: Request) -> str:
+ nonlocal _proxy_warned
+ if (
+ not _proxy_warned
+ and not config.server.trusted_proxies
+ and request.headers.get("x-forwarded-for")
+ ):
+ # Behind a proxy that isn't declared, every caller shares the
+ # proxy's address, and the per-IP TOTP and login limits become
+ # one budget an attacker can spend for everyone.
+ _proxy_warned = True
+ logging.getLogger("beaconmcp.auth").warning(
+ "Request carries X-Forwarded-For but server.trusted_proxies is "
+ "empty: all clients share the proxy's IP for rate limits and "
+ "the TOTP lockout. Declare the proxy in server.trusted_proxies."
+ )
return client_ip(request, tuple(config.server.trusted_proxies))
def _issuer(request: Request) -> str:
@@ -1680,11 +1698,9 @@ def _run_http(mcp, host: str, port: int):
return JSONResponse(
{"ok": False, "error": "Unknown client."}, status_code=400,
)
- if totp_locked(client_id, _ip(request)):
- return JSONResponse(
- {"ok": False, "error": "Too many attempts. Try again in 5 minutes."},
- status_code=429,
- )
+ # No TOTP lockout check here: a passkey can't be guessed, and it is
+ # the owner's way in while someone trips the per-client TOTP cap.
+ # _authorize_passkey_guard already rate-limits per IP.
try:
options, state = passkey_service.authentication_options(
request, client_id=_passkey_owner(client_id),
@@ -1713,11 +1729,6 @@ def _run_http(mcp, host: str, port: int):
status_code=400,
)
client_id = normalized["client_id"]
- if totp_locked(client_id, _ip(request)):
- return JSONResponse(
- {"ok": False, "error": "Too many attempts. Try again in 5 minutes."},
- status_code=429,
- )
credential = body.get("credential")
state = str(body.get("state") or "")
if not isinstance(credential, dict) or not state:
diff --git a/src/beaconmcp/ratelimit.py b/src/beaconmcp/ratelimit.py
index 173a266..1943773 100644
--- a/src/beaconmcp/ratelimit.py
+++ b/src/beaconmcp/ratelimit.py
@@ -99,7 +99,9 @@ class TotpLockout:
Strikes are counted per source instead: ``per_source`` wrong codes from
one IP lock that IP out for that client. A second, higher per-client cap
(``per_client`` wrong codes from any mix of IPs) still stops a guesser
- who spreads attempts over many addresses.
+ who spreads attempts over many addresses. That cap can be tripped on
+ purpose from a few IPs, so callers must not use it to gate passkey
+ sign-in, which can't be guessed.
Every failure pushes the cooldown of its counters forward; a counter
whose cooldown has passed starts again from zero. A success clears the
@@ -111,7 +113,7 @@ class TotpLockout:
self,
*,
per_source: int = 5,
- per_client: int = 20,
+ per_client: int = 10,
lockout_seconds: float = 300.0,
) -> None:
self._per_source = per_source
diff --git a/tests/test_totp_lockout.py b/tests/test_totp_lockout.py
index 5b5a650..ed8964c 100644
--- a/tests/test_totp_lockout.py
+++ b/tests/test_totp_lockout.py
@@ -117,3 +117,12 @@ def test_unknown_ip_is_its_own_source(clock) -> None:
guard.record_failure("c1")
assert guard.locked("c1")
assert not guard.locked("c1", OWNER)
+
+
+def test_default_per_client_cap_is_ten(clock) -> None:
+ # 10, not 20: a guesser spread over many IPs gets two lockouts' worth of
+ # tries per window, not four.
+ guard = TotpLockout()
+ for i in range(10):
+ guard.record_failure("c1", f"203.0.113.{i}")
+ assert guard.locked("c1", OWNER)
--
2.43.0Generated by Claude Code |
…ent cap - The OAuth passkey endpoints no longer refuse a client whose TOTP budget is spent: a passkey cannot be guessed, so it stays the owner's way in while someone trips the per-client cap. The per-IP guard still applies. The dashboard passkey login already behaved this way. - per_client goes from 20 to 10 wrong codes per 5 minutes. - Log a one-time warning when X-Forwarded-For arrives while server.trusted_proxies is empty: every visitor behind that proxy then shares one rate-limit and lockout budget. Reported by Ailcope in review. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lxm6BaCA2kUHH1f6xvh8kS
|
@Ailcope agreed on all three.
On a real server (trusted proxy, wrong codes spread over several Generated by Claude Code |
Ailcope
left a comment
There was a problem hiding this comment.
LGTM, 0eb27dc covers all three points. Tiny nit: the comment above TotpLockout(...) in __main__.py still says "20 from any mix of IPs".
Generated by Claude Code
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lxm6BaCA2kUHH1f6xvh8kS
…n7ph-totp-lockout # Conflicts: # src/beaconmcp/__main__.py
Before: anyone who knew a
client_id(it is not a secret: it shows up in authorize URLs, browser history, reverse-proxy logs) could send 5 wrong TOTP codes toPOST /oauth/authorize, which needs nothing else and had no per-IP limit, and lock the owner out of the dashboard,/app/refresh, token minting,client_credentialsand self-update for 5 minutes, again and again.After: wrong codes lock out the address they come from, not the client. The owner signs in normally from anywhere else. A per-client cap still stops someone spreading guesses over many addresses.
How:
ratelimit.TotpLockout. 5 wrong codes from one IP lock that IP for that client; 20 wrong codes from any mix of IPs lock the client everywhere; 5-minute cooldown, pushed forward by each failure. Strikes from an expired cooldown no longer pile up (onmain, 4 wrong codes one day and 1 the next locked the client). A success clears only the caller's own per-IP counter, so the owner signing in does not reset the per-client cap for a distributed guesser.client_ip(), sotrusted_proxiesaware) is passed to every lockout check:/oauth/authorize, the passkey sign-in endpoints,/oauth/tokenand all dashboard 2FA paths (DashboardDeps.totp_*now take(client_id, ip)).POST /oauth/authorizegets the same per-IP limiter as/app/login(10 per minute). The limit message is shown on the authorize page (or in the JSON reply) with aRetry-Afterheader.Exercised on a real server
beaconmcp servefrommainand from this branch, dashboard on, real clients and real TOTP codes, source addresses set throughX-Forwarded-Forfrom the trusted loopback proxy:mainPOST /oauth/authorize, then the owner logs into the dashboard from another IP with the right secret and code/oauth/token(client_credentials)/oauth/authorizewith the right codeRetry-After: 60Unit tests:
tests/test_totp_lockout.py(per-source vs per-client, expiry, no accumulation across cooldowns, what a success resets). Dashboard test fixtures updated to the two-argument callables.Notes
trusted_proxiesmust list it (the example config andbeaconmcp initalready put127.0.0.1/::1). Otherwise every visitor shares the proxy's address and the per-IP lock behaves like the old per-client one.oauth_token; whichever merges second needs a small rebase.