Settings was every registry group, then invites, accounts, groups and activity, in one scroll. It is now six tabs held in ?tab=: - General: General, Notes, Attachments, Links. - People: Access, Invites, Accounts, Groups. - Email, with a line saying whether email is on and what it still needs. - Security. - Activity. A registry group that no tab names lands on General, so it can't go missing. Save saves only the current tab. Edits waiting on other tabs keep their values and mark their tab with a dot. Leaving the page with unsaved edits asks first. Review fixes: - On a phone, a field stacks under its label instead of overflowing beside it. - Descriptions are easier to read. - The SMTP encryption choice shows readable names (choice_labels). - The test-email button uses .btn-sm and is disabled while email is off. - The accounts list is headed Accounts, not People, now that People is a tab. - The docs point at the new paths. 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 →
|
|
People → Allow new registrations** before exposing an instance you have been running
|
|
on a LAN.
|
|
|
|
To let someone else in, send them an invite: **Settings → People → 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 →
|
|
General → 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.
|