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:
@@ -270,3 +270,71 @@ func TestLoginRejectsWrongPassword(t *testing.T) {
|
||||
t.Error("expected login failure message in response body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPermissionsCannotDemoteLastAdmin(t *testing.T) {
|
||||
h, db := newTestWeb(t)
|
||||
srv := httptest.NewServer(h.NewRouter())
|
||||
defer srv.Close()
|
||||
|
||||
adminJar := map[string]*http.Cookie{}
|
||||
doSetup(t, srv, adminJar, t.TempDir())
|
||||
|
||||
admin, err := database.GetUserByUsername(db, "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByUsername: %v", err)
|
||||
}
|
||||
|
||||
token := csrfToken(t, srv, adminJar, "/admin/users/"+admin.ID+"/permissions")
|
||||
resp := doRequest(t, srv, adminJar, http.MethodPost, "/admin/users/"+admin.ID+"/permissions", url.Values{
|
||||
"csrf_token": {token},
|
||||
// "admin" intentionally omitted: attempting to demote the only admin.
|
||||
"stream": {"1"},
|
||||
})
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("demote last admin = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
|
||||
got, err := database.GetUserByID(db, admin.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByID: %v", err)
|
||||
}
|
||||
if !got.IsAdmin {
|
||||
t.Fatal("last admin was demoted despite the guard")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPermissionsSetsMusicFolderRestriction(t *testing.T) {
|
||||
h, db := newTestWeb(t)
|
||||
srv := httptest.NewServer(h.NewRouter())
|
||||
defer srv.Close()
|
||||
|
||||
adminJar := map[string]*http.Cookie{}
|
||||
doSetup(t, srv, adminJar, t.TempDir())
|
||||
|
||||
user, err := database.CreateUser(db, "regular", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
folder, err := database.CreateMusicFolder(db, "Music", t.TempDir(), "music")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMusicFolder: %v", err)
|
||||
}
|
||||
|
||||
token := csrfToken(t, srv, adminJar, "/admin/users/"+user.ID+"/permissions")
|
||||
resp := doRequest(t, srv, adminJar, http.MethodPost, "/admin/users/"+user.ID+"/permissions", url.Values{
|
||||
"csrf_token": {token},
|
||||
"music_folders": {folder.ID},
|
||||
"stream": {"1"},
|
||||
})
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("save permissions = %d, want 302", resp.StatusCode)
|
||||
}
|
||||
|
||||
allowed, err := database.AllowedMusicFolderIDs(db, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("AllowedMusicFolderIDs: %v", err)
|
||||
}
|
||||
if len(allowed) != 1 || allowed[0] != folder.ID {
|
||||
t.Fatalf("AllowedMusicFolderIDs = %v, want [%s]", allowed, folder.ID)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user