From 9e1b7ce34fd4ad9e98569076dc2823df2b929493 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Sun, 7 Jun 2026 22:28:57 -0700 Subject: [PATCH] feat(ratelimit): env-tunable per-IP auth limiter budgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add RATELIMIT_OTC_REQUEST_MAX / RATELIMIT_VERIFY_MAX / RATELIMIT_CHECK_MAX (default to the existing secure values; non-positive/unparseable → default). Lets a test/PPE stack that drives the auth endpoints repeatedly from one IP lift the budget; production leaves them unset. Mirrors the existing OTC_REQUEST_COOLDOWN_SECONDS knob. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/ratelimit.py | 25 ++++++++++++++-- backend/tests/test_ratelimit_env.py | 44 +++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_ratelimit_env.py diff --git a/backend/app/ratelimit.py b/backend/app/ratelimit.py index 41850f3..db698e5 100644 --- a/backend/app/ratelimit.py +++ b/backend/app/ratelimit.py @@ -19,11 +19,27 @@ lockouts). Both layers run together. """ from __future__ import annotations +import os import threading import time from collections import defaultdict, deque +def _max_events(env_name: str, default: int) -> int: + """Per-limiter budget, overridable via env (e.g. a test/PPE stack that + drives the auth endpoints repeatedly from one IP). Production leaves these + unset and gets the secure defaults below. A non-positive / unparseable + value falls back to the default.""" + raw = os.environ.get(env_name, "").strip() + if not raw: + return default + try: + n = int(raw) + except ValueError: + return default + return n if n > 0 else default + + class SlidingWindowLimiter: """Allow at most `max_events` per `window_seconds` per key. @@ -66,12 +82,15 @@ class SlidingWindowLimiter: # * verify: 10 attempts / 5 min / IP across the auth verify surfaces. # * otc request: 5 sends / 5 min / IP (Turnstile is the primary gate; # this is defense in depth against a solved-challenge replay loop). -verify_limiter = SlidingWindowLimiter(max_events=10, window_seconds=300) -otc_request_limiter = SlidingWindowLimiter(max_events=5, window_seconds=300) +verify_limiter = SlidingWindowLimiter( + max_events=_max_events("RATELIMIT_VERIFY_MAX", 10), window_seconds=300) +otc_request_limiter = SlidingWindowLimiter( + max_events=_max_events("RATELIMIT_OTC_REQUEST_MAX", 5), window_seconds=300) # /auth/passcode/check is an anonymous has-passcode oracle (audit 0026 L3). # It's a legitimate Login-flow affordance, so the budget is generous — # enough for a human typing emails, tight enough to stop bulk scraping. -check_limiter = SlidingWindowLimiter(max_events=30, window_seconds=300) +check_limiter = SlidingWindowLimiter( + max_events=_max_events("RATELIMIT_CHECK_MAX", 30), window_seconds=300) def _reset_all_for_tests() -> None: diff --git a/backend/tests/test_ratelimit_env.py b/backend/tests/test_ratelimit_env.py new file mode 100644 index 0000000..51772ad --- /dev/null +++ b/backend/tests/test_ratelimit_env.py @@ -0,0 +1,44 @@ +"""The per-IP limiter budgets are env-overridable (test/PPE stacks drive the +auth endpoints repeatedly from one IP); production leaves them unset and keeps +the secure defaults. A non-positive / unparseable value falls back.""" +from __future__ import annotations + +import importlib + +import pytest + + +def _reload_with(monkeypatch, **env): + for k, v in env.items(): + if v is None: + monkeypatch.delenv(k, raising=False) + else: + monkeypatch.setenv(k, v) + import app.ratelimit as ratelimit + return importlib.reload(ratelimit) + + +@pytest.fixture(autouse=True) +def _restore(): + yield + # Leave the module in its default state for other tests. + import app.ratelimit as ratelimit + importlib.reload(ratelimit) + + +def test_defaults_when_unset(monkeypatch): + rl = _reload_with(monkeypatch, RATELIMIT_OTC_REQUEST_MAX=None, RATELIMIT_VERIFY_MAX=None) + assert rl.otc_request_limiter.max_events == 5 + assert rl.verify_limiter.max_events == 10 + + +def test_env_override(monkeypatch): + rl = _reload_with(monkeypatch, RATELIMIT_OTC_REQUEST_MAX="1000", RATELIMIT_VERIFY_MAX="250") + assert rl.otc_request_limiter.max_events == 1000 + assert rl.verify_limiter.max_events == 250 + + +def test_bad_value_falls_back_to_default(monkeypatch): + rl = _reload_with(monkeypatch, RATELIMIT_OTC_REQUEST_MAX="nope", RATELIMIT_VERIFY_MAX="0") + assert rl.otc_request_limiter.max_events == 5 # unparseable → default + assert rl.verify_limiter.max_events == 10 # non-positive → default