Mosaic ships an HTTPS+WebSocket interface that exposes torrent control to anyone who can reach the listener with valid credentials. This page documents the threat model, the defenses currently in place, and the two remaining items that should be closed before any deployment crosses a LAN boundary.

Threat model

The remote interface is intended for:

  • Loopback / single-user use on the same machine
  • LAN-attached headless seedboxes accessed by a trusted user

It is not currently hardened for direct public-internet exposure. Run it behind SSH, WireGuard, Tailscale, or a reverse proxy with a real cert if you need external access.

Auth model

There are two ways to authenticate:

  1. Argon2id password sessions. POST /api/login with {username, password}. On success, a mosaic_session cookie is set: HttpOnly, SameSite=Strict, Path=/, 12-hour TTL. The cookie’s Secure flag is set conditionally — see “Transport” below. Sessions are kept in memory; the store is capped at 100 entries with the earliest-expiring entry evicted on overflow. Session revocation (RevokeAll()) fires on every password change (SetWebPassword) and on a username change (SetWebConfig, only when username actually changed), forcing every browser to re-log in.
  2. Bearer API key. Include Authorization: Bearer <key> (or ?key=... for WS upgrades that can’t set headers). The key is a 32-byte random token encoded with base64.RawURLEncoding (URL-safe, no padding), stored verbatim in the SQLite settings table and constant-time compared on each request. Rotating via POST /api/settings/web/api_key/rotate invalidates the previous key.

The AuthGate middleware passes a request if either check succeeds. The login route itself is rate-limited by client IP (5 attempts / minute, 12-second refill, idle buckets evicted after 10 minutes by a janitor).

HTTPS is conditional on bind_all. The server speaks HTTPS only when bound to all interfaces (useTLS = cfg.BindAll). When bound to loopback only — the default the first time you enable the web interface — the server speaks plain HTTP, and the session cookie’s Secure flag is off. The cookie still works in this mode because Secure only changes cross-origin behavior in browsers; same-origin loopback flows attach the cookie regardless.

When bind_all is flipped on (LAN or internet exposure), TLS is enabled and the session cookie carries Secure. The first time bind_all flips on, a self-signed ECDSA P-256 cert is generated under <DataDir>/web-tls/ (cert.pem + key.pem); subsequent restarts reuse the same files. The cert is not generated in loopback-only mode — there is no cert at all in that mode.

CSRF defense

Cookie-authed state-changing requests (POST/PUT/DELETE/PATCH) pass through an OriginGuard middleware that:

  1. Skips bearer-keyed requests entirely. Browsers don’t auto-attach Authorization headers or ?key= params to cross-origin requests, so bearer-keyed calls aren’t CSRF-vulnerable in the first place.
  2. For all other state-changing requests:
    • If both Origin and Referer are absent, allow the request. Combined with SameSite=Strict on the session cookie, a real browser CSRF would not have the cookie attached anyway, and the absence of Origin is what most non-browser clients (curl, scripts) send.
    • If Origin is set, its host must match the request’s Host. Reject 403 otherwise.
    • If only Referer is set, its host must match Host. Reject 403 otherwise.

WebSocket upgrades go through a separate OriginPatterns: []string{r.Host} constraint via nhooyr.io/websocket. This pins the cross-site WebSocket hijacking surface to only requests originating from the same host the connection is being made to — a logged-in user visiting a malicious page can’t have their browser open an authenticated WS to Mosaic.

What v0.1.22 hardened

The v0.1.22 audit pass closed eight items, identified by the audit’s own severity codes:

  1. C1 — CSWSH (Cross-Site WebSocket Hijacking). OriginPatterns: []string{r.Host} on the WS upgrade pins the cross-site surface to same-host only.
  2. C2 — CSRF. OriginGuard middleware on cookie-authed state-changing routes (POST/PUT/DELETE/PATCH); skips bearer-keyed callers.
  3. C3 — Login rate limit. Per-IP token bucket on /api/login (5/min, burst 5, 12-second refill).
  4. M4 — Session revocation on credential change. SetWebPassword always revokes; SetWebConfig revokes when the username changes (and only when it changes).
  5. L1 — Argon2id verify reads PHC params. Verify path parses the encoded params from the stored hash rather than trusting compile-time defaults — lets us migrate parameters without invalidating older hashes.
  6. L2 — RandomToken propagates crypto/rand errors. Removes a silently-zero-token failure mode.
  7. H1 — safeRemovePath path-traversal validation. Refuses to delete outside the configured save root, defending the engine’s “delete on remove” path against crafted relative paths.
  8. M1 — safefetch SSRF defense. validateFetchURL rejects non-http(s) schemes and refuses to dial private/loopback addresses; the dialer in safeHTTPClient is the second layer that catches DNS-rebind tricks. Used by both blocklist refresh and RSS feed fetches.

Remaining audit follow-ups

The full open set, in priority order:

  • H2 — cert SAN coverage for LAN binds. When bind_all is on, the self-signed cert generated under <DataDir>/web-tls/ covers localhost, 127.0.0.1, and ::1 only — not the actual LAN IP or hostname the listener is reachable on. Browsers will refuse the cert without a per-host exception. Fix: extend EnsureSelfSignedCert to accept an explicit SAN list and pass the advertised Host through. The cert isn’t generated or used at all in loopback-only mode, so this surfaces only when bind_all is on.
  • H3 — signed SHA256SUMS. The auto-updater verifies SHA-256 against the release’s SHA256SUMS file, which is itself unsigned. Anyone who can replace assets on a release (a compromised CI token, a stolen GitHub PAT) can also rewrite the manifest — making it a single-shot mass-compromise vector if maintainer credentials leak. Fix: publish a signed copy of the manifest alongside it (sigstore, minisign, or a long-lived release-signing key) and have the updater verify the signature before trusting the digests.
  • Per-IP session cap. The session store cap is currently a global 100 with earliest-expiry eviction. An attacker who has valid credentials (e.g. a former employee, a leaked password) can flood the store from one IP and evict legitimate users’ sessions. Fix: track sessions per source IP (or per username) and apply the cap at that scope.
  • Argon2id parameter upper bounds. Verify rejects p > 255 but doesn’t bound m (memory) or t (time) — a hostile stored hash could in theory force the verifier into pathological CPU/memory use. Fix: clamp m and t to plausible upper limits before invoking Argon2.
  • No XFF spoof regression test. The login rate limiter keys on the source IP derived from the request; we have no test that asserts the X-Forwarded-For header is not trusted in deployments where the listener faces clients directly. Fix: add a regression test that verifies the rate limiter uses RemoteAddr, not XFF, in the default config.
  • prevUser read error swallowed in SetWebConfig. When determining whether the username changed (to decide whether to revoke sessions), the read error from the old prevUser lookup is currently dropped. In the worst case, a flake on settings read could mask a username change and skip revocation. Fix: surface the read error and treat unknown-prev as “username changed” (revoke conservatively).

The recommendation is unchanged: do not expose Mosaic outside a trusted network until at least H2 + H3 ship, and do not run an instance on a machine where an attacker could replace your release signing/CI credentials.

Recommendations

  • Loopback by default; LAN by exception. Treat “Bind to all” as a deliberate choice, not a setting you flip and forget.
  • Use the API key, not the password, for scripts. Bearer-keyed callers skip the CSRF guard cleanly; cookie-authed scripts have to forge an Origin header and are easier to misconfigure.
  • Rotate the API key when devices/scripts are decommissioned.
  • Don’t disable auto-update. The updater is the channel by which audit follow-ups reach you.
  • Keep an eye on your config directory. Anyone with read access to mosaic.db has the API key (it’s stored verbatim) and the password hash. The directory is created 0o700 on Unix; on Windows it inherits the user profile ACL.

Reporting

Found something that looks like a vulnerability? Open a private security advisory at https://github.com/exec/mosaic/security/advisories/new rather than a public issue. The audit history (the v0.1.22 fixes above, the v0.1.23 macOS auto-update fix) gives you a sense of how disclosure is handled.