Files
inkwell/src/thoughtsync/ratelimit.py
T
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

135 lines
5.5 KiB
Python

"""Throttling for the endpoints that a public deployment leaves exposed.
Only the credential endpoints are rate-limited: login, register, and the native
device-link exchange. Everything else already needs a session or a device token to
reach, so an attacker has to get through one of these three first.
## Why in-process is enough here, and where that stops being true
State lives in module-level dicts, so it is per-process. That is correct for how
this image actually serves — one hypercorn worker (see the Dockerfile, and the
same assumption the trash sweeper documents in app.py). If that ever gains
``--workers N``, each worker would keep its own counters and the effective limit
would multiply by N; the fix then is a shared store (the Postgres connection is
already there), not a bigger number here.
## Two keys, on purpose
Every attempt is counted against BOTH the account being tried and the address it
came from, and either one can refuse it:
- **The account** is the key that matters, and the key that cannot be forged. It
is what stops credential stuffing against one known email, no matter how many
addresses the attempts arrive from.
- **The address** bounds the damage from one source spraying many accounts. It is
read from ``X-Forwarded-For``, counting in from the RIGHT by
the **Trusted proxy hops** setting so that only entries our own proxies wrote are
believed — a forged header lands to the left of those and is never selected. It is
still the weaker of the two keys, because it depends on that setting matching the
deployment; the account key depends on nothing.
Counting is by failure for the sign-in routes and by attempt for registration: a
correct password should never move someone closer to being locked out, but every
registration is a row in the users table whether it succeeds or not.
"""
from __future__ import annotations
import time
from collections import deque
from collections.abc import Callable
from .settings import live
# Never let the bookkeeping become the denial of service: an attacker rotating an
# address could otherwise mint an unbounded number of buckets. Well above any real
# deployment's distinct-caller count, so a legitimate instance never reaches it; when
# it is reached the oldest buckets are dropped, which at worst forgives some attempts.
#
# Not a setting: it protects the limiter from itself rather than the app from a
# caller, and there is no operator judgment to apply to it.
MAX_BUCKETS = 10_000
class SlidingWindow:
"""Counts events per key over a trailing window.
Sliding rather than a fixed window because a fixed one lets twice the limit
through across a boundary — 10 at 14:59 and 10 at 15:00 — which for a login
limiter is the difference between the number meaning something and not.
The limit and window are SUPPLIERS, not values, so an admin saving a new number in
Settings takes effect on the next attempt instead of the next deploy. They are read
per call, which is a dict lookup — the settings cache never touches the database.
"""
def __init__(self, limit: Callable[[], int], window_s: Callable[[], float]) -> None:
self._limit = limit
self._window_s = window_s
self._hits: dict[str, deque[float]] = {}
@property
def limit(self) -> int:
return self._limit()
@property
def window_s(self) -> float:
return self._window_s()
def _prune(self, key: str, now: float) -> deque[float]:
hits = self._hits.get(key)
if hits is None:
hits = deque()
# Insertion-ordered, so the first key is the least recently created.
if len(self._hits) >= MAX_BUCKETS:
self._hits.pop(next(iter(self._hits)), None)
self._hits[key] = hits
cutoff = now - self.window_s
while hits and hits[0] <= cutoff:
hits.popleft()
return hits
def retry_after(self, key: str, now: float | None = None) -> int | None:
"""Seconds until `key` may try again, or None while it is still under the
limit. Read-only — it does not count as an attempt."""
now = time.monotonic() if now is None else now
hits = self._prune(key, now)
if len(hits) < self.limit:
return None
# The window frees up when its OLDEST hit falls out of it.
return max(1, int(hits[0] + self.window_s - now) + 1)
def record(self, key: str, now: float | None = None) -> None:
now = time.monotonic() if now is None else now
self._prune(key, now).append(now)
def forget(self, key: str) -> None:
"""Drop a key's history. Used after a successful sign-in, so someone who
fumbled a password twice and then got it right starts clean rather than
carrying those two for the next quarter of an hour."""
self._hits.pop(key, None)
def clear(self) -> None:
self._hits.clear()
def _minutes(key: str) -> Callable[[], float]:
return lambda: float(live(key)) * 60.0
sign_in_by_account = SlidingWindow(
lambda: live("signin_limit_per_account"), _minutes("signin_window_minutes")
)
sign_in_by_address = SlidingWindow(
lambda: live("signin_limit_per_address"), _minutes("signin_window_minutes")
)
register_by_address = SlidingWindow(
lambda: live("register_limit_per_address"), _minutes("register_window_minutes")
)
def reset_all() -> None:
"""Drop every counter. For tests — nothing in the app calls this."""
for window in (sign_in_by_account, sign_in_by_address, register_by_address):
window.clear()