Files
thoughtsync/docs/public-hosting.md
T
bvandeusen b6152ec18b
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 9s
CI & Build / Python tests (push) Failing after 11s
CI & Build / Build & push image (push) Successful in 33s
server: harden the surfaces a public deployment leaves exposed
On a LAN the login form is reachable by people you already trust. Exposed, it is
reachable by everyone, and nothing in front of it was counting.

Three credential routes — /login, /register and /device-login — now throttle.
Every attempt is counted against BOTH the account and the calling address, and
either can refuse it. The account key is the one that matters and the one that
cannot be forged: it stops stuffing against a known email no matter how many
addresses the attempts arrive from. The address key bounds one source spraying
many accounts, and is best-effort by nature — behind a proxy it comes from
X-Forwarded-For, which a caller can set to anything if the app is exposed
directly. That is exactly why it isn't the only key.

The check runs BEFORE the password is verified, which is the other half of what
this protects. bcrypt is deliberately slow; an unauthenticated caller who can
trigger it without limit has a CPU exhaustion primitive as well as a guessing
one. Sliding rather than fixed windows, because a fixed one lets twice the limit
through across a boundary. Bucket count is capped so a rotating forged header
can't turn the limiter into the exhaustion it prevents.

A sign-in against an email with no account now spends a real bcrypt against a
throwaway hash first. Without it "no such account" returned in microseconds
while a wrong password took ~100ms, which is a reliable 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 and no third-party scripts, so this concedes
nothing; the exceptions are honest — inline STYLE (Vue writes it itself for
v-show and the FLIP), and remote images (a link preview renders the og:image of
an arbitrary host, over either scheme, since a LAN install is served over http).
HSTS only where the request already arrived over TLS, and scoped to the one
host: no includeSubDomains, no preload, neither of which is this app's to
commit.

X-Forwarded-Proto detection moved into one `_is_https()` — the session cookie's
Secure flag and HSTS are the same question, and answering it twice is how the
two drift apart.

docs/public-hosting.md is the rest of it: the four things only the operator can
do (close registration, terminate TLS and forward the scheme, stop publishing
the app port, back up the attachment volume as well as the database), and an
honest list of what the app does NOT have — no email verification, no password
reset, no second factor, no per-user quota, no audit log. Those aren't blockers
for an instance whose accounts are people you know. They're the reason not to
leave signups open to strangers.
2026-08-21 22:01:37 -04:00

5.3 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 four things first

1. Close registration. allow_registration defaults to on, because the first run of a fresh instance has to be able to create the admin account. It stays on afterwards. Once your own account exists, turn it off in Settings → Access → Allow new registrations, or the first stranger to find the hostname can open an account on your server.

The first account created is always the admin, regardless of this setting — so a brand-new instance is never locked out of itself.

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.

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.