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 11s
CI & Build / integration (push) Successful in 17s
CI & Build / Build & push image (push) Successful in 28s
Operator: *"registration should be open only for the first user and they get granted admin privileges. then registration is closed."* The old shape had a window in it. The first account was always allowed and became admin; every account after that was gated by `allow_registration` — which defaulted to ON. So the door stayed open between "my account exists" and "I remembered to turn it off in Settings", and on a public host that gap is the entire exposure: it starts the moment DNS resolves and lasts until someone remembers. Now the door shuts as a CONSEQUENCE of the admin account existing, in the same transaction that creates it. Not "defaults closed" — that would still need the first person to get in somehow. There is no window to remember, because there is no window. Re-opening it is a deliberate act in Settings → Access: turn it on, have the person register, turn it off. Crude, and it is the only mechanism there is — **there is no invite system**, not even a stub. That is real work (a token table, admin create/revoke, a redemption flow, expiry) and is filed as later work rather than smuggled into a release. An integration test covers it, because it is the interaction between two writes in one transaction: first register → 201 and `is_admin: true`; the setting is then false; a second register → 403; re-open deliberately and a third → 201, not admin. **This does not retroactively close an instance that already has users.** The close fires on first-account creation, so a server whose admin predates this keeps whatever the setting was — which was on. `docs/public-hosting.md` now says so explicitly, and step 1 of the checklist is "check" rather than "do" for exactly that reason.
115 lines
5.9 KiB
Markdown
115 lines
5.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 four 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. 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
|
|
```
|
|
|
|
**4. 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 log.** Device tokens record `last_used_at`; sign-ins are not recorded.
|
|
- **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.
|