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