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
-35
View File
@@ -15,9 +15,6 @@ class Config:
If unset, a key is generated and persisted in the DB (see
``thoughtsync.settings.load_or_create_secret_key``), so sessions survive
restarts with no volume required.
- ``THOUGHTSYNC_TRUSTED_PROXY_HOPS`` — how many proxies in front of this app may
be believed when reading ``X-Forwarded-For`` / ``X-Forwarded-Proto``. Defaults
to 1 (one reverse proxy terminating TLS). See ``trusted_proxy_hops``.
Uploaded media lives under ``DATA_DIR`` — a fixed, authoritative path
(``/var/thoughtsync``), intentionally NOT configurable (a mutable data path only
@@ -52,35 +49,3 @@ class Config:
"""Optional break-glass override for the cookie-signing secret."""
return os.environ.get("THOUGHTSYNC_SECRET_KEY") or None
@classmethod
def trusted_proxy_hops(cls) -> int:
"""How many proxies in front of this app may be believed.
`X-Forwarded-For` grows LEFT to RIGHT: each hop appends the address it saw.
So the RIGHTMOST entry was written by our own proxy and is the address that
actually connected to it, while anything a client sent arrives to the LEFT of
that — which is why the leftmost entry, the "original client", is precisely
the one a caller can forge.
With `n` trusted hops the real client is the nth entry from the right:
0 no proxy — ignore the header entirely, use the socket address
1 one reverse proxy terminating TLS (the default, and this deployment)
2 a CDN in front of that proxy — Cloudflare appended the client, our
proxy appended Cloudflare
Set it to the number of proxies you actually run. Too HIGH and a caller can
forge an address by padding the header; too low and everyone behind the CDN
shares one bucket. Too low is the safe direction, so it is the fallback when
the header is shorter than configured.
Env rather than the Settings UI (rule 25's "absolute bootstrap only" carve-
out): it is a property of the deployment topology, not a preference, and the
rate limiter consults it before opening a database connection — which is the
whole point of checking a throttle before doing expensive work.
"""
raw = os.environ.get("THOUGHTSYNC_TRUSTED_PROXY_HOPS", "1")
try:
return max(0, int(raw))
except (TypeError, ValueError):
return 1