Files
vibesonic/docs/security.md
T
owenandClaude Sonnet 5 789bf91eed Complete Phase 4: security hardening, player/playlist/admin UI, docs
Security:
- New internal/netguard package: podcast feed/enclosure fetching now
  refuses to dial non-public IPs (loopback/RFC1918/link-local/cloud
  metadata/CGNAT), validated per-connection including across redirects,
  closing an SSRF gap where a malicious or compromised feed URL could
  probe the server's internal network. Configurable via
  VIBESONIC_PODCASTS_ALLOW_PRIVATE_NETWORKS for intentional LAN feeds.
- Podcast episode downloads capped at 4GB (was unbounded, disk-exhaustion risk)
- Cover art decoding now rejects images above a 40-megapixel budget
  before full decode (decompression-bomb guard)
- HTTP server sets ReadHeaderTimeout (slowloris hardening)
- Admin folder-scan redirect now validated as same-site instead of
  trusting the raw Referer header (open-redirect fix)
- Legacy Subsonic token/salt auth now uses a constant-time comparison

Web UI completion:
- Persistent player: shuffle and repeat (off/all/one) controls, wired
  into queue advance/prev logic and persisted like other player state
- Admin user-permissions form: adminRole toggle (with a guard against
  demoting the last remaining admin) and per-user music-folder access
  checkboxes, backed by existing schema/enforcement that was previously
  unreachable from the UI
- Playlist M3U8 import/export

Docs: added docs/client-compatibility.md (honest tracking template —
no client testing has been performed), docs/deployment.md,
docs/security.md, docs/backup.md; refreshed README and cross-links;
added a footnote in docs/subsonic-compatibility.md distinguishing
spec-standard from VibeSonic-original OpenSubsonic extensions.

Also added Go fuzz tests for the external-auth CIDR/IP parser and the
LRC lyrics parser.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv
2026-08-23 09:18:17 -05:00

6.2 KiB

Security

This documents VibeSonic's security-relevant design decisions and the configuration knobs that affect them. See also docs/external-auth.md for the trusted-proxy authentication model in detail.

Authentication

  • Passwords are hashed with Argon2id (internal/auth/password.go). VibeSonic never stores or logs a plaintext password.
  • Subsonic legacy token/salt auth (t=/s= params, used by older Subsonic clients) requires comparing an MD5 digest against one derived from the stored password — but MD5 hashes aren't reversible into Argon2id, so VibeSonic separately keeps an AES-256-GCM-encrypted copy of the plaintext password (internal/auth/subsonic_secret.go), keyed by a server-held encryption key, solely to support this legacy scheme. This is an inherent property of the Subsonic protocol, not a VibeSonic design choice — every Subsonic-compatible server needs some way to recover the plaintext to validate t=md5(password+salt). The comparison itself uses a constant-time compare (crypto/subtle.ConstantTimeCompare) to avoid a timing side-channel.
  • API keys are stored and looked up by hash, never in plaintext.
  • Sessions are random opaque tokens in an HTTP-only, Secure (configurable via VIBESONIC_COOKIE_SECURE, on by default) cookie.
  • External (trusted-proxy) authentication is off by default and, when enabled, trusts an identity-asserting header only from configured source IPs verified against the real TCP peer address — never against spoofable headers like X-Forwarded-For. Full detail in docs/external-auth.md.
  • /rest/* (the Subsonic/OpenSubsonic API) authenticates independently of external auth, via token/salt, password, or apiKey, so standard Subsonic clients work unmodified regardless of how the browser UI is fronted.

CSRF

State-changing browser routes are protected by a double-submit CSRF token (internal/auth/csrf.go), compared in constant time. The /rest/* API is exempt by design — it's a machine API authenticated per request (token/password/API key), not cookie-session-based, so CSRF doesn't apply to it the way it does to browser form posts.

Path traversal

Every file served — streamed audio, downloads, cover art — is resolved through internal/streaming.ResolvePath, which joins the stored relative path onto its music folder root, resolves symlinks, and verifies the result is still inside that root before anything is opened. A request cannot escape its folder via ../ segments or a symlink pointing outside the folder.

Podcast fetching: SSRF protections

Podcast feed and episode-enclosure URLs are, by nature, externally supplied (an admin adds a feed URL; that feed's own content — including HTTP redirects — is not otherwise controlled by VibeSonic). Without restriction, this would let a malicious or compromised feed turn VibeSonic into a proxy onto its own internal network: pointing a feed URL, or a redirect from one, at 127.0.0.1, an RFC1918 LAN address, or a cloud metadata endpoint (169.254.169.254).

internal/netguard closes this off for every connection the podcast manager's HTTP client makes, including connections made while following a redirect (so a feed can't pass an initial URL check and then redirect somewhere unsafe), by validating the actual IP address about to be dialed:

  • Loopback, RFC1918/RFC4193 private ranges, link-local (including the 169.254.169.254 cloud metadata address), carrier-grade NAT (100.64.0.0/10), multicast, and unspecified addresses are all rejected.
  • The check runs at dial time against the specific resolved IP, not a hostname re-resolved later — so a DNS record that changes between the initial check and the connection ("DNS rebinding") can't bypass it.
  • Redirects are capped at 5 hops and restricted to http/https.
  • Episode downloads are capped at 4 GB so a misbehaving or malicious feed can't exhaust disk space via an unbounded response.

For deployments that intentionally host podcast feeds on a private network, set VIBESONIC_PODCASTS_ALLOW_PRIVATE_NETWORKS=true to disable the IP restriction (the size cap and redirect limit still apply). Leave this off otherwise.

Cover art / image handling

Source image files are size-capped before decoding, and — separately — their decoded pixel dimensions are checked via image.DecodeConfig before the full image is decoded into memory, rejecting anything over 40 megapixels. This prevents a small, highly-compressed image file from forcing an out-of-proportion memory allocation ("decompression bomb").

Transcoding

ffmpeg/ffprobe are invoked with exec.CommandContext and an explicit argument-vector slice — never through a shell — so there is no shell-metacharacter injection surface. Output paths are passed after a literal -- argument so a generated filename can never be misinterpreted as an additional flag. Transcode concurrency is bounded by a worker pool, and a client disconnect cancels the underlying ffmpeg process via the request context.

HTTP server hardening

The server sets ReadHeaderTimeout to bound how long a client can take to send request headers, mitigating slow-header ("Slowloris"-style) connection exhaustion. WriteTimeout is deliberately left unset: audio/video streaming and transcoded output are long-lived writes that a fixed write deadline would break.

Redirects

All same-site post-action redirects (login's next=, playlist actions, favorites, admin actions) are validated through a shared safeRedirectPath helper that only accepts a same-site relative path — rejecting protocol-relative (//evil.example) or absolute (https://evil.example) targets — before being used in an http.Redirect, so a crafted link can't be used to bounce a logged-in user's browser off-site.

Logging

VibeSonic logs operational events (startup/shutdown, migrations, scanner activity, authentication failures, admin actions, transcode/ podcast failures) via structured logging. It never logs passwords, session tokens, API keys, Subsonic auth tokens/salts, or Authorization headers.

Reporting a vulnerability

If you find a security issue, please report it privately rather than opening a public issue, so a fix can be prepared before the details are public.