CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 1m27s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m47s
CI & Build / Build & push image (push) Successful in 45s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m4s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Sign-ins and failed sign-ins, accounts created and sign-ups refused, password changes, resets and reset links, devices linked and unlinked, invites made and revoked. Each is kept in `audit_events` with the address it came from, for `audit_retention_days` (Settings → Security, 90 by default, 0 keeps them forever), and listed newest first for admins under Settings → Activity. The retention loop deletes older events. `audit.record` writes in its own session, so a refusal is kept even when the request's transaction rolls back. A failure to record is logged and swallowed, never the reason a sign-in fails. A throttled attempt (429) is not recorded: a row per refused request would make each request in a flood cost a database write. Throttle trips stay in the app log. Also: the storage-limit test puts `storage_quota_gb` back afterwards, since settings outlive the per-test truncate. #2939 §5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
204 lines
11 KiB
Markdown
204 lines
11 KiB
Markdown
# Putting Inkwell on the public internet
|
|
|
|
Inkwell 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 three 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.
|
|
|
|
**The first account has to be made within 30 minutes of the server starting.** After
|
|
that, `/register` refuses the first account. Otherwise a fresh server on a public
|
|
address would belong to whoever found it first. If you miss the window, restart the
|
|
container and register straight away. Once an account exists, the window no longer
|
|
matters.
|
|
|
|
**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, send them an invite: **Settings → Invites** makes a link that
|
|
works once, expires (a week by default), and can be pinned to one email address. It
|
|
lets that one person register while registration stays closed.
|
|
|
|
**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 `INKWELL_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
|
|
`inkwell-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 inkwell inkwell > notes.sql
|
|
docker run --rm -v inkwell-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.
|
|
- **Sessions can be ended from the Account page.** Changing your password there needs
|
|
the current one. It signs you out of every other browser and unlinks every app,
|
|
and you stay signed in where you made the change. **Sign out everywhere else**
|
|
does the same without changing the password. A browser session doesn't appear in
|
|
a list the way a linked app does: it is a signed cookie in that browser, ended by
|
|
moving the account on rather than by deleting a row.
|
|
|
|
## Email and forgotten passwords
|
|
|
|
A forgotten password can be reset two ways. Either way the link works once, within
|
|
an hour, and using it signs the account out everywhere and unlinks its apps.
|
|
|
|
- **By an admin, always.** Settings → People → Reset password makes a link, and the
|
|
admin hands it over.
|
|
- **By the person, once email is set up.** Fill in Settings → Email (SMTP server,
|
|
port, encryption, sign-in and a from address) and Settings → General → Public
|
|
address, the URL people reach this server at. Save, then press **Send test email**,
|
|
which mails you with exactly the settings a reset will use. From then on the sign-in
|
|
screen offers **Forgot password?**
|
|
|
|
The public address is required because the emailed link has to point somewhere the
|
|
server can trust. Built from the request instead, a forged `Host` header would mail
|
|
someone a reset link to a site the forger controls.
|
|
|
|
The SMTP password is kept in the database and never sent back to the browser; the
|
|
field shows only that one is saved. The forgot-password page answers the same way,
|
|
at the same speed, whether or not an address has an account, and
|
|
`reset_emails_per_account` (Settings → Security) caps how many reset emails one
|
|
address can be sent.
|
|
|
|
## Storage per account
|
|
|
|
Each account may store up to `storage_quota_gb` of attachments (Settings →
|
|
Attachments, 5 GB by default; 0 means no limit). Admins have no limit. Trashed notes
|
|
count until the trash is emptied, because their files are still on disk. An upload
|
|
past the limit is refused with 507 Insufficient Storage, and a phone or desktop
|
|
retries that file on each sync, so it goes through once there is room. The Account
|
|
page shows how much an account uses.
|
|
|
|
`max_attachment_mb` still caps any single file.
|
|
|
|
## Activity
|
|
|
|
**Settings → Activity** lists what has happened to accounts, newest first, with the
|
|
address each came from:
|
|
|
|
- sign-ins and failed sign-ins;
|
|
- accounts created and sign-ups refused;
|
|
- password changes, resets and reset links;
|
|
- devices linked and unlinked;
|
|
- invites made and revoked.
|
|
|
|
Events are kept for `audit_retention_days` (Settings → Security, 90 by default; 0
|
|
keeps them forever). Admins only.
|
|
|
|
A throttled attempt (429) is not listed, so that a flood of them costs no database
|
|
writes. Throttle trips are in the app log, with every event above:
|
|
`docker compose logs app`.
|
|
|
|
## What it does not do
|
|
|
|
Know these before you decide who gets an account.
|
|
|
|
- **No email verification.** An account's address is whatever was typed when it
|
|
registered, and email is used only for password resets (above), which go to that
|
|
address. Nothing changes an address afterwards, so someone who registered with a mistyped
|
|
one can't reset by email; an admin can issue them a reset link instead.
|
|
- **An admin who forgets their own password**, with email off and no other admin,
|
|
still needs a hand on the database.
|
|
- **No second factor.** A password is the whole of it.
|
|
|
|
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 Inkwell is built for. Over the public internet, link the phone to the
|
|
**HTTPS** hostname. The phone and desktop apps refuse a plain `http://` address that
|
|
isn't on a private network, before any password or token is sent. Allowed: private
|
|
and Tailscale IPs, single-label names like `nas`, and LAN names such as `.local` or
|
|
`.home.arpa`. A device already linked to a public `http://` address stops syncing
|
|
and says why.
|
|
|
|
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.
|