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
+20 -4
View File
@@ -7,7 +7,7 @@ one guessed password away from someone's whole note history.
This is what the app does about that on its own, and the four things it cannot do for
you.
## Do these four things first
## Do these five things first
**1. Check registration is closed.** On a fresh instance this now takes care of
itself: the first account created becomes the admin *and* closes registration behind
@@ -41,7 +41,19 @@ Once a browser has seen HSTS from your hostname it will refuse plain HTTP there
year, even if the header stops. That is the point of it, but it is worth knowing
before you put a hostname behind TLS temporarily.
**3. Stop publishing the app port.** The default compose binds `0.0.0.0:5000` so LAN
**3. Tell it how many proxies are in front of it.** `THOUGHTSYNC_TRUSTED_PROXY_HOPS`
defaults to `1` — one reverse proxy terminating TLS. Behind a CDN as well (Cloudflare
in front of your proxy) set it to `2`.
This decides which entry of `X-Forwarded-For` is believed, and it is a security
setting rather than a preference. The header grows left to right as a request
traverses, so the rightmost entries are the ones your own infrastructure wrote and
anything a caller forged sits to the left of them. Counting in from the right by the
number of proxies you actually run means a forged prefix can never be selected. Set it
too HIGH and it starts trusting entries no proxy of yours wrote; too low and several
callers share one rate-limit bucket, which is merely inconvenient.
**4. Stop publishing the app port.** The default compose binds `0.0.0.0:5000` so LAN
clients can reach it directly. Behind a proxy that is a second, unprotected front
door. In `.env`:
@@ -49,7 +61,7 @@ door. In `.env`:
THOUGHTSYNC_BIND=127.0.0.1
```
**4. Have a backup that includes the files.** Attachments are files on the
**5. Have a backup that includes the files.** Attachments are files on the
`thoughtsync-data` volume, not rows — a `pg_dump` restores notes whose images are all
gone. Back up both:
@@ -92,7 +104,11 @@ Know these before you decide who gets an account.
- **No second factor.** A password is the whole of it.
- **No per-user storage quota.** Any account can upload attachments until the volume
is full. `max_attachment_mb` caps a single file, not a total.
- **No audit log.** Device tokens record `last_used_at`; sign-ins are not recorded.
- **No audit TABLE.** Credential events — sign-ins, failures, throttle trips, new
accounts, device tokens issued — are written to the application log and readable
with `docker compose logs app`, which is enough to see whether anyone is knocking.
They are not queryable, not retained beyond the container's log rotation, and not
attributable after the fact.
- **No invites.** Adding a second person means re-opening registration while they
sign up, then closing it again. There is no per-person token, no expiry, and no
record of who invited whom.