Security
Auth model, CSRF defenses, what v0.1.22 hardened, and what's still TODO before non-LAN exposure.
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:
- Argon2id password sessions.
POST /api/loginwith{username, password}. On success, amosaic_sessioncookie is set: HttpOnly, SameSite=Strict, Path=/, 12-hour TTL. The cookie’sSecureflag 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 whenusernameactually changed), forcing every browser to re-log in. - 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 withbase64.RawURLEncoding(URL-safe, no padding), stored verbatim in the SQLite settings table and constant-time compared on each request. Rotating viaPOST /api/settings/web/api_key/rotateinvalidates 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).
Transport: HTTPS and the Secure cookie flag
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:
- Skips bearer-keyed requests entirely. Browsers don’t auto-attach
Authorizationheaders or?key=params to cross-origin requests, so bearer-keyed calls aren’t CSRF-vulnerable in the first place. - For all other state-changing requests:
- If both
OriginandRefererare absent, allow the request. Combined withSameSite=Stricton the session cookie, a real browser CSRF would not have the cookie attached anyway, and the absence ofOriginis what most non-browser clients (curl, scripts) send. - If
Originis set, its host must match the request’sHost. Reject 403 otherwise. - If only
Refereris set, its host must matchHost. Reject 403 otherwise.
- If both
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:
- C1 — CSWSH (Cross-Site WebSocket Hijacking).
OriginPatterns: []string{r.Host}on the WS upgrade pins the cross-site surface to same-host only. - C2 — CSRF.
OriginGuardmiddleware on cookie-authed state-changing routes (POST/PUT/DELETE/PATCH); skips bearer-keyed callers. - C3 — Login rate limit. Per-IP token bucket on
/api/login(5/min, burst 5, 12-second refill). - M4 — Session revocation on credential change.
SetWebPasswordalways revokes;SetWebConfigrevokes when the username changes (and only when it changes). - 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.
- L2 —
RandomTokenpropagatescrypto/randerrors. Removes a silently-zero-token failure mode. - H1 —
safeRemovePathpath-traversal validation. Refuses to delete outside the configured save root, defending the engine’s “delete on remove” path against crafted relative paths. - M1 —
safefetchSSRF defense.validateFetchURLrejects non-http(s) schemes and refuses to dial private/loopback addresses; the dialer insafeHTTPClientis 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_allis on, the self-signed cert generated under<DataDir>/web-tls/coverslocalhost,127.0.0.1, and::1only — not the actual LAN IP or hostname the listener is reachable on. Browsers will refuse the cert without a per-host exception. Fix: extendEnsureSelfSignedCertto accept an explicit SAN list and pass the advertisedHostthrough. The cert isn’t generated or used at all in loopback-only mode, so this surfaces only whenbind_allis on. - H3 — signed
SHA256SUMS. The auto-updater verifies SHA-256 against the release’sSHA256SUMSfile, 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 > 255but doesn’t boundm(memory) ort(time) — a hostile stored hash could in theory force the verifier into pathological CPU/memory use. Fix: clampmandtto 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-Forheader is not trusted in deployments where the listener faces clients directly. Fix: add a regression test that verifies the rate limiter usesRemoteAddr, not XFF, in the default config. prevUserread error swallowed inSetWebConfig. When determining whether the username changed (to decide whether to revoke sessions), the read error from the oldprevUserlookup 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.dbhas the API key (it’s stored verbatim) and the password hash. The directory is created0o700on 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.