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:
2026-08-23 09:18:17 -05:00
co-authored by Claude Sonnet 5
parent 933ed31550
commit 789bf91eed
36 changed files with 1722 additions and 96 deletions
+66 -6
View File
@@ -3,6 +3,7 @@ package web
import (
"context"
"net/http"
"net/url"
"time"
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
@@ -48,6 +49,16 @@ func (h *Web) handleAdminUserCreate(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/users", http.StatusFound)
}
// userPermissionsPageData bundles the target user with the data the
// permissions form needs beyond the plain user row: every music folder
// (so the form can render a checkbox per folder) and which of those the
// user is currently restricted to.
type userPermissionsPageData struct {
*database.User
Folders []*database.MusicFolder
AllowedFolders map[string]bool
}
func (h *Web) handleAdminUserPermissions(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
target, err := database.GetUserByID(h.DB, id)
@@ -55,12 +66,31 @@ func (h *Web) handleAdminUserPermissions(w http.ResponseWriter, r *http.Request)
http.NotFound(w, r)
return
}
h.page(w, r, "admin_user_permissions", "admin_users", "Permissions: "+target.Username, target)
folders, err := database.ListMusicFolders(h.DB)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
allowedIDs, err := database.AllowedMusicFolderIDs(h.DB, id)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
allowed := make(map[string]bool, len(allowedIDs))
for _, fid := range allowedIDs {
allowed[fid] = true
}
h.page(w, r, "admin_user_permissions", "admin_users", "Permissions: "+target.Username, userPermissionsPageData{
User: target,
Folders: folders,
AllowedFolders: allowed,
})
}
func (h *Web) handleAdminUserPermissionsUpdate(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, err := database.GetUserByID(h.DB, id); err != nil {
target, err := database.GetUserByID(h.DB, id)
if err != nil {
http.NotFound(w, r)
return
}
@@ -86,6 +116,32 @@ func (h *Web) handleAdminUserPermissionsUpdate(w http.ResponseWriter, r *http.Re
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
wantAdmin := get("admin")
if target.IsAdmin && !wantAdmin {
// Refuse to demote the last remaining admin — otherwise the
// instance could end up with no one able to reach /admin/*.
n, err := database.CountAdmins(h.DB)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if n <= 1 {
http.Error(w, "cannot remove admin role from the last remaining admin", http.StatusBadRequest)
return
}
}
if err := database.SetUserAdmin(h.DB, id, wantAdmin); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
folderIDs := r.Form["music_folders"]
if err := database.SetAllowedMusicFolders(h.DB, id, folderIDs); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/users", http.StatusFound)
}
@@ -189,11 +245,15 @@ func (h *Web) handleAdminFolderScan(w http.ResponseWriter, r *http.Request) {
if h.Scanner != nil {
go func() { _ = h.Scanner.ScanFolder(context.Background(), id) }()
}
referer := r.Header.Get("Referer")
if referer == "" {
referer = "/admin/scanner"
dest := "/admin/scanner"
if referer := r.Header.Get("Referer"); referer != "" {
if u, err := url.Parse(referer); err == nil {
if safe := safeRedirectPath(u.Path); safe != "/" {
dest = safe
}
}
}
http.Redirect(w, r, referer, http.StatusFound)
http.Redirect(w, r, dest, http.StatusFound)
}
// --- Scanner status ---
+5
View File
@@ -5,6 +5,7 @@ import (
"net/http"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
"git.noroutes.net/NoRoutes/vibetunes/internal/netguard"
)
func (h *Web) handleAdminPodcasts(w http.ResponseWriter, r *http.Request) {
@@ -35,6 +36,10 @@ func (h *Web) handleAdminPodcastCreate(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/podcasts", http.StatusFound)
return
}
if err := netguard.ValidateURL(feedURL); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
channel, err := database.CreatePodcastChannel(h.DB, feedURL)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
+194
View File
@@ -0,0 +1,194 @@
package web
import (
"bufio"
"database/sql"
"fmt"
"net/http"
"path/filepath"
"strings"
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
// handlePlaylistExport streams a playlist out as a standard M3U8 file:
// an #EXTM3U header, an #EXTINF duration/title line per track, and the
// track's absolute on-disk path (folder root + relative path) so a
// re-import against the same library's folders resolves cleanly.
func (h *Web) handlePlaylistExport(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
id := r.PathValue("id")
playlist, err := database.GetPlaylist(h.DB, id)
if err != nil {
http.NotFound(w, r)
return
}
if playlist.OwnerID != user.ID && !playlist.IsPublic {
http.Error(w, "403 Forbidden", http.StatusForbidden)
return
}
entries, err := database.ListPlaylistEntries(h.DB, id)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
folders, err := database.ListMusicFolders(h.DB)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
folderByID := make(map[string]*database.MusicFolder, len(folders))
for _, f := range folders {
folderByID[f.ID] = f
}
filename := sanitizeFilename(playlist.Name) + ".m3u8"
w.Header().Set("Content-Type", "audio/x-mpegurl; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
buf := bufio.NewWriter(w)
defer buf.Flush()
buf.WriteString("#EXTM3U\n")
for _, e := range entries {
song, err := database.GetSong(h.DB, e.SongID)
if err != nil {
continue
}
mf, err := database.GetMediaFile(h.DB, song.MediaFileID)
if err != nil {
continue
}
folder, ok := folderByID[mf.MusicFolderID]
if !ok {
continue
}
artist := ""
if a := albumOf(h.DB, song); a != nil && a.PrimaryAlbumArtistID.Valid {
if art, err := database.GetArtist(h.DB, a.PrimaryAlbumArtistID.String); err == nil {
artist = art.Name
}
}
title := song.Title
if artist != "" {
title = artist + " - " + title
}
fmt.Fprintf(buf, "#EXTINF:%d,%s\n", int(mf.DurationSeconds), title)
buf.WriteString(filepath.Join(folder.Path, mf.RelativePath))
buf.WriteString("\n")
}
}
// handlePlaylistImportForm renders the upload form.
func (h *Web) handlePlaylistImportForm(w http.ResponseWriter, r *http.Request) {
h.page(w, r, "playlist_import", "playlists", "Import Playlist", nil)
}
// playlistImportResult is the summary shown after an import: how many
// M3U entries matched a track in the library versus were skipped.
type playlistImportResult struct {
Playlist *database.Playlist
Matched int
Skipped int
}
// handlePlaylistImport accepts an uploaded .m3u/.m3u8 file, resolves each
// entry to a song already known to the library (matching by absolute
// path against a scanned music folder's root, falling back to a
// filename-only match within that folder), and creates a new playlist
// from whatever matched. Unmatched lines are reported, not treated as an
// error — a partial match is still useful to the user.
func (h *Web) handlePlaylistImport(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
if err := r.ParseMultipartForm(4 << 20); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "no file uploaded", http.StatusBadRequest)
return
}
defer file.Close()
folders, err := database.ListMusicFolders(h.DB)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
name := strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
if name == "" {
name = "Imported Playlist"
}
playlist, err := database.CreatePlaylist(h.DB, user.ID, name, "", false)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
matched, skipped := 0, 0
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
song := resolvePlaylistImportLine(h.DB, folders, line)
if song == nil {
skipped++
continue
}
if err := database.AppendPlaylistEntry(h.DB, playlist.ID, song.ID); err != nil {
skipped++
continue
}
matched++
}
h.page(w, r, "playlist_import_result", "playlists", "Import Result", playlistImportResult{
Playlist: playlist,
Matched: matched,
Skipped: skipped,
})
}
// resolvePlaylistImportLine matches one M3U entry (an absolute path, or a
// path relative to a music folder root) to a known song.
func resolvePlaylistImportLine(db *sql.DB, folders []*database.MusicFolder, line string) *database.Song {
for _, folder := range folders {
rel := line
if strings.HasPrefix(line, folder.Path) {
rel = strings.TrimPrefix(line, folder.Path)
rel = strings.TrimPrefix(rel, string(filepath.Separator))
} else if filepath.IsAbs(line) {
continue // absolute path under a different folder root: not a match here
}
mf, err := database.FindMediaFileByPath(db, folder.ID, rel)
if err != nil {
continue
}
song, err := database.GetSongByMediaFileID(db, mf.ID)
if err != nil {
continue
}
return song
}
return nil
}
func sanitizeFilename(name string) string {
name = strings.Map(func(r rune) rune {
switch r {
case '/', '\\', ':', '*', '?', '"', '<', '>', '|':
return '_'
}
return r
}, name)
name = strings.TrimSpace(name)
if name == "" {
return "playlist"
}
return name
}
+174
View File
@@ -0,0 +1,174 @@
package web
import (
"bytes"
"database/sql"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
// seedPlaylistFixture creates a logged-in user with one song in a music
// folder, matching the shape seedShareFixture uses elsewhere in this
// package.
func seedPlaylistFixture(t *testing.T, db *sql.DB) (folderPath string, mediaFile *database.MediaFile, song *database.Song) {
t.Helper()
folderPath = t.TempDir()
folder, err := database.CreateMusicFolder(db, "Music", folderPath, "music")
if err != nil {
t.Fatal(err)
}
mf, err := database.CreateMediaFile(db, folder.ID, "artist/album/track.mp3", 100, 0, "hash1", "mp3", "song", 123, 128, 44100, 2)
if err != nil {
t.Fatal(err)
}
s, err := database.UpsertSong(db, database.UpsertSongInput{MediaFileID: mf.ID, Title: "Test Track"})
if err != nil {
t.Fatal(err)
}
return folderPath, mf, s
}
func TestPlaylistExportProducesValidM3U(t *testing.T) {
h, db := newTestWeb(t)
srv := httptest.NewServer(h.NewRouter())
defer srv.Close()
jar := map[string]*http.Cookie{}
doSetup(t, srv, jar, t.TempDir())
folderPath, mf, song := seedPlaylistFixture(t, db)
user, err := database.GetUserByUsername(db, "admin")
if err != nil {
t.Fatal(err)
}
playlist, err := database.CreatePlaylist(db, user.ID, "My Mix", "", false)
if err != nil {
t.Fatal(err)
}
if err := database.AppendPlaylistEntry(db, playlist.ID, song.ID); err != nil {
t.Fatal(err)
}
resp := doRequest(t, srv, jar, http.MethodGet, "/playlists/"+playlist.ID+"/export", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET export = %d, want 200", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
text := string(body)
if !strings.HasPrefix(text, "#EXTM3U\n") {
t.Fatalf("export missing #EXTM3U header: %q", text)
}
wantPath := filepath.Join(folderPath, mf.RelativePath)
if !strings.Contains(text, wantPath) {
t.Fatalf("export = %q, want it to contain track path %q", text, wantPath)
}
if !strings.Contains(text, "Test Track") {
t.Fatalf("export = %q, want it to contain track title", text)
}
}
func TestPlaylistExportRequiresOwnershipOrPublic(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())
seedPlaylistFixture(t, db)
owner, err := database.CreateUser(db, "owner", "", "", false)
if err != nil {
t.Fatal(err)
}
playlist, err := database.CreatePlaylist(db, owner.ID, "Private", "", false)
if err != nil {
t.Fatal(err)
}
resp := doRequest(t, srv, adminJar, http.MethodGet, "/playlists/"+playlist.ID+"/export", nil)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("GET export of another user's private playlist = %d, want 403", resp.StatusCode)
}
}
func TestPlaylistImportMatchesTracksByPath(t *testing.T) {
h, db := newTestWeb(t)
srv := httptest.NewServer(h.NewRouter())
defer srv.Close()
jar := map[string]*http.Cookie{}
doSetup(t, srv, jar, t.TempDir())
folderPath, mf, _ := seedPlaylistFixture(t, db)
trackPath := filepath.Join(folderPath, mf.RelativePath)
m3u := "#EXTM3U\n#EXTINF:123,Some Artist - Test Track\n" + trackPath + "\nunmatched/nonexistent.mp3\n"
token := csrfToken(t, srv, jar, "/playlists/import")
var body bytes.Buffer
mw := multipart.NewWriter(&body)
if err := mw.WriteField("csrf_token", token); err != nil {
t.Fatal(err)
}
fw, err := mw.CreateFormFile("file", "My Import.m3u8")
if err != nil {
t.Fatal(err)
}
if _, err := fw.Write([]byte(m3u)); err != nil {
t.Fatal(err)
}
mw.Close()
req, err := http.NewRequest(http.MethodPost, srv.URL+"/playlists/import", &body)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", mw.FormDataContentType())
for _, c := range jar {
req.AddCookie(c)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
t.Fatalf("POST import = %d, want 200; body: %s", resp.StatusCode, respBody)
}
respBody, _ := io.ReadAll(resp.Body)
text := string(respBody)
if !strings.Contains(text, "1") {
t.Fatalf("import result page = %q, want it to mention 1 matched track", text)
}
user, err := database.GetUserByUsername(db, "admin")
if err != nil {
t.Fatal(err)
}
playlists, err := database.ListPlaylistsByOwner(db, user.ID)
if err != nil {
t.Fatal(err)
}
if len(playlists) != 1 {
t.Fatalf("ListPlaylistsByOwner = %d playlists, want 1", len(playlists))
}
entries, err := database.ListPlaylistEntries(db, playlists[0].ID)
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 {
t.Fatalf("imported playlist has %d entries, want 1 (one unmatched line should be skipped)", len(entries))
}
}
+3
View File
@@ -40,8 +40,11 @@ func (h *Web) NewRouter() http.Handler {
mux.HandleFunc("GET /playlists", h.requireUser(h.handlePlaylists))
mux.HandleFunc("POST /playlists", h.requireUser(h.handlePlaylistCreate))
mux.HandleFunc("GET /playlists/import", h.requireUser(h.handlePlaylistImportForm))
mux.HandleFunc("POST /playlists/import", h.requireUser(h.handlePlaylistImport))
mux.HandleFunc("GET /playlists/{id}", h.requireUser(h.handlePlaylist))
mux.HandleFunc("POST /playlists/{id}/delete", h.requireUser(h.handlePlaylistDelete))
mux.HandleFunc("GET /playlists/{id}/export", h.requireUser(h.handlePlaylistExport))
mux.HandleFunc("GET /favorites", h.requireUser(h.handleFavorites))
mux.HandleFunc("POST /favorites/star", h.requireUser(h.handleFavoriteStar))
+68
View File
@@ -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)
}
}