Ferd is a self-hosted single-instance app. The threat model assumes the admin controls the box and the network gate (nginx + TLS, or a VPN). This page documents what the app does to harden itself within that assumption, and where the admin still has to think.
Before the first user registers, registration is open. If the site is reachable from the public internet during the window between deploy and first registration, a stranger can race you to claim the admin account. Close the window via VPN-only access during setup, a pre-seeded admin, or a setup token; see python.md > First-run hardening (or docker.md > Configuration for the container path).
- Username/password, hashed with PBKDF2-SHA256 at 600k iterations (OWASP 2024+ guidance for PBKDF2-SHA256), 16-byte salt, 32-byte derived key.
- Usernames are case-insensitive via
COLLATE NOCASE. Valid characters: alphanumerics, hyphens, underscores. Max 64 chars. - Passwords must be 12 to 256 characters. The upper bound prevents PBKDF2 DoS via huge inputs.
- The first registered user is marked admin. Admin-only endpoints today: re-opening registration, editing
site-config.jsoncategory labels. Every other account has the same powers within its ownusers/<username>/folder; the admin cannot read or modify other users' data. - Registration auto-closes after the first user. The admin can re-open it from Settings to invite someone.
- Cookie-based.
HttpOnly,SameSite=Lax,Securewhensecure_cookiesis true. - 30-day expiry. Stored SHA-256-hashed in SQLite (a database read can't be replayed as a live cookie) and surviving a process restart (important for socket-activated deploys).
- Listed under Settings > Security, Active sessions. Each entry shows the device, IP, and last seen time. The current device is marked. Other sessions can be revoked.
- Changing your password invalidates every session except the one initiating the change.
- Bearer tokens for non-browser clients (scripts, integrations), sent as
Authorization: Bearer <token>. - Minted under Settings > Security with a name, a scope (
fullorreadonly), and an optional expiry (default 1 year; can be set to never). - Stored SHA-256-hashed; the plaintext is shown once at creation and never again. Lose it and you mint a replacement.
- A read-only token may only make
GETrequests; any mutating request returns403. A full token acts as its owner, including admin rights if the owner is an admin. - Listed with last-used time and revocable individually under Settings > Security.
- Per-IP rate limit: 10 failed attempts per 15 minutes triggers
429. Counter resets on a successful login. - Username enumeration via response time is blocked: when the username doesn't exist, the API runs a dummy PBKDF2 round to equalize timing.
- The nginx example also rate-limits
/api/loginat the proxy layer (defense in depth).
- All writes require an authenticated session. Writes land in the calling user's
users/<username>/folder; nothing in the API lets one user write to another user's data. Read endpoints follow the same scope, plus an explicit public read path (/api/u/<username>/...) that returns 404 unless that user'spublishedflag is on. - Place writes go through schema validation: required/optional field names checked, lat in
[-90, 90], lon in[-180, 180], string length caps. Unknown fields rejected with 400. sourcesentries are restricted tohttp://orhttps://URLs. Other schemes (javascript:,data:,mailto:, ...) are rejected at the API. The frontend re-checks the protocol when rendering source links and falls back to inert text if it isn't http(s), so legacy data from before this check can't be turned into a clickable script URL.- Writes are atomic: tmp file in the target directory, fsync,
os.replace, fsync directory. Symlinks are resolved so writes land on the real file and the link stays intact. - A file lock (
fcntl.flock) serializes concurrent writes toplaces.jsonand thegpx/tree within each user's folder. - GPX uploads are XML-parsed before saving; non-GPX content is rejected. PII is stripped server-side:
<time>and<author>elements removed,creator=attribute on<gpx>dropped. Never trusts client-side stripping. - GPX region and filename are validated against a strict character set, normalized, and confirmed to resolve inside the user's
gpx/root. The public read path applies the same validation to the username and path components before resolving.
The integrated dev mode serves files from static_dir. The handler refuses any path containing .., any URL-decoded NUL byte, and any path whose first segment is tools/, deploy/, or .git/. Paths under /u/<username>/ are rewritten to index.html so the SPA can pick up the per-user public view; the actual per-user content is reachable only through the API. Symlinks within static_dir are allowed and intentionally not resolved, so symlinks pointing into your data store work.
In production, nginx serves the static content directly. The example config in deploy/nginx.example.conf has matching deny rules for the sensitive paths.
tools/app.db holds password hashes plus SHA-256 hashes of active session and API tokens (no token plaintext). Created 0600. WAL and SHM siblings created at the same permissions.
Backup includes this file. The whole users + sessions state lives in three files: tools/app.db, tools/app.db-shm, tools/app.db-wal. Use any backup tool that handles SQLite (or stop the service before snapshotting).
- A malicious admin. The model is "single-user, trusted admin".
- A compromise of the box. Anything on the host can read the DB and the data files.
- A network attacker between you and the site if you skip TLS. Always front it with HTTPS in production.
- Cross-site request forgery on browsers older than 2020 that ignore
SameSite=Lax. The modern major browsers respect it; we don't carry a CSRF token. Bearer-token auth is unaffected: browsers never attach theAuthorizationheader automatically, so token requests aren't forgeable cross-site.
See python.md > Backups or docker.md > Backups for recipes. The two paths that matter are users/ (everyone's data) and tools/app.db* (auth state plus site-wide settings).