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.
131 lines
6.9 KiB
Markdown
131 lines
6.9 KiB
Markdown
# Putting ThoughtSync on the public internet
|
|
|
|
ThoughtSync is built to run on a LAN and works fine there with no ceremony. Exposing
|
|
it changes the threat model: anyone can now reach the login form, and any account is
|
|
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 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
|
|
it, so there is no window between "my account exists" and "I remembered to turn it
|
|
off". A brand-new instance is never locked out of itself, and never left open either.
|
|
|
|
**Instances that predate this still need one manual flip.** The close fires when the
|
|
first account is created, so a server whose admin already existed keeps whatever
|
|
`allow_registration` was set to — which was **on** by default. Check **Settings →
|
|
Access → Allow new registrations** before exposing an instance you have been running
|
|
on a LAN.
|
|
|
|
To let someone else in, turn it back on, have them register, turn it off. There is no
|
|
invite system yet, so that is the mechanism.
|
|
|
|
**2. Terminate TLS in front of it, and forward the scheme.** The app marks the
|
|
session cookie `Secure` and sends HSTS only when it can tell the request arrived over
|
|
HTTPS. It looks at `X-Forwarded-Proto`, so the proxy has to set it:
|
|
|
|
```
|
|
# Traefik does this automatically. For nginx:
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
```
|
|
|
|
Without that header the app assumes plain HTTP and leaves the cookie unmarked — the
|
|
conservative choice, since forcing `Secure` on an HTTP install stops the browser from
|
|
ever sending the cookie back and silently breaks login.
|
|
|
|
Once a browser has seen HSTS from your hostname it will refuse plain HTTP there for a
|
|
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. 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`:
|
|
|
|
```
|
|
THOUGHTSYNC_BIND=127.0.0.1
|
|
```
|
|
|
|
**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:
|
|
|
|
```
|
|
docker compose exec -T db pg_dump -U thoughtsync thoughtsync > notes.sql
|
|
docker run --rm -v thoughtsync-data:/d -v "$PWD":/out alpine tar czf /out/media.tgz -C /d .
|
|
```
|
|
|
|
## What the app already does
|
|
|
|
- **The credential endpoints are throttled.** `/api/auth/login`, `/api/auth/register`
|
|
and `/api/auth/device-login` count attempts against both the account and the calling
|
|
address, and answer `429` with a `Retry-After` once either is over budget — ten
|
|
failed sign-ins per account per fifteen minutes, five registrations per address per
|
|
hour. The account-keyed limit is the one that holds when the address is forged.
|
|
Checked *before* the password is verified, so a throttled attempt costs no bcrypt:
|
|
hashing is deliberately slow, and an unauthenticated caller who can trigger it
|
|
without limit has a CPU-exhaustion primitive as well as a guessing one.
|
|
- **A failed sign-in takes the same time whether or not the account exists.** No
|
|
timing oracle for which emails are registered here.
|
|
- **Every response carries a CSP** with `script-src 'self'`, `object-src 'none'` and
|
|
`frame-ancestors 'none'`, plus `nosniff`, a referrer policy and a permissions
|
|
policy. The app has no inline or third-party scripts, so this costs nothing.
|
|
- **Link unfurling is SSRF-hardened.** Every hop is resolved and every resolved
|
|
address must be publicly routable before a socket is opened, and the connection is
|
|
made to the vetted IP so a rebind between check and connect cannot slip through. A
|
|
note containing `http://192.168.1.1/` cannot make your server probe your network.
|
|
- **Attachments never render inline unless they are a known raster image.** Anything
|
|
else — an SVG, an HTML file — is served `Content-Disposition: attachment`, so a file
|
|
on a note shared with you can't run script in your session.
|
|
- **Session cookies are `HttpOnly` and `SameSite=Lax`**, which is also what stands in
|
|
for CSRF protection: a `Lax` cookie is not sent on a cross-site POST.
|
|
|
|
## What it does not do
|
|
|
|
Know these before you decide who gets an account.
|
|
|
|
- **No email verification and no password reset.** `email_verified` exists on the user
|
|
row and nothing sets it. A forgotten password needs a hand on the database.
|
|
- **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 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.
|
|
|
|
None of these are hard blockers for an instance whose accounts are you and people you
|
|
know. They are the reason not to hand out open registration to strangers.
|
|
|
|
## The Android client
|
|
|
|
The app allows plain HTTP so a self-hosted server on a LAN is usable at all — Android
|
|
blocks cleartext by default from API 28, and `http://192.168.1.10:8000` is exactly the
|
|
case ThoughtSync is built for. Over the public internet, link the phone to the
|
|
**HTTPS** hostname. The sync screen shows a warning before any credential field
|
|
whenever the address it probed was `http://`; on a public network that warning means
|
|
what it says.
|
|
|
|
The APK the server hands out is signed with the project release key, and the in-app
|
|
updater installs over the existing app only because the signature matches. A build
|
|
from anywhere else will not install over it.
|