Skip to content

Security: Kerrator/lotusfield

Security

docs/security.md

lotusfield — Security

Threat model, non-negotiables, and trust commitments. The authoritative specs are prd-v1.md (v1) and prd-v2.md §Security (v2 delta). Security is treated as non-negotiable because lotusfield is meant to be stood up by other households too — and some of those operators will expose it.

Posture in one line

lotusfield must be safe even when nakedly exposed to the public internet. The recommended deployment puts a network-layer VPN gate in front of the app, but we never assume it: every app-layer control below holds whether the server is on a private tailnet or sitting on a public port.

Threat model

v1 assets and adversaries

Assets: the user's media library, account credentials, session cookies, watch history, and the TMDB API key.

Adversaries we design against (v1):

Adversary Concern Primary defense
Internet background noise / scanners Discovery, default-cred login, brute force No defaults, network gate, rate-limiting
Targeted brute-forcer Guessing a real user's password Argon2id, login rate-limit/lockout
Passive network observer Sniffing credentials/cookies/streams HTTPS-capable; encrypted tailnet transport
Database thief (stolen SQLite file) Recovering passwords from a leak Argon2id hashing — no plaintext, no fast hashes
Curious third party (cloud/vendor) Tracking usage, phoning home Zero telemetry, no built-in relay

Explicitly out of scope for v1 (documented boundaries, not omissions): serving external/non-household users from one instance, friend-sharing/invites, and per-user library permissions. Single household, multi-user, multi-device.

v2 threat-model delta

v2 deliberately crosses a v1 boundary: external people can now receive share links and reach a limited surface of the server over the public internet. This changes the threat model.

New assets introduced in v2:

  • Share tokens — high-entropy secrets (secrets.token_urlsafe(32), ≥128-bit) that live only in the share URL. The database stores only the SHA-256 hash; a database leak cannot reconstruct a working link.
  • Share passcodes — optional second factor on a share link. Stored as Argon2id hashes (same hasher as user passwords). The plaintext is never stored or logged.
  • Passcode access markers — short-lived, HMAC-signed HttpOnly cookies scoped to /api/share/{token}. Issued on successful passcode verification; expires in 1 hour or at the share's expires_at, whichever is sooner.
  • Runtime TMDB key — the in-app-configurable TMDB key (v2 settings route). Never echoed in API responses, logs, or the OpenAPI schema.

New adversaries introduced in v2:

Adversary Concern Primary defense
Untrusted external share recipient Pivoting from a shared item into the library, admin, filesystem, other items, or another share's transcode session Least-privilege /api/share/* surface; item-scoping + path confinement; transcode-session binding (share must own the session)
Link-leak recipient (someone forwarded the URL) Accessing a share the operator did not intend to share with them Expiring + revocable tokens; optional passcode; Referrer-Policy: no-referrer so the token is not emitted as a Referer header
Passcode brute-forcer Guessing a share passcode Rate-limiting keyed per-token and per-IP; constant-work failure (dummy Argon2id verify on all failure branches)
Token enumerator Guessing valid share tokens ≥128-bit token entropy makes enumeration infeasible; rate-limiting on the resolution endpoint
Attacker who compromises the operator VPS A foothold on the VPS pivots over the mesh into the home box and home LAN Mesh ACL scopes the VPS to the single lotusfield port only (default-deny to everything else); VPS hardening checklist in docs/deployment-vps-mesh.md
Attacker who compromises the mesh Man-in-the-middle between VPS and home box Tailscale/WireGuard encrypts mesh transport end-to-end; the same app-layer auth applies regardless of transport

v1 security non-negotiables

These are requirements, true regardless of transport:

  1. Forced admin password on first run — no default or empty credentials, ever. The server is never reachable with a known-out-of-the-box login.
  2. Argon2id password hashing (argon2-cffi). A stolen database does not expose credentials to fast offline cracking.
  3. Session-based auth with secure, httpOnly cookies and sensible expiry. Cookies are not readable by JavaScript and (when HTTPS) not sent in the clear.
  4. Login rate-limiting / lockout to blunt brute force.
  5. HTTPS-capable so traffic can be encrypted on any public deployment.
  6. Zero telemetry / no phone-home (see trust commitment below).
  7. Watch state is per user — progress and history don't leak across household accounts.

v2 security non-negotiables (in addition to all seven above)

These apply wherever v2 sharing, session revocation, or the VPS topology is involved:

  1. Shares are expiring and revocable; tokens are high-entropy and stored only as a hash. Secret tokens are secrets.token_urlsafe(32) (≥128 bits of entropy, URL-safe). The DB stores only the SHA-256 hash — a database leak cannot reconstruct a working link. Every share has a required expires_at; revocation (revoked_at) takes effect immediately. Any invalid or nonexistent share resolves to one indistinguishable response.

  2. A share grants stream + subtitles for granted items only. The /api/share/{token}/* surface is separate and minimal. A share token cannot reach: list/scan/user management/settings/admin; an un-granted item; an arbitrary filesystem path; or another share's transcode session. The path- confinement helper is shared code — never a second copy that can drift.

  3. Passcode enforced on every media endpoint for passcode-protected shares. The check lives inside get_share_context so it cannot be bypassed by hitting /stream, /transcode, or /subtitles directly. Missing/expired/forged/wrong-share marker → the standard 404-style rejection. No grace period, no bypass.

  4. Constant-work / indistinguishable failure across all rejection branches. Share resolution and the passcode route return identical observable responses (status, body, timing) for nonexistent, expired, revoked, passcode-missing, and wrong-passcode branches. A dummy Argon2id verify runs on branches that would otherwise skip hashing, mirroring the v1 _DUMMY_PASSWORD_HASH login path.

  5. Server-side session revocation. User.session_epoch is embedded in the signed session cookie and checked in get_current_user. A password change or "sign out everywhere" bumps the epoch, immediately invalidating all cookies for that user. A v1-format (epoch-less) cookie is rejected outright — never defaulted to epoch 0. This is the load-bearing control for a server that is now externally reachable.

  6. The home machine takes no inbound ports in the blessed topology. The mesh ACL scopes the VPS to the single lotusfield port on the home box (default-deny to the rest of the LAN/tailnet). See docs/deployment-vps-mesh.md for the mandatory hardening checklist.

  7. Zero telemetry / no phone-home still holds. The only outbound calls remain to the user's own TMDB key. The runtime TMDB key is never echoed in API responses, logs, or the OpenAPI schema.

Defense in depth: network gate in front of app auth

The two layers are independent and complementary; either alone is a defense, both together is the blessed posture.

flowchart LR
    Internet([Internet]) --> L1
    subgraph L1["Layer 1 — network (recommended)"]
        VPN["Tailscale / WireGuard mesh<br/>device must be on the tailnet"]
    end
    L1 --> L2
    subgraph L2["Layer 2 — application (always on)"]
        Auth["Forced admin pw · Argon2id ·<br/>session cookies · login rate-limit"]
    end
    L2 --> App[(lotusfield)]

    Naked([Public reverse proxy]) -. "skips Layer 1" .-> L2
Loading
  • Layer 1 (network) — with Tailscale/WireGuard, the login page is not on the public internet at all. An attacker must first be on the tailnet. This is the cheapest, strongest control and it kills remote-access friction at the same time. Default-deny by construction.
  • Layer 2 (application) — the auth stack above. It is always on, so the reverse-proxy operator who deliberately skips Layer 1 is still protected by it.

The key principle: Layer 1 is a bonus, never a crutch. We never weaken Layer 2 on the assumption that a VPN is present.

Security properties by deployment profile

Property Tailscale only (v1 default) VPS + mesh (v2 blessed) Reverse proxy (advanced)
Login page reachable from public internet No No Yes
Share links reachable externally No Yes Yes
Open inbound ports on home host None None 80/443
TLS certificates to manage None (Tailscale Serve) None (Caddy on VPS) Yes (auto via ACME)
Network-layer access gate Yes (tailnet) Yes (mesh ACL) No (only app auth)
Brute-force exposure surface Tailnet peers only VPS only (mesh-ACL-scoped) Whole internet
Third party in the path None None (operator VPS) None
Primary risk Misconfigured tailnet ACLs VPS compromise → mesh pivot Public login page, scanners, weak admin pw

All three profiles rely on the same app-layer auth. The reverse-proxy profile has the largest attack surface; see its deployment guide. The Tailscale-only profile is the v1 default blessed path. The VPS+mesh profile is the v2 blessed path for external sharing.

Accepted residuals (v2)

The following items were confirmed during the v2 security review. Each is documented honestly — acknowledged, bounded, and accepted rather than closed, because closing them would require disproportionate complexity or risk introducing regressions.

1. Media-endpoint sub-millisecond timing oracle

On a valid passcode-protected share token, the media endpoints (/stream, /transcode, /subtitles) do approximately 2× the database work before the marker check compared to a nonexistent token (one extra DB query to load the share row). This is a micro-timing difference observable only under controlled conditions.

Why it is not a practical concern: tokens are 256-bit (secrets.token_urlsafe(32)); confirming "this token exists but I lack the access marker" gains an attacker nothing — they still need the passcode. The passcode route itself and all response bodies/statuses are constant-work and indistinguishable. The oracle window is the DB round-trip time (~microseconds), not the Argon2id verify (~hundreds of milliseconds) that would be needed for passcode guessing.

Why it is not closed: closing it would require inverting the authZ resolver (check marker before loading the share, or always load the share even on a marker hit). Both approaches carry higher structural risk than the oracle itself. Rate-limiting on the resolution endpoint bounds the enumeration rate regardless.

Status: accepted, not closed.

2. In-process limits are single-worker-authoritative

The per-IP rate-limiter, the share-stream cap (LOTUSFIELD_MAX_CONCURRENT_SHARE_STREAMS), and the transcode-ownership registry are in-memory state. Under a multi-worker deployment each worker maintains an independent copy, so limits multiply by worker count (2 workers → 2× the cap, 2× the rate-limit buckets, etc.).

Additionally, a share transcode session whose client disappears without sending DELETE has its slot returned by the TTL reaper (as in v1). Until the reaper runs, the slot is held — bounded and predictable, not exploitable.

Mitigation in place: the blessed topology pins the home box to a single Uvicorn worker (--workers 1), keeping all three limits authoritative. See docs/deployment-vps-mesh.md.

Status: accepted; documented in the deployment guide; the shared-store fix is a TODO(roadmap).

3. Per-IP rate-limiting requires trusted-proxy configuration

The per-IP component of the share-resolution and passcode rate limits keys on request.client.host. Behind the VPS mesh proxy, without --proxy-headers --forwarded-allow-ips=<vps-tailscale-ip>, every external client's request.client.host is the VPS mesh IP. All external recipients collapse into one rate-limit bucket: a single bad client (or a single high-traffic use) exhausts the bucket and locks out all other external recipients (cross-client DoS via rate-limit sharing).

Mitigation in place: the blessed topology requires --proxy-headers --forwarded-allow-ips=<vps-tailscale-ip> (documented as mandatory in docs/deployment-vps-mesh.md). The --forwarded-allow-ips flag must name the specific VPS Tailscale IP only — never *.

The per-token-hash rate limit is unaffected (it does not key on IP).

Status: accepted; requires operator configuration; mandatory item in the hardening checklist.

Trust commitment: no telemetry, no phone-home

lotusfield sends no telemetry and phones home to no one. There is no usage analytics, no crash reporting to a vendor, no license check, no built-in cloud relay, and no account service. The only outbound network call the app makes is to TMDB, using the self-hoster's own free API key, solely to fetch metadata and artwork the operator asked for — and even that degrades gracefully when absent. This is a deliberate, foundational stance against the cloud-middleman and paywall-creep grievances that motivated the project, and the AGPLv3 license keeps it (and any networked derivative) free and open.

Responsible disclosure

If you find a security issue, please report it privately rather than opening a public issue. Use GitHub's private vulnerability reporting on the repository (Security → "Report a vulnerability"), or contact the maintainer through the address listed in the repository profile. Please include reproduction steps and the affected version/commit. We aim to acknowledge reports promptly and to coordinate a fix and disclosure timeline with you.

There aren't any published security advisories