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