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.
6.9 KiB
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/registerand/api/auth/device-logincount attempts against both the account and the calling address, and answer429with aRetry-Afteronce 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'andframe-ancestors 'none', plusnosniff, 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
HttpOnlyandSameSite=Lax, which is also what stands in for CSRF protection: aLaxcookie 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_verifiedexists 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_mbcaps 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.