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

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:
2026-08-23 15:24:15 -04:00
parent a85c53ba2c
commit 09b5f874b6
11 changed files with 302 additions and 98 deletions
+131 -1
View File
@@ -19,6 +19,13 @@ class SettingDef:
label: str
description: str
group: str
# Ints only. Enforced server-side in validate_updates and passed to the UI so the
# number input carries them too. These exist because several of the security
# values have ranges where a typo is not merely wrong but dangerous — a proxy hop
# count of 50 would trust anything a caller sent, and a sign-in limit of 0 would
# lock every account out permanently.
minimum: int | None = None
maximum: int | None = None
# The source of truth for every user-facing setting. Add a row here and it appears
@@ -70,6 +77,80 @@ REGISTRY: list[SettingDef] = [
"The server contacts the linked site; private/internal addresses are always blocked.",
"Links",
),
# --- Security -----------------------------------------------------------------
#
# Read on paths too hot for a database round trip (the credential throttle checks
# them BEFORE opening a connection, which is the point of checking a throttle
# before doing expensive work), so they are cached — see `live()` below.
SettingDef(
"trusted_proxy_hops",
"int",
1,
"Trusted proxy hops",
"How many proxies sit in front of this server. 1 for a single reverse proxy "
"terminating HTTPS; 2 if a CDN like Cloudflare sits in front of that; 0 if "
"the app is exposed directly. This decides which entry of X-Forwarded-For is "
"believed — set it TOO HIGH and a visitor can forge their own address and "
"slip the sign-in limits below.",
"Security",
minimum=0,
maximum=10,
),
SettingDef(
"signin_limit_per_account",
"int",
10,
"Failed sign-ins per account",
"How many failures one account tolerates within the window before it stops "
"answering. Comfortably above mistyping a password, far below anything that "
"makes guessing worth attempting.",
"Security",
minimum=1,
maximum=1000,
),
SettingDef(
"signin_limit_per_address",
"int",
50,
"Failed sign-ins per address",
"The same, counted per visitor address instead of per account — it bounds one "
"source trying many accounts. Wider, because one address is legitimately many "
"people: a household, an office, a phone on carrier NAT.",
"Security",
minimum=1,
maximum=10000,
),
SettingDef(
"signin_window_minutes",
"int",
15,
"Sign-in window (minutes)",
"The trailing period both sign-in limits are counted over.",
"Security",
minimum=1,
maximum=1440,
),
SettingDef(
"register_limit_per_address",
"int",
5,
"Sign-ups per address",
"How many accounts one address may create within its window. Counted per "
"attempt rather than per failure — each one is a row either way.",
"Security",
minimum=1,
maximum=1000,
),
SettingDef(
"register_window_minutes",
"int",
60,
"Sign-up window (minutes)",
"The trailing period the sign-up limit is counted over.",
"Security",
minimum=1,
maximum=10080,
),
]
_BY_KEY: dict[str, SettingDef] = {d.key: d for d in REGISTRY}
@@ -153,11 +234,52 @@ async def get_admin_settings(db) -> list[dict]:
"label": d.label,
"description": d.description,
"group": d.group,
"minimum": d.minimum,
"maximum": d.maximum,
}
)
return result
# Settings the app must be able to read WITHOUT awaiting a database.
#
# The credential throttle consults these before opening a connection — deliberately,
# because a refused attempt is supposed to cost nothing, and the proxy hop count is
# needed to know who is even asking. A per-request query would undo both.
#
# Seeded from the registry defaults so the app works before (and without) a database —
# unit tests construct it with no Postgres at all — then refreshed from the DB at boot
# and again whenever an admin saves. Same live-update contract `session_ttl_days`
# already has in settings_api.py.
_LIVE_KEYS = (
"trusted_proxy_hops",
"signin_limit_per_account",
"signin_limit_per_address",
"signin_window_minutes",
"register_limit_per_address",
"register_window_minutes",
)
_live: dict[str, Any] = {k: _BY_KEY[k].default for k in _LIVE_KEYS}
def live(key: str) -> Any:
"""The cached value of a hot setting. Synchronous, never touches the database."""
return _live[key]
async def refresh_live(db) -> None:
"""Re-read the hot settings into the cache. Called at boot and after every save."""
for key in _LIVE_KEYS:
_live[key] = await get_setting(db, key)
def reset_live() -> None:
"""Back to registry defaults. For tests — nothing in the app calls this."""
for key in _LIVE_KEYS:
_live[key] = _BY_KEY[key].default
def validate_updates(updates: dict) -> tuple[dict, str | None]:
"""Coerce/validate a {key: value} dict against the registry. Returns
(clean_values, error_message). An unknown key or a bad int is rejected."""
@@ -168,9 +290,17 @@ def validate_updates(updates: dict) -> tuple[dict, str | None]:
return {}, f"unknown setting: {key}"
if defn.type == "int":
try:
clean[key] = int(val)
n = int(val)
except (ValueError, TypeError):
return {}, f"{defn.label} must be a whole number"
# Rejected rather than clamped: silently accepting a number and storing a
# different one is how somebody ends up believing a protection is set to
# something it is not.
if defn.minimum is not None and n < defn.minimum:
return {}, f"{defn.label} must be at least {defn.minimum}"
if defn.maximum is not None and n > defn.maximum:
return {}, f"{defn.label} must be at most {defn.maximum}"
clean[key] = n
elif defn.type == "bool":
clean[key] = _coerce_bool(val)
else: