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

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:
2026-08-23 15:12:14 -04:00
parent 2141a0ac45
commit a85c53ba2c
9 changed files with 267 additions and 56 deletions
+36
View File
@@ -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