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
This commit is contained in:
+111
@@ -0,0 +1,111 @@
|
||||
# Backup
|
||||
|
||||
VibeSonic keeps all of its state under one directory,
|
||||
`VIBESONIC_DATA_DIR` (default `/var/lib/vibesonic`, or `/data` in the
|
||||
provided Docker image). Everything that must be backed up lives there:
|
||||
|
||||
```
|
||||
<DataDir>/
|
||||
vibesonic.db SQLite database — users, library index,
|
||||
playlists, favorites, sessions, API keys,
|
||||
podcast subscriptions, shares, ...
|
||||
vibesonic.db-wal SQLite write-ahead log (see below)
|
||||
vibesonic.db-shm SQLite shared-memory index for the WAL
|
||||
cache/covers/ resized cover-art cache (safe to delete —
|
||||
regenerated on demand from source images)
|
||||
podcasts/ downloaded podcast episodes
|
||||
```
|
||||
|
||||
Your music library itself (the folders configured via
|
||||
`VIBESONIC_MUSIC_DIRS` / the admin "Music Folders" page) is not inside
|
||||
`DataDir` and has its own backup story — it's your source media, and
|
||||
VibeSonic never modifies it.
|
||||
|
||||
## What must be backed up
|
||||
|
||||
1. **`vibesonic.db`** (plus `-wal`/`-shm` if present — see below). This
|
||||
is the only thing whose loss is unrecoverable: it holds every user
|
||||
account, password/API-key hash, the entire scanned library index,
|
||||
playlists, favorites/ratings, play history, podcast subscriptions,
|
||||
share links, and configuration stored in the `settings` table.
|
||||
2. **`podcasts/`**, if you don't want to re-download subscribed
|
||||
episodes after a restore.
|
||||
3. Anything you passed via `VIBESONIC_*` environment variables
|
||||
(systemd unit, Docker Compose file, `.env`) — these aren't stored in
|
||||
the database and won't come back from a database restore alone.
|
||||
|
||||
## What does not need to be backed up
|
||||
|
||||
- **`cache/covers/`** — purely a derived cache, regenerated
|
||||
automatically the first time each cover art size is requested again.
|
||||
Safe to delete at any time, including while VibeSonic is running.
|
||||
- **Your music files** — back these up through whatever process you
|
||||
already use for your media library; VibeSonic treats them as
|
||||
read-only input, never as its own state.
|
||||
|
||||
## Correct SQLite backup: mind the WAL
|
||||
|
||||
VibeSonic runs SQLite in **WAL (write-ahead log) mode** for concurrency
|
||||
(`internal/database/database.go`: `PRAGMA journal_mode=WAL`). In WAL
|
||||
mode, recently-committed data can live in the `vibesonic.db-wal` file
|
||||
rather than in `vibesonic.db` itself, and isn't merged back
|
||||
("checkpointed") into the main file on any fixed schedule.
|
||||
|
||||
**Do not just `cp vibesonic.db` from a live server.** A plain file copy
|
||||
of `vibesonic.db` alone, taken while VibeSonic is running, can miss data
|
||||
that only exists in the `-wal` file, or copy `vibesonic.db` and
|
||||
`vibesonic.db-wal` at slightly different points in time and produce an
|
||||
inconsistent pair. Either failure mode can leave you with a backup that
|
||||
looks fine (opens without error) but is silently missing recent writes
|
||||
or is outright corrupt.
|
||||
|
||||
Use one of these instead:
|
||||
|
||||
### Option A — stop the server, then copy (simplest, requires downtime)
|
||||
|
||||
```sh
|
||||
systemctl stop vibesonic
|
||||
cp /var/lib/vibesonic/vibesonic.db* /path/to/backup/
|
||||
systemctl start vibesonic
|
||||
```
|
||||
|
||||
On a clean shutdown SQLite checkpoints and closes the WAL cleanly, so a
|
||||
plain copy of the (possibly WAL-less) database directory taken while the
|
||||
process is stopped is always consistent.
|
||||
|
||||
### Option B — the SQLite online backup API, via the `sqlite3` CLI (no downtime)
|
||||
|
||||
`sqlite3`'s `.backup` command uses SQLite's own backup API, which
|
||||
correctly handles an in-progress WAL and produces a consistent snapshot
|
||||
without stopping VibeSonic or blocking writers for more than the brief
|
||||
final copy step:
|
||||
|
||||
```sh
|
||||
sqlite3 /var/lib/vibesonic/vibesonic.db ".backup /path/to/backup/vibesonic.db"
|
||||
```
|
||||
|
||||
Do this on a schedule (cron/systemd timer) and rotate the output.
|
||||
|
||||
### Option C — filesystem snapshot (LVM, ZFS, Btrfs)
|
||||
|
||||
A crash-consistent filesystem snapshot of the whole `DataDir` (taken
|
||||
atomically, e.g. `zfs snapshot` or an LVM snapshot) is also safe — SQLite
|
||||
in WAL mode is designed to recover cleanly from a snapshot equivalent to
|
||||
an ungraceful power loss, the same way it recovers on the next open
|
||||
after a crash. Plain `rsync`/`cp` while the server is running is **not**
|
||||
equivalent to this, since it isn't atomic across the `.db`/`-wal`/`-shm`
|
||||
trio.
|
||||
|
||||
## Restoring
|
||||
|
||||
1. Stop VibeSonic.
|
||||
2. Replace `vibesonic.db` (and `-wal`/`-shm` if you backed those up too)
|
||||
with the backup copy.
|
||||
3. Restore `podcasts/` if you backed it up.
|
||||
4. Start VibeSonic. It runs pending migrations automatically on
|
||||
startup, same as any other launch.
|
||||
|
||||
If you only have a `vibesonic.db` from Option B/C without matching
|
||||
`-wal`/`-shm` files, that's fine — `.backup` and filesystem snapshots
|
||||
both produce a database file that's already fully checkpointed/consistent
|
||||
on its own.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Client compatibility
|
||||
|
||||
This is a **tracking template**, not a report of testing that has
|
||||
already happened. As of this phase, no real Subsonic/OpenSubsonic
|
||||
client apps have been run against VibeSonic in this development
|
||||
environment — every row below is unfilled and marked accordingly. Please
|
||||
do not read the table as evidence of compatibility; read
|
||||
[`docs/subsonic-compatibility.md`](subsonic-compatibility.md) for that —
|
||||
it documents endpoint-by-endpoint protocol conformance validated by
|
||||
VibeSonic's own automated HTTP-level test suite (`internal/subsonic/*_test.go`),
|
||||
which is real evidence, exercised on every change. This document exists
|
||||
to capture *real device/app* testing once someone actually does it.
|
||||
|
||||
## How to test a client and fill in a row
|
||||
|
||||
1. Point the client at your VibeSonic server URL, e.g.
|
||||
`https://music.example.com` or `http://<host>:8080` for a direct,
|
||||
un-proxied connection. Most Subsonic clients want the base URL only
|
||||
(no `/rest` suffix) plus a username/password — they construct
|
||||
`/rest/*` requests themselves.
|
||||
2. **Authentication**: try native username/password first (works whether
|
||||
or not external auth is enabled, as long as the account has a local
|
||||
password — see [`external-auth.md`](external-auth.md)). If the client
|
||||
supports OpenSubsonic API keys (the `apiKeyAuthentication` extension —
|
||||
see [`subsonic-compatibility.md`](subsonic-compatibility.md#opensubsonic-extensions)),
|
||||
generate one from the web UI's account settings and test that path
|
||||
too, noting which the client actually used.
|
||||
3. Work through: server discovery/ping, browsing (artists/albums/songs,
|
||||
both filesystem and ID3 modes if the client exposes both), search,
|
||||
cover art, direct streaming, forced transcoding (pick a bitrate/format
|
||||
the source file doesn't already match), playlists (create/edit/
|
||||
delete/reorder), favorites/star, scrobbling, lyrics (plain and
|
||||
synced, if the client supports OpenSubsonic's `songLyrics`), the play
|
||||
queue, downloads, podcasts (if the client has podcast support), and
|
||||
any OpenSubsonic-specific features the client advertises.
|
||||
4. Fill in the row below with what actually worked, partially worked, or
|
||||
didn't — and describe **Known Issues** concretely (not "broken", but
|
||||
"album art doesn't load for tracks with only embedded art, HTTP 404"
|
||||
or similar) so someone else can reproduce or fix it.
|
||||
5. If a client needs a compatibility workaround (bending strict protocol
|
||||
behavior to accommodate something the client gets wrong), that
|
||||
exception must be documented here **and** in
|
||||
[`subsonic-compatibility.md`](subsonic-compatibility.md) next to the
|
||||
affected endpoint — don't silently special-case a client in code
|
||||
without a paper trail.
|
||||
|
||||
## Tracking table
|
||||
|
||||
| Client | Version | Platform | Authentication | Browsing | Streaming | Transcoding | Playlists | Lyrics | OpenSubsonic | Known Issues |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Symfonium | — | Android | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | — |
|
||||
| Ultrasonic | — | Android | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | — |
|
||||
| Tempo | — | iOS | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | — |
|
||||
| Amperfy | — | iOS/macOS | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | — |
|
||||
| play:Sub | — | iOS | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | — |
|
||||
| Subtracks | — | Android | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | — |
|
||||
| DSub | — | Android | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | — |
|
||||
| Supersonic | — | Desktop (Linux/macOS/Windows) | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | — |
|
||||
| Feishin | — | Desktop/Web | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | Not yet tested | — |
|
||||
|
||||
Add new rows for any other client you test — this list isn't exhaustive,
|
||||
just a reasonable starting set across ecosystems (Android, iOS, desktop).
|
||||
|
||||
## Compatibility exceptions
|
||||
|
||||
None recorded yet. If a real client requires bending protocol behavior
|
||||
to work correctly, document the exception here: which client/version,
|
||||
what VibeSonic does differently and why, and a link to the affected
|
||||
endpoint in [`subsonic-compatibility.md`](subsonic-compatibility.md).
|
||||
@@ -17,6 +17,8 @@ except the session secret.
|
||||
| `VIBESONIC_SCAN_INTERVAL` | `1h` | How often to rescan enabled music folders, as a Go duration (`30m`, `6h`, ...). A startup scan always runs regardless of this value. |
|
||||
| `VIBESONIC_SESSION_SECRET` | *(none)* | Reserved for future use hardening session cookies beyond their database-backed random IDs. Not required to run, but recommended to set (e.g. `openssl rand -hex 32`) — VibeSonic logs a warning at startup if it's empty. |
|
||||
| `VIBESONIC_COOKIE_SECURE` | `true` | Whether session/CSRF cookies get the `Secure` attribute. Set to `false` only for local development over plain HTTP. |
|
||||
| `VIBESONIC_ENABLE_EXTERNAL_METADATA` | `false` | Enable optional MusicBrainz/Cover Art Archive lookups for songs/albums that already carry a locally-tagged MusicBrainz ID. VibeSonic works fully offline with this off; it's the only feature that makes outbound Internet requests. |
|
||||
| `VIBESONIC_PODCASTS_ALLOW_PRIVATE_NETWORKS` | `false` | Allow podcast feed/enclosure URLs to resolve to loopback/LAN/link-local addresses. Leave this off unless you intentionally host podcast feeds on a private network — see [`docs/security.md`](security.md#podcast-fetching-ssrf-protections). |
|
||||
|
||||
## External authentication
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
# Deployment
|
||||
|
||||
VibeSonic is a single static binary plus `ffmpeg`/`ffprobe` on `PATH`
|
||||
(and optionally `mpv`, for the jukebox feature). Docker is convenient
|
||||
but never required for normal operation.
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is via `VIBESONIC_*` environment variables — see
|
||||
[`docs/configuration.md`](configuration.md) for the full list. There is
|
||||
no config file.
|
||||
|
||||
## Running natively
|
||||
|
||||
```sh
|
||||
go build -o vibesonic ./cmd/vibesonic
|
||||
|
||||
export VIBESONIC_DATA_DIR=/var/lib/vibesonic
|
||||
export VIBESONIC_MUSIC_DIRS=/music
|
||||
export VIBESONIC_SESSION_SECRET="$(openssl rand -hex 32)"
|
||||
./vibesonic
|
||||
```
|
||||
|
||||
For a persistent deployment, see
|
||||
[`systemd/vibesonic.service`](../systemd/vibesonic.service) — install it
|
||||
with:
|
||||
|
||||
```sh
|
||||
sudo useradd --system --home /var/lib/vibesonic --shell /usr/sbin/nologin vibesonic
|
||||
sudo install -m 0755 vibesonic /usr/local/bin/vibesonic
|
||||
sudo cp systemd/vibesonic.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now vibesonic
|
||||
```
|
||||
|
||||
The unit runs as a dedicated `vibesonic` user (not `DynamicUser`,
|
||||
because it needs a stable UID with read access to your music library —
|
||||
`DynamicUser` allocates a random UID per start, which doesn't compose
|
||||
well with a fixed set of file permissions on external media). Grant that
|
||||
user read access to your music folders:
|
||||
|
||||
```sh
|
||||
sudo usermod -aG <group-that-owns-your-music> vibesonic
|
||||
# or, more simply, make the music tree group- or world-readable
|
||||
```
|
||||
|
||||
`StateDirectory=vibesonic` in the unit creates and owns
|
||||
`/var/lib/vibesonic` (mode `0700`) for you — don't create it manually
|
||||
with different permissions first. The unit also applies standard
|
||||
hardening (`ProtectSystem=strict`, `NoNewPrivileges`, `PrivateDevices`,
|
||||
etc.) and `Restart=on-failure`.
|
||||
|
||||
Remember to set a real `VIBESONIC_SESSION_SECRET` in the unit (or in an
|
||||
`EnvironmentFile=`) — the shipped example is a placeholder.
|
||||
|
||||
## Running with Docker
|
||||
|
||||
```sh
|
||||
docker build -t vibesonic .
|
||||
```
|
||||
|
||||
Runtime image is `debian:bookworm-slim` with `ffmpeg`, `mpv`, and CA
|
||||
certificates installed, running as a non-root `vibesonic` user (uid
|
||||
10001). See [`Dockerfile`](../Dockerfile).
|
||||
|
||||
Example Compose file (also in [`docker-compose.yml`](../docker-compose.yml)):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
vibesonic:
|
||||
image: vibesonic
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./data:/var/lib/vibesonic
|
||||
- /mnt/music:/music:ro
|
||||
environment:
|
||||
VIBESONIC_SESSION_SECRET: "change-me-to-a-random-string"
|
||||
```
|
||||
|
||||
Mount your music read-only (`:ro`) — VibeSonic never writes to it.
|
||||
`./data` (or a named volume) holds the SQLite database, cover art cache,
|
||||
and downloaded podcast episodes; back it up per
|
||||
[`docs/backup.md`](backup.md).
|
||||
|
||||
## Reverse proxy
|
||||
|
||||
Run VibeSonic behind a reverse proxy for TLS termination in any
|
||||
real deployment; the examples below forward to VibeSonic on
|
||||
`127.0.0.1:8080`.
|
||||
|
||||
VibeSonic does not use WebSockets. Streaming and transcoded responses
|
||||
are ordinary long-lived HTTP responses using standard range requests —
|
||||
make sure your proxy doesn't buffer the entire response body before
|
||||
forwarding it (buffering breaks range requests and adds needless
|
||||
latency to playback start).
|
||||
|
||||
### Caddy
|
||||
|
||||
```
|
||||
music.example.com {
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}
|
||||
```
|
||||
|
||||
Caddy handles TLS automatically and streams request/response bodies
|
||||
without buffering by default — no extra configuration needed for range
|
||||
requests or large responses.
|
||||
|
||||
### nginx
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name music.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/music.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/music.example.com/privkey.pem;
|
||||
|
||||
client_max_body_size 0;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`proxy_buffering off` matters here: nginx buffers proxied responses to
|
||||
disk/memory by default, which delays the start of playback and range
|
||||
responses. `client_max_body_size 0` avoids nginx rejecting large file
|
||||
uploads if the upload feature is used.
|
||||
|
||||
### Traefik
|
||||
|
||||
```yaml
|
||||
http:
|
||||
routers:
|
||||
vibesonic:
|
||||
rule: "Host(`music.example.com`)"
|
||||
service: vibesonic
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
services:
|
||||
vibesonic:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://127.0.0.1:8080"
|
||||
```
|
||||
|
||||
Traefik streams responses without buffering by default, same as Caddy.
|
||||
|
||||
## Reverse proxy + external (trusted-proxy) authentication
|
||||
|
||||
If you're using [`docs/external-auth.md`](external-auth.md) to have the
|
||||
proxy handle SSO (OIDC, SAML, HTTP Basic Auth, ...) for the browser UI,
|
||||
two things are essential:
|
||||
|
||||
1. **The proxy must strip any client-supplied identity header before
|
||||
inserting its own.** If it doesn't, a client can set
|
||||
`Remote-User: admin` (or whatever header name you configured) on its
|
||||
own request and have the proxy forward it unchanged, impersonating
|
||||
any user. Every proxy example below includes this.
|
||||
|
||||
2. **`/rest/*` must bypass the proxy's SSO layer.** Real Subsonic/
|
||||
OpenSubsonic clients (mobile apps, desktop players) authenticate
|
||||
directly against VibeSonic's own token/API-key scheme and have no way
|
||||
to complete a browser-based SSO flow (OIDC redirect, SAML POST,
|
||||
etc.). Exclude `/rest/*` from the SSO requirement so those clients
|
||||
keep working, while still letting `/rest/*` reach VibeSonic normally
|
||||
— VibeSonic's own `/rest/*` auth is independent of external auth
|
||||
either way.
|
||||
|
||||
### Caddy example (with `forward_auth` for SSO + header stripping)
|
||||
|
||||
```
|
||||
music.example.com {
|
||||
# Strip any client-supplied identity header before forward_auth
|
||||
# potentially sets a real one, so a client can't forge it.
|
||||
header_down -Remote-User
|
||||
|
||||
@needsAuth {
|
||||
not path /rest/*
|
||||
}
|
||||
forward_auth @needsAuth auth.example.com:9091 {
|
||||
uri /verify
|
||||
copy_headers Remote-User
|
||||
}
|
||||
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}
|
||||
```
|
||||
|
||||
### nginx example (with `auth_request` for SSO + header stripping)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
# ... TLS config as above ...
|
||||
|
||||
location /rest/ {
|
||||
# Subsonic/OpenSubsonic clients: no SSO, straight through.
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Remote-User ""; # never forward a client-supplied value
|
||||
}
|
||||
|
||||
location / {
|
||||
auth_request /sso-verify;
|
||||
auth_request_set $remote_user $upstream_http_remote_user;
|
||||
proxy_set_header Remote-User $remote_user;
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
}
|
||||
|
||||
location = /sso-verify {
|
||||
internal;
|
||||
proxy_pass http://auth.example.com/verify;
|
||||
proxy_set_header X-Original-URI $request_uri;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Either way, also set `VIBESONIC_EXTAUTH_TRUSTED_SOURCES` to only the
|
||||
proxy's own address (e.g. `127.0.0.1/32` if it's on the same host) —
|
||||
VibeSonic independently verifies the real TCP peer, so even a
|
||||
misconfigured proxy can't be used to spoof identity from the internet
|
||||
directly. See [`docs/external-auth.md`](external-auth.md) for that
|
||||
check in detail.
|
||||
@@ -59,6 +59,10 @@ password on VibeSonic's own `/login` page even when external auth is
|
||||
enabled, unless their account has no password hash set (auto-created
|
||||
external users start this way).
|
||||
|
||||
In later phases, `/rest/*` (the native Subsonic API) will authenticate
|
||||
exclusively via Subsonic's own token/API-key scheme and must never be
|
||||
affected by external auth's browser-cookie flow.
|
||||
`/rest/*` (the Subsonic/OpenSubsonic API) never consults external auth
|
||||
at all: it authenticates exclusively via Subsonic's own token/salt,
|
||||
password, or `apiKey` scheme (see `internal/subsonic/auth.go`), so real
|
||||
Subsonic/OpenSubsonic clients keep working unmodified even when external
|
||||
auth fronts the browser UI. This is also why a reverse proxy enforcing
|
||||
SSO in front of VibeSonic should let `/rest/*` bypass that SSO layer —
|
||||
see `docs/deployment.md` for example proxy configuration.
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
# 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, `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`](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.
|
||||
@@ -239,6 +239,16 @@ in `internal/subsonic/opensubsonic_core_test.go` fails the build if an
|
||||
extension is ever registered without its handler actually existing, so
|
||||
this list can't drift ahead of what's real.
|
||||
|
||||
Of the extensions below, `apiKeyAuthentication`, `formPost`, and
|
||||
`songLyrics` come from the published OpenSubsonic extension list.
|
||||
`getPodcastEpisode`, `indexBasedQueue`, `playbackReport`,
|
||||
`topSongsByArtistId`, `transcodeOffset`, `transcoding`, and
|
||||
`sonicSimilarity` are **VibeSonic-original** extensions — real,
|
||||
implemented, and advertised the same way as any other extension, but
|
||||
not part of the OpenSubsonic project's own spec, so don't assume
|
||||
another OpenSubsonic server implements them just because it advertises
|
||||
OpenSubsonic support in general.
|
||||
|
||||
| Extension | Versions | Endpoint(s) | Tested | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `apiKeyAuthentication` | 1 | `tokenInfo`, `apiKey=` param on every endpoint | ✅ | Per-user named keys with plaintext shown once, SHA-256 hash stored, creation/last-used tracking, revocation (self or admin). `apiKey` conflicting with `u` returns error 43; an invalid/revoked key returns error 44. Managed at Settings → API Keys; admins see/revoke everyone's keys at `/admin/api-keys`. |
|
||||
|
||||
Reference in New Issue
Block a user