Security values move into the Settings UI
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
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
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.
This commit is contained in:
@@ -23,7 +23,7 @@ came from, and either one can refuse it:
|
||||
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
|
||||
``THOUGHTSYNC_TRUSTED_PROXY_HOPS`` so that only entries our own proxies wrote are
|
||||
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.
|
||||
@@ -36,29 +36,18 @@ from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import deque
|
||||
from collections.abc import Callable
|
||||
|
||||
from .settings import live
|
||||
|
||||
|
||||
# Failed sign-ins tolerated per account before it stops answering, and for how long.
|
||||
# Ten is comfortably above a person mistyping a password and far below anything that
|
||||
# makes a dictionary worth running.
|
||||
ACCOUNT_LIMIT = 10
|
||||
ACCOUNT_WINDOW_S = 15 * 60
|
||||
|
||||
# Wider, because one address is legitimately many people: a household, an office
|
||||
# behind NAT, a phone on carrier-grade NAT.
|
||||
ADDRESS_LIMIT = 50
|
||||
ADDRESS_WINDOW_S = 15 * 60
|
||||
|
||||
# Registration is scarcer than a sign-in — it creates a row, and on a private
|
||||
# instance the honest number of accounts anyone needs to make is one.
|
||||
REGISTER_LIMIT = 5
|
||||
REGISTER_WINDOW_S = 60 * 60
|
||||
|
||||
# Never let the bookkeeping become the denial of service: an attacker rotating a
|
||||
# forged X-Forwarded-For 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.
|
||||
# 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
|
||||
|
||||
|
||||
@@ -68,13 +57,25 @@ class SlidingWindow:
|
||||
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: int, window_s: float) -> None:
|
||||
self.limit = limit
|
||||
self.window_s = window_s
|
||||
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:
|
||||
@@ -112,9 +113,19 @@ class SlidingWindow:
|
||||
self._hits.clear()
|
||||
|
||||
|
||||
sign_in_by_account = SlidingWindow(ACCOUNT_LIMIT, ACCOUNT_WINDOW_S)
|
||||
sign_in_by_address = SlidingWindow(ADDRESS_LIMIT, ADDRESS_WINDOW_S)
|
||||
register_by_address = SlidingWindow(REGISTER_LIMIT, REGISTER_WINDOW_S)
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user