Trust proxy headers by hop count, and log every credential event
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) Successful in 9s
CI & Build / integration (push) Successful in 12s
CI & Build / Build & push image (push) Successful in 32s
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) Successful in 9s
CI & Build / integration (push) Successful in 12s
CI & Build / Build & push image (push) Successful in 32s
Operator, before exposing the instance: *"I'd expect that we should have a proxy hops setting for how many proxy hops we should trust a shared real-ip at… and is there any session logging."* Neither existed, and the first one was a real hole. **The address was forgeable.** `client_address()` read the LEFTMOST `X-Forwarded-For` entry — nominally "the original client", and precisely the one a caller controls, because anything they send arrives before what proxies append. So `curl -H "X-Forwarded-For: 1.2.3.4"`, rotated per request, minted a fresh rate-limit bucket every time. Concretely: stuffing ONE account stayed limited (the account key is unforgeable and that is why it exists), but spraying MANY accounts from one source was not — each account got its own budget, and the per-address cap meant to bound the total was defeated by a header. On a LAN that is nothing. It is not nothing on a public host. Now it counts in from the RIGHT by `THOUGHTSYNC_TRUSTED_PROXY_HOPS`, default 1. Each hop appends what it saw, so the rightmost entries are the ones our own infrastructure wrote and a forged prefix lands to the left of them where it can never be selected — proven for the honest, forged, padded, CDN and shorter-than-configured cases. 0 ignores the header entirely; 2 is Cloudflare in front of a proxy. Too high is the dangerous direction, so a header shorter than configured falls back to the socket address rather than reaching further left. `X-Forwarded-Proto` had the same bug and now shares the same rule. Both live in a new `proxy.py` rather than being written twice — two places holding one decision is how issue 2183 happened, and this is the same decision. Env rather than the Settings UI, against rule 25's usual pull: it is deployment topology rather than preference, and the limiter consults it BEFORE opening a database connection, which is the entire point of checking a throttle before doing expensive work. Easy to move if that reads wrong. **And there was no logging at all** — `auth.py` had no logger, and the only record of anything was `device_tokens.last_used_at`. Sign-ins, failures, throttle trips, new accounts and device-token issuance now all log, with the attempted email and the trusted address. Deliberately including the email: it is the operator's own server, and "somebody failed a login" without saying against which account is not actionable. `basicConfig` at INFO in `create_app`, because hypercorn configures its own loggers and leaves the root at WARNING — without it every line above would have gone nowhere, which is a worse failure than not writing them. This is the app log, not an audit table. Not queryable, not retained past log rotation. The table is task 2939; this is what makes the next few days observable.
This commit is contained in:
@@ -15,6 +15,9 @@ 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
|
||||
@@ -48,3 +51,36 @@ class Config:
|
||||
def secret_key_env(cls) -> str | None:
|
||||
"""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