Files
bvandeusen 09b5f874b6
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Failing after 9s
CI & Build / integration (push) Failing after 12s
CI & Build / Build & push image (push) Successful in 32s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m14s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Security values move into the Settings UI
Operator: *"proxy hops defaults to 1 and should be in the settings UI not in the
envs, we need the security values to be in the UI."* Overrules the call I made
yesterday, and rule 25 is on your side — I argued deployment-topology, but the
operator has to be able to SEE what protects them, and reading a container's
environment is not seeing.

Six new settings in a **Security** group: trusted proxy hops (default 1), the
per-account and per-address sign-in limits with their shared window, and the
sign-up limit with its own. `THOUGHTSYNC_TRUSTED_PROXY_HOPS` is gone; the rate
limits are no longer hardcoded constants.

**The hard part was keeping the throttle cheap.** It consults these BEFORE opening
a database connection — deliberately, because a refused attempt is meant to cost
nothing, and the hop count is needed to know who is even asking. A query per
attempt would undo both. So there is a small cache seeded from the registry
defaults (the app works with no database at all, which is what the DB-free unit
lane relies on), loaded at boot, and refreshed on every settings save — the same
live-update contract `session_ttl_days` already had.

`SlidingWindow` now takes its limit and window as SUPPLIERS rather than values, so
a saved number applies to the next attempt instead of the next deploy.

**Bounds are rejected, not clamped.** A hop count of 99 would trust anything a
caller sent; a sign-in limit of 0 would lock every account out permanently. Both
now fail validation with a message naming the range, and the number input carries
min/max so the browser objects first. Silently storing a different number than the
one typed is how somebody ends up believing a protection is set to something it is
not.

`MAX_BUCKETS` stays a constant on purpose: it protects the limiter from itself
rather than the app from a caller, and there is no operator judgment to apply.

Two integration tests, because the whole point is the round trip: a dangerous
value refused, a legitimate one reaching the cache the throttle reads and
persisting; and every Security row reaching the admin payload with bounds and a
description that explains itself.
2026-08-23 15:24:15 -04:00

146 lines
5.1 KiB
Python

"""The credential throttle. DB-free, like the rest of this suite.
The window itself is exercised directly with an injected clock, so nothing here
sleeps: a 15-minute window tested in real time is a test nobody runs twice.
The routes are exercised only as far as they get WITHOUT a database — a throttled
request returns 429 before any session is opened, which is the whole point of
checking the limit before the password. The happy path can't be reached here and is
not pretended at.
"""
import time
import pytest
from thoughtsync import ratelimit
from thoughtsync.settings import live
from thoughtsync.app import create_app
from thoughtsync.ratelimit import SlidingWindow
def window(limit: int, window_s: float) -> SlidingWindow:
"""A fixed-value window. The real ones read their numbers from the settings cache
so an admin's change applies immediately; these tests are about the counting, not
about where the numbers come from."""
return SlidingWindow(lambda: limit, lambda: window_s)
@pytest.fixture(autouse=True)
def _clean_counters():
ratelimit.reset_all()
yield
ratelimit.reset_all()
@pytest.fixture
def app():
return create_app()
def test_under_the_limit_is_not_blocked():
w = window(3, 60)
for i in range(3):
assert w.retry_after("k", now=i) is None
w.record("k", now=i)
assert w.retry_after("k", now=3) is not None
def test_window_slides_rather_than_resetting():
w = window(2, 60)
w.record("k", now=0)
w.record("k", now=30)
assert w.retry_after("k", now=31) is not None
# The 0s hit falls out at t=60, which frees exactly one slot — the 30s hit is
# still inside the window, so this is a slide and not a reset.
assert w.retry_after("k", now=61) is None
w.record("k", now=61)
assert w.retry_after("k", now=62) is not None
def test_retry_after_points_past_the_oldest_hit():
w = window(1, 100)
w.record("k", now=10)
wait = w.retry_after("k", now=40)
# The hit at t=10 leaves the window at t=110, i.e. 70s away. Rounded up, never
# under-reported — a client that waits exactly this long must not be refused
# again.
assert wait is not None
assert 70 <= wait <= 72
assert w.retry_after("k", now=40 + wait) is None
def test_keys_are_counted_separately():
w = window(1, 60)
w.record("a", now=0)
assert w.retry_after("a", now=1) is not None
assert w.retry_after("b", now=1) is None
def test_forget_clears_one_key():
w = window(1, 60)
w.record("a", now=0)
w.record("b", now=0)
w.forget("a")
assert w.retry_after("a", now=1) is None
assert w.retry_after("b", now=1) is not None
def test_bucket_count_is_bounded(monkeypatch):
# An attacker rotating a forged X-Forwarded-For must not be able to grow this
# dict without limit — the limiter cannot become the exhaustion it prevents.
monkeypatch.setattr(ratelimit, "MAX_BUCKETS", 8)
w = window(5, 60)
for i in range(50):
w.record(f"addr-{i}", now=i)
assert len(w._hits) <= 8
async def test_login_starts_refusing(app):
client = app.test_client()
body = {"email": "someone@example.com", "password": "wrong-password"}
# Pre-load the account's counter to its limit rather than posting that many
# times: every real attempt would need a database to reach the password check.
#
# On the REAL clock, not an injected one. The window is trailing, so hits stamped
# at t=0..9 are fifteen minutes stale the moment the route reads
# `time.monotonic()` and get pruned before they can refuse anything.
now = time.monotonic()
for _ in range(live("signin_limit_per_account")):
ratelimit.sign_in_by_account.record("someone@example.com", now=now)
resp = await client.post("/api/auth/login", json=body)
assert resp.status_code == 429
assert resp.headers.get("Retry-After")
# The refusal says nothing about whether that account exists.
assert "someone@example.com" not in (await resp.get_data(as_text=True))
async def test_device_login_shares_the_account_counter(app):
client = app.test_client()
now = time.monotonic()
for _ in range(live("signin_limit_per_account")):
ratelimit.sign_in_by_account.record("someone@example.com", now=now)
resp = await client.post(
"/api/auth/device-login",
json={"email": "someone@example.com", "password": "wrong-password"},
)
# Same budget as /login — otherwise guessing just moves to the route that hands
# out a long-lived bearer token.
assert resp.status_code == 429
async def test_register_is_throttled_by_address(app):
client = app.test_client()
now = time.monotonic()
for _ in range(live("register_limit_per_address")):
ratelimit.register_by_address.record("203.0.113.9", now=now)
resp = await client.post(
"/api/auth/register",
json={"email": "new@example.com", "password": "a-long-enough-password"},
# One entry, so with the default single trusted hop this IS the address the
# limiter keys on. The forged-prefix cases live in test_proxy.py.
headers={"X-Forwarded-For": "203.0.113.9"},
)
assert resp.status_code == 429