Files
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

161 lines
4.5 KiB
Go

package subsonic
import (
"crypto/md5"
"crypto/subtle"
"database/sql"
"encoding/hex"
"strconv"
"strings"
vsauth "git.noroutes.net/NoRoutes/vibetunes/internal/auth"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
// authenticate implements original Subsonic authentication: either
// token/salt (t = md5(password + salt), the modern recommended mode) or
// password (p=<plaintext> or p=enc:<hex>, kept for older clients).
//
// This never touches VibeSonic's Argon2id-hashed password directly for
// token auth — that hash is one-way by design. Token auth is instead
// verified against a separately maintained, AES-GCM-encrypted copy of
// the password (see internal/auth/subsonic_secret.go); the /rest/*
// surface never reintroduces MD5 or plaintext as VibeSonic's own storage
// format, it only speaks it on the wire where the protocol requires it.
func authenticate(db *sql.DB, encryptionKey []byte, p params) (*database.User, *apiError) {
apiKey := p.Get("apiKey")
username := p.Get("u")
if apiKey != "" {
// The apiKeyAuthentication extension requires a client not send
// 'u' alongside 'apiKey' — accepting both would leave it
// ambiguous which identity the caller actually intended, so this
// is rejected outright rather than silently preferring one.
if username != "" {
return nil, errConflictingAuth
}
return authenticateAPIKey(db, apiKey)
}
if username == "" {
return nil, errMissingParameter("u")
}
user, err := database.GetUserByUsername(db, username)
if err != nil || user.Disabled {
return nil, errWrongCredentials
}
token := p.Get("t")
salt := p.Get("s")
password := p.Get("p")
switch {
case token != "" && salt != "":
if !user.SubsonicAuthSecret.Valid {
return nil, errTokenAuthNotSupported
}
plaintext, derr := vsauth.DecryptPassword(encryptionKey, user.SubsonicAuthSecret.String)
if derr != nil {
return nil, errWrongCredentials
}
sum := md5.Sum([]byte(plaintext + salt))
expected := hex.EncodeToString(sum[:])
if subtle.ConstantTimeCompare([]byte(expected), []byte(strings.ToLower(token))) != 1 {
return nil, errWrongCredentials
}
case password != "":
plaintext := password
if strings.HasPrefix(password, "enc:") {
decoded, herr := hex.DecodeString(strings.TrimPrefix(password, "enc:"))
if herr != nil {
return nil, errWrongCredentials
}
plaintext = string(decoded)
}
if !user.PasswordHash.Valid {
return nil, errWrongCredentials
}
ok, verr := vsauth.VerifyPassword(plaintext, user.PasswordHash.String)
if verr != nil || !ok {
return nil, errWrongCredentials
}
default:
return nil, errMissingParameter("p or t/s")
}
return user, nil
}
// authenticateAPIKey implements the OpenSubsonic apiKeyAuthentication
// extension: the key is hashed the same way as VibeSonic's own web
// settings API keys (they are exactly the same keys — one system, not a
// parallel one) and looked up by hash, never by scanning stored keys in
// plaintext.
func authenticateAPIKey(db *sql.DB, rawKey string) (*database.User, *apiError) {
key, err := database.GetAPIKeyByHash(db, vsauth.HashAPIKey(rawKey))
if err != nil {
return nil, errInvalidAPIKey
}
user, err := database.GetUserByID(db, key.UserID)
if err != nil || user.Disabled {
return nil, errInvalidAPIKey
}
_ = database.TouchAPIKey(db, key.ID)
return user, nil
}
// checkVersion rejects clients whose declared protocol version is newer
// than what this server implements. A missing or unparsable v is
// tolerated (treated as compatible) rather than rejected outright, to
// stay lenient with tooling that omits it — the protocol is additive, so
// there is no reason to reject an old client either.
func checkVersion(v string) *apiError {
if v == "" {
return nil
}
client, ok := parseVersion(v)
if !ok {
return nil
}
server, _ := parseVersion(ServerVersion)
if versionGreater(client, server) {
return errClientMustUpgrade
}
return nil
}
type semver struct{ major, minor, patch int }
func parseVersion(v string) (semver, bool) {
parts := strings.SplitN(v, ".", 3)
if len(parts) == 0 {
return semver{}, false
}
var s semver
var err error
s.major, err = strconv.Atoi(parts[0])
if err != nil {
return semver{}, false
}
if len(parts) > 1 {
s.minor, _ = strconv.Atoi(parts[1])
}
if len(parts) > 2 {
s.patch, _ = strconv.Atoi(parts[2])
}
return s, true
}
func versionGreater(a, b semver) bool {
if a.major != b.major {
return a.major > b.major
}
if a.minor != b.minor {
return a.minor > b.minor
}
return a.patch > b.patch
}