The operator asked why `THOUGHTSYNC_BIND` isn't just defaulted to the safe value. Fair question, and the answer exposed that my own advice was incomplete: I told them to set it to 127.0.0.1 without asking where their proxy runs, and for a proxy inside Docker that is the wrong fix. There are three shapes, not two: 1. **LAN, no proxy** — the default. Binds every interface so a phone and a desktop can reach the server. This is why the default is NOT the locked-down value: a server reachable only from the machine it runs on isn't hardened, it's broken, and that is the primary documented use of this app. 2. **Proxy in Docker** — delete the `ports:` block entirely. The proxy reaches the app over the compose network; publishing a host port is a second, unauthenticated way in that bypasses whatever the proxy does about TLS. Safer than 127.0.0.1, because there is no host port to reach even from the host. 3. **Proxy on the host** — `THOUGHTSYNC_BIND=127.0.0.1`. The compose file now spells out all three where the decision is made, and `docs/public-hosting.md` item 4 asks where your proxy runs before telling you what to do, plus how to check: `curl http://<lan-ip>:5000/api/health` from another machine should NOT answer once you're proxied. No default changed. Changing it would silently break every LAN install on the next `docker compose pull` — the phone would just stop syncing, with nothing saying why.
143 lines
7.9 KiB
Markdown
143 lines
7.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.** **Settings → Security → Trusted
|
|
proxy hops**, which defaults to `1` — one reverse proxy terminating TLS. Behind a CDN
|
|
as well (Cloudflare in front of your proxy) set it to `2`. It applies immediately; no
|
|
restart.
|
|
|
|
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 reaching the app except through the proxy.** The default compose binds
|
|
`0.0.0.0:5000` so LAN clients can reach it directly — which is right for a LAN install
|
|
and wrong the moment there is a proxy in front, because it leaves a second way in that
|
|
bypasses everything the proxy does.
|
|
|
|
Which fix depends on where your proxy runs:
|
|
|
|
- **Proxy in Docker** (Traefik discovering the container, an nginx container): delete
|
|
the `ports:` block from `docker-compose.yml`. The proxy reaches the app over the
|
|
compose network; no published port is needed at all, and this is the safest of the
|
|
two because there is no host port to reach even from the host.
|
|
- **Proxy on the host**: set `THOUGHTSYNC_BIND=127.0.0.1` in `.env`, so the port
|
|
exists but only the host itself can use it.
|
|
|
|
To check which you have: `docker compose ps` shows the published ports, and
|
|
`curl http://<your-lan-ip>:5000/api/health` from another machine tells you whether the
|
|
app is still answering around the proxy. It should not be.
|
|
|
|
**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. The
|
|
numbers live in **Settings → Security** — ten failed sign-ins per account per
|
|
fifteen minutes and five sign-ups per address per hour by default — and a change
|
|
applies to the next attempt rather than the next deploy. 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.
|