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:
+131
-1
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user