Files
2026-08-23 14:13:18 -05:00

161 lines
7.7 KiB
Markdown

# Security
This documents VibeSonic's security-relevant design decisions and the
configuration knobs that affect them. See also
[`docs/external-auth.md`](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 cookie. Set
`VIBESONIC_COOKIE_SECURE=true` for HTTPS deployments so browsers only send
session and CSRF cookies over secure connections.
- **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`](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").
## Artist photos and manual metadata editing
When `VIBESONIC_ENABLE_ARTIST_PHOTOS` is on, VibeSonic looks up an
artist photo from fanart.tv (only for an artist with both a tagged
MusicBrainz ID and `VIBESONIC_FANART_TV_API_KEY` set) or, failing
that, by searching Wikidata directly by artist name and reading the
matched entity's image from Wikimedia Commons — both
externally-hosted services, same trust model as the existing
MusicBrainz/Cover Art Archive lookups used elsewhere. This path
deliberately never calls the MusicBrainz API itself (its public
instance proved too rate-limited to depend on); a name-only match is
necessarily less precise, so it requires an exact (case-insensitive)
label match and only weakly prefers a music-flavored Wikidata
description, rather than fabricating a stricter guarantee it can't
actually make. The resulting image download, and any admin-supplied
photo/cover-art URL entered on an artist or album's edit page, is
fetched through the same `internal/netguard` SSRF-safe client described
above (dial-time IP validation, capped redirects, size-limited
response). Uploaded photo/cover files are validated by actually
decoding them as an image before being written to disk.
Editing artist/album/song metadata (`/admin/artists/{id}/edit`,
`/admin/albums/{id}/edit`, `/admin/songs/{id}/edit`) and overriding a
photo/cover are admin-only actions, gated the same way as every other
`/admin/*` route.
## 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.