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.
Hardening history
The v0.1.22 audit pass closed the initial 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.
A subsequent backend-authz audit closed 13 more items across the api / remote surfaces:
- Default-deny authz.
CallerFromreturns the zeroCaller(no privileges) when no caller is installed on the request context; every legitimate internal call site explicitly installsSystemCaller. Admin-only mutations (SetWebConfig,SetWebPassword,RotateAPIKey,InstallUpdate,SetUpdaterConfig,SetDesktopIntegration) and read-only settings (GetWebConfig,GetUpdaterConfig,GetPeerLimits,GetBlocklist) now require admin. - Username-enumeration timing oracle closed.
Authenticate/AuthenticateAPIKeyalways run Argon2id (using a sentinel hash on lookup miss) so wall-clock response time no longer distinguishes “unknown user” from “wrong password.” - Admin self-heal.
EnsureAdminUserrecovers when the stored admin hash isn’t a valid Argon2id PHC string — an upgrade from pre-migration storage doesn’t permanently lock out the admin. - Request body size limits. Every JSON handler is wrapped with
http.MaxBytesReader(1 MiB; multipart upload stays at 10 MiB). The login route’sContent-Lengthis pre-checked so an oversized body doesn’t burn a rate-limiter slot before it’s rejected. - Trusted-proxy CIDR allowlist gates
X-Forwarded-For. Without an explicit trusted-proxy config the per-RemoteAddrrate limiter is preserved (no spoof regression). - Per-user password limiter.
ChangeMyPasswordandResetUserPasswordshare a per-user limiter so a stolen session can’t grind the old password. - Error-leak guard.
writeServiceErrno longer maps non-validation errors to 400 with the raw message; they return 500 with a generic body and the full error is logged. OriginGuardtightened. State-changing cookie-authed requests with bothOriginandReferermissing are now rejected 403 (previously allowed). Bearer-keyed requests still skip the check.?key=URL bearer form removed. URL params leak into Referer headers, reverse-proxy logs, and browser history.BearerTokenFromRequestaccepts only theAuthorizationheader.- WebSocket revocation.
Hub.RevokeUserhangs up a user’s live WebSocket sockets.api.Service.invalidateCallercloses the socket in the same step it revokes the session and evicts the caller cache. The WS read loop additionally re-validates cookie-backed sessions every 30 s as a safety net. - Session store cap behavior. Sessions slide their TTL on each
Valid()call. When the store is full, new logins are rejected — the previous “evict oldest” behavior was a DoS vector (an authenticated attacker could evict legitimate users). - Cookie
Secureis per-request. Computed from the effective scheme of each request (with optionalX-Forwarded-Prototrust whenTrustForwardedProtois set), not pinned at startup.
A filesystem-containment + process-hardening audit followed:
- OS-level sandboxing for
mosaicd. The packaged systemd unit ships withNoNewPrivileges,ProtectSystem=strict,ProtectHome=true,PrivateTmp,PrivateDevices,ProtectKernelTunables,ProtectKernelModules,ProtectControlGroups, and a dedicatedmosaic:mosaicuser. See Daemon → systemd. - Save-path containment. Adds reject save paths that escape the engine’s configured root, defending against
../-style traversal even when the path comes from a trusted-looking source like a.torrentfilename suggestion.
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.