Files
thoughtsync/docs/public-hosting.md
T
bvandeusen 2141a0ac45
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
Registration closes itself once the instance has an owner
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.
2026-08-23 14:18:58 -04:00

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.