Files
inkwell/docs/public-hosting.md
T
bvandeusenandClaude Opus 5.5 2e2d8667dd
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 11s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 15s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / integration (push) Successful in 1m16s
CI & Build / Build & push image (push) Successful in 1m15s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m49s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m26s
Desktop (Tauri) / Update manifest (push) Successful in 4s
password reset by email: Settings → Email, Forgot password?, and a test-email button
The operator asked for self-service reset over SMTP. It reuses #5173's
password_resets table, /reset-password page, one-hour single-use token and
sign-out-everywhere.

- Settings (rule 25, not env): a new Email group (SMTP server, port,
  encryption as a choice, username, password, from), General → Public
  address, and Security → Reset emails per account. The registry gains
  `choices`, `secret` (the value is never sent back, `is_set` says one is
  saved, an empty save keeps it) and `url` (http(s), trailing slash
  stripped).
- mailer.py: stdlib smtplib on a worker thread, 20 s timeout,
  starttls | tls | none. mail_settings() is None until a server, a sender
  and the public address are set. Links are built from the public address
  because the Host header can be forged.
- POST /api/auth/forgot-password: the same answer at the same speed for
  any address. The link is made and mailed off the request (send_later).
  It is throttled like a sign-in per visitor address, and capped per typed
  email by reset_emails_per_account; past the cap it answers the same and
  sends nothing.
- POST /api/settings/test-email: mails the admin with the saved settings
  and shows the server's error if it fails.
- Public config `password_reset_by_email`. Sign-in shows "Forgot
  password?" only then, linking to a new /forgot-password page.
- docs/public-hosting.md: an "Email and forgotten passwords" section.

Tests: the secret stays server-side; emailed link → reset; the same
answer for unknown addresses; the cap; test email success and failure;
validation units. #5266.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:15:43 -04:00

9.0 KiB

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 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, 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.

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.

What it does not do

Know these before you decide who gets an account.

  • No email verification. email_verified exists on the user row and nothing sets it. Email is used only for password resets (above).
  • 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.
  • 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.

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 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.