Vellum/internal/subsonic/subsonic_test.go
mithral 6e04e957b8 Rate limit every surface that checks a password
The limiter covered only /api/v1/auth/login. Jellyfin's AuthenticateByName
and Subsonic's authenticate both called auth.Authenticate with no limiting,
so an attacker brute-forced through those instead: the same password, the
same argon2, no ceiling.

The limiter moves from auth.Handlers onto *Authenticator, which all three
surfaces already hold, so they share one budget and splitting guesses
across surfaces gains nothing. main.go builds one authenticator and hands
it to both compat layers, so the sharing is real rather than intended.

Subsonic needs care: it has no login step, authenticate runs on every
request, so a naive check would charge playback against the login budget
and lock a listener out mid-album. The limiter is consulted only when a
credential was actually supplied, and a success or an uncredentialed
request spends nothing.

Verified red-green on all three: removing the Jellyfin gate fails
TestAuthenticateByNameIsRateLimited with 401 instead of 429; removing the
Subsonic credential guard fails
TestSuccessAndUncredentialedDoNotConsumeTheBudget; neutering AllowLogin or
ResetLogin fails the auth tests.

Known gap, unchanged: the limiter is in-process, so it resets on restart
and does not span replicas. Fine for one binary against one SQLite file.
2026-10-12 00:57:56 -04:00

551 lines
19 KiB
Go

package subsonic
import (
"context"
"crypto/md5"
"database/sql"
"encoding/hex"
"encoding/json"
"encoding/xml"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/mithral/vellum/internal/apitoken"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/internal/subsonic/dto"
"github.com/mithral/vellum/migrations"
)
// fixture is one seeded Subsonic server.
type fixture struct {
db *sql.DB
userID int64
key string
libID int64
album int64
file int64
root string
}
func testDB(t *testing.T) *sql.DB {
t.Helper()
db, err := core.OpenDB(filepath.Join(t.TempDir(), "subsonic.db"))
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := core.Migrate(context.Background(), db, migrations.FS, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatalf("Migrate: %v", err)
}
return db
}
// newFixture seeds one non-admin user with a granted music library holding a
// single three-track album, an artist credit and a cover.
func newFixture(t *testing.T) (*httptest.Server, *fixture) {
t.Helper()
db := testDB(t)
ctx := context.Background()
root := t.TempDir()
for _, name := range []string{
"Artist/Album/01 - One.mp3",
"Artist/Album/02 - Two.mp3",
"Artist/Album/03 - Three.mp3",
} {
p := filepath.Join(root, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
// 100 deterministic bytes so a Range read is verifiable.
if err := os.WriteFile(p, []byte(strings.Repeat("v", 100)), 0o644); err != nil {
t.Fatal(err)
}
}
coverPath := filepath.Join(root, "cover.png")
if err := os.WriteFile(coverPath, []byte("\x89PNG\r\n\x1a\ncoverbytes"), 0o644); err != nil {
t.Fatal(err)
}
user, err := auth.CreateUser(ctx, db, "alice", "Alice", "password123", false)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
var libID int64
if err := db.QueryRowContext(ctx,
`INSERT INTO libraries (name, kind, root_path) VALUES ('Music','music',?) RETURNING id`,
root).Scan(&libID); err != nil {
t.Fatalf("insert library: %v", err)
}
if _, err := db.ExecContext(ctx,
`INSERT INTO library_grants (user_id, library_id, can_read, can_download) VALUES (?,?,1,1)`,
user.ID, libID); err != nil {
t.Fatalf("grant: %v", err)
}
var albumID int64
if err := db.QueryRowContext(ctx, `
INSERT INTO media_items (library_id, kind, path, title, sort_title, year)
VALUES (?, 'music', 'Artist/Album', 'Album', 'album', 2013) RETURNING id`,
libID).Scan(&albumID); err != nil {
t.Fatalf("insert album: %v", err)
}
var firstFile int64
for i, name := range []string{
"Artist/Album/01 - One.mp3",
"Artist/Album/02 - Two.mp3",
"Artist/Album/03 - Three.mp3",
} {
var id int64
if err := db.QueryRowContext(ctx, `
INSERT INTO media_files (item_id, path, size, container, duration_ms)
VALUES (?,?,100,'mp3',60000) RETURNING id`,
albumID, name).Scan(&id); err != nil {
t.Fatalf("insert file: %v", err)
}
if i == 0 {
firstFile = id
}
}
var personID int64
if err := db.QueryRowContext(ctx,
`INSERT INTO people (name) VALUES ('The Artist') RETURNING id`).Scan(&personID); err != nil {
t.Fatalf("insert person: %v", err)
}
if _, err := db.ExecContext(ctx,
`INSERT INTO item_people (item_id, person_id, role, ord) VALUES (?,?,'author',0)`,
albumID, personID); err != nil {
t.Fatalf("insert credit: %v", err)
}
if _, err := db.ExecContext(ctx,
`INSERT INTO assets (item_id, role, path, bytes) VALUES (?, 'cover', ?, 20)`,
albumID, coverPath); err != nil {
t.Fatalf("insert asset: %v", err)
}
key, err := apitoken.Mint(ctx, db, user.ID, apitoken.KindSubsonic, "test")
if err != nil {
t.Fatalf("Mint: %v", err)
}
authenticator := auth.NewAuthenticator(db, "vellum_session", 0, false, slog.New(slog.NewTextHandler(io.Discard, nil)))
h := NewHandlers(db, slog.New(slog.NewTextHandler(io.Discard, nil)), authenticator)
mux := http.NewServeMux()
h.Register(mux)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, &fixture{db: db, userID: user.ID, key: key, libID: libID, album: albumID, file: firstFile, root: root}
}
// authQuery is the preferred t=md5(key+salt) credential form.
func (f *fixture) authQuery() string {
salt := "abc123"
sum := md5.Sum([]byte(f.key + salt))
return "u=alice&s=" + salt + "&t=" + hex.EncodeToString(sum[:]) + "&v=1.16.1&c=test"
}
// get performs a request and returns the recorder.
func get(t *testing.T, url string) *http.Response {
t.Helper()
resp, err := http.Get(url)
if err != nil {
t.Fatalf("GET %s: %v", url, err)
}
return resp
}
func readBody(t *testing.T, resp *http.Response) string {
t.Helper()
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
return string(b)
}
// jsonEnvelope decodes a Subsonic JSON response into the root object.
func jsonEnvelope(t *testing.T, body string) map[string]any {
t.Helper()
var out map[string]any
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Fatalf("decode json %q: %v", body, err)
}
root, ok := out["subsonic-response"].(map[string]any)
if !ok {
t.Fatalf("missing subsonic-response in %q", body)
}
return root
}
// ── auth ───────────────────────────────────────────────────────────────
// TestAuthTokenForm proves the preferred md5(key+salt) path succeeds.
func TestAuthTokenForm(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/ping.view?"+f.authQuery()+"&f=json"))
root := jsonEnvelope(t, body)
if root["status"] != "ok" {
t.Fatalf("status = %v, want ok; body=%s", root["status"], body)
}
}
// TestAuthWrongTokenIsEnvelope pins the single most common integration failure:
// a bad token is HTTP 200 with status failed and error code 40, never a 401.
func TestAuthWrongTokenIsEnvelope(t *testing.T) {
srv, _ := newFixture(t)
resp := get(t, srv.URL+"/rest/ping.view?u=alice&s=abc123&t=deadbeef&v=1.16.1&c=test&f=json")
if resp.StatusCode != http.StatusOK {
t.Errorf("HTTP status = %d, want 200 (errors belong in the envelope)", resp.StatusCode)
}
body := readBody(t, resp)
root := jsonEnvelope(t, body)
if root["status"] != "failed" {
t.Fatalf("status = %v, want failed; body=%s", root["status"], body)
}
errObj, ok := root["error"].(map[string]any)
if !ok {
t.Fatalf("missing error object; body=%s", body)
}
if code, _ := errObj["code"].(float64); int(code) != 40 {
t.Errorf("error code = %v, want 40; body=%s", errObj["code"], body)
}
}
// TestAuthPlainKey proves p=<key> is accepted (Navidrome compatibility).
func TestAuthPlainKey(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/ping.view?u=alice&p="+f.key+"&v=1.16.1&c=test&f=json"))
if root := jsonEnvelope(t, body); root["status"] != "ok" {
t.Fatalf("p=<key> status = %v, want ok; body=%s", root["status"], body)
}
}
// TestAuthUnknownUserIsCode40 proves an unknown user is the same envelope error.
func TestAuthUnknownUserIsCode40(t *testing.T) {
srv, _ := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/ping.view?u=nobody&p=x&v=1.16.1&c=test&f=json"))
root := jsonEnvelope(t, body)
if root["status"] != "failed" {
t.Fatalf("unknown user status = %v, want failed", root["status"])
}
if code, _ := root["error"].(map[string]any)["code"].(float64); int(code) != 40 {
t.Errorf("unknown user code = %v, want 40", root["error"])
}
}
// ── login rate limiting ─────────────────────────────────────────────────
// Subsonic is a third way to guess a password, so it shares the login budget:
// otherwise the web login's limit is bypassed by pointing a client here. The
// refusal is indistinguishable from any other credential failure — code 40.
func TestFailedAuthIsRateLimited(t *testing.T) {
srv, f := newFixture(t)
bad := "u=alice&s=abc123&t=deadbeef&v=1.16.1&c=test&f=json"
for i := 1; i <= 10; i++ {
root := jsonEnvelope(t, readBody(t, get(t, srv.URL+"/rest/ping.view?"+bad)))
if root["status"] != "failed" {
t.Fatalf("attempt %d: status = %v, want failed", i, root["status"])
}
}
// The correct key is refused too: that is the limiter, not the key.
root := jsonEnvelope(t, readBody(t, get(t, srv.URL+"/rest/ping.view?"+f.authQuery()+"&f=json")))
if root["status"] != "failed" {
t.Errorf("the correct key still worked after 10 failures; the limiter is not engaged")
}
}
// The budget must move only on a failed check with a credential behind it.
// authenticate runs on EVERY request, so charging a working client would lock a
// listener out mid-album, and a request that supplies no credential must not
// spend anything either.
func TestSuccessAndUncredentialedDoNotConsumeTheBudget(t *testing.T) {
srv, f := newFixture(t)
for i := 1; i <= 25; i++ {
// A username but no credential: charged against nothing, so a client
// that sends one cannot lock the real user out.
readBody(t, get(t, srv.URL+"/rest/ping.view?u=alice&v=1.16.1&c=test&f=json"))
}
root := jsonEnvelope(t, readBody(t, get(t, srv.URL+"/rest/ping.view?"+f.authQuery()+"&f=json")))
if root["status"] != "ok" {
t.Fatalf("the correct key was refused after 25 requests carrying no credential")
}
// A success does not spend budget either: authenticate runs on every
// request, so a listener browsing for hours must never be locked out.
for i := 1; i <= 25; i++ {
root := jsonEnvelope(t, readBody(t, get(t, srv.URL+"/rest/ping.view?"+f.authQuery()+"&f=json")))
if root["status"] != "ok" {
t.Fatalf("request %d with the correct key: status = %v, want ok", i, root["status"])
}
}
}
// ── system / browse ────────────────────────────────────────────────────
func TestPingJSONAndXML(t *testing.T) {
srv, f := newFixture(t)
jroot := jsonEnvelope(t, readBody(t, get(t, srv.URL+"/rest/ping.view?"+f.authQuery()+"&f=json")))
if jroot["status"] != "ok" || jroot["version"] != dto.ProtocolVersion || jroot["type"] != dto.ServerType {
t.Errorf("json envelope = %v", jroot)
}
xmlBody := readBody(t, get(t, srv.URL+"/rest/ping.view?"+f.authQuery()+"&f=xml"))
var parsed struct {
XMLName xml.Name `xml:"subsonic-response"`
Status string `xml:"status,attr"`
Version string `xml:"version,attr"`
}
if err := xml.Unmarshal([]byte(xmlBody), &parsed); err != nil {
t.Fatalf("xml parse: %v; body=%s", err, xmlBody)
}
if parsed.Status != "ok" || parsed.Version != dto.ProtocolVersion {
t.Errorf("xml envelope = %+v", parsed)
}
}
func TestGetMusicFolders(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/getMusicFolders.view?"+f.authQuery()+"&f=json"))
root := jsonEnvelope(t, body)
folders, ok := root["musicFolders"].(map[string]any)
if !ok {
t.Fatalf("missing musicFolders; body=%s", body)
}
list, ok := folders["musicFolder"].([]any)
if !ok || len(list) != 1 {
t.Fatalf("musicFolder = %v, want one entry; body=%s", folders["musicFolder"], body)
}
if list[0].(map[string]any)["name"] != "Music" {
t.Errorf("folder = %v", list[0])
}
}
func TestGetIndexesAndArtists(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/getArtists.view?"+f.authQuery()+"&f=json"))
root := jsonEnvelope(t, body)
artists := root["artists"].(map[string]any)
indexes := artists["index"].([]any)
if len(indexes) != 1 {
t.Fatalf("indexes = %v", indexes)
}
first := indexes[0].(map[string]any)
if first["name"] != "A" {
t.Errorf("index name = %v, want A (The Artist)", first["name"])
}
if first["artist"].([]any)[0].(map[string]any)["name"] != "The Artist" {
t.Errorf("artist = %v", first["artist"])
}
// getIndexes returns the same index but a leaner artist element.
body = readBody(t, get(t, srv.URL+"/rest/getIndexes.view?"+f.authQuery()+"&f=json"))
root = jsonEnvelope(t, body)
if _, ok := root["indexes"]; !ok {
t.Fatalf("missing indexes; body=%s", body)
}
}
func TestSearch3(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/search3.view?"+f.authQuery()+"&query=Artist&f=json"))
root := jsonEnvelope(t, body)
result := root["searchResult3"].(map[string]any)
albums := result["album"].([]any)
if len(albums) != 1 {
t.Fatalf("albums = %v, want one; body=%s", albums, body)
}
if albums[0].(map[string]any)["name"] != "Album" {
t.Errorf("album = %v", albums[0])
}
if len(result["song"].([]any)) != 3 {
t.Errorf("songs = %v, want three", result["song"])
}
if len(result["artist"].([]any)) != 1 {
t.Errorf("artists = %v, want one", result["artist"])
}
}
func TestGetAlbumList2Newest(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/getAlbumList2.view?"+f.authQuery()+"&type=newest&f=json"))
root := jsonEnvelope(t, body)
list := root["albumList2"].(map[string]any)
albums := list["album"].([]any)
if len(albums) != 1 {
t.Fatalf("albums = %v; body=%s", albums, body)
}
if albums[0].(map[string]any)["songCount"].(float64) != 3 {
t.Errorf("songCount = %v, want 3", albums[0].(map[string]any)["songCount"])
}
}
func TestGetAlbumAndSong(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/getAlbum.view?"+f.authQuery()+"&id="+dto.AlbumID(f.album)+"&f=json"))
root := jsonEnvelope(t, body)
album := root["album"].(map[string]any)
if len(album["song"].([]any)) != 3 {
t.Errorf("getAlbum songs = %v", album["song"])
}
body = readBody(t, get(t, srv.URL+"/rest/getSong.view?"+f.authQuery()+"&id="+dto.FileID(f.file)+"&f=json"))
root = jsonEnvelope(t, body)
song := root["song"].(map[string]any)
if song["album"] != "Album" || song["artist"] != "The Artist" {
t.Errorf("song = %v", song)
}
}
func TestGetMusicDirectory(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/getMusicDirectory.view?"+f.authQuery()+"&id="+dto.FolderID(f.libID)+"&f=json"))
root := jsonEnvelope(t, body)
dir := root["directory"].(map[string]any)
children := dir["child"].([]any)
if len(children) != 1 || children[0].(map[string]any)["isDir"] != true {
t.Fatalf("root children = %v; body=%s", children, body)
}
// Drill into the album directory and confirm its songs appear.
body = readBody(t, get(t, srv.URL+"/rest/getMusicDirectory.view?"+f.authQuery()+"&id="+dto.AlbumID(f.album)+"&f=json"))
root = jsonEnvelope(t, body)
dir = root["directory"].(map[string]any)
if len(dir["child"].([]any)) != 3 {
t.Errorf("album directory children = %v", dir["child"])
}
}
func TestGetUser(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/getUser.view?"+f.authQuery()+"&f=json"))
root := jsonEnvelope(t, body)
user := root["user"].(map[string]any)
if user["username"] != "alice" {
t.Errorf("username = %v", user["username"])
}
if user["adminRole"] != false || user["streamRole"] != true {
t.Errorf("roles = %v", user)
}
}
func TestGetPlaylistsEmpty(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/getPlaylists.view?"+f.authQuery()+"&f=json"))
root := jsonEnvelope(t, body)
pl, ok := root["playlists"].(map[string]any)
if !ok {
t.Fatalf("missing playlists; body=%s", body)
}
if list, ok := pl["playlist"].([]any); !ok || len(list) != 0 {
t.Errorf("playlist = %v, want empty array", pl["playlist"])
}
}
// ── media ──────────────────────────────────────────────────────────────
func TestStreamRawAndRange(t *testing.T) {
srv, f := newFixture(t)
resp := get(t, srv.URL+"/rest/stream.view?"+f.authQuery()+"&id="+dto.FileID(f.file)+"&format=raw")
if resp.StatusCode != http.StatusOK {
t.Fatalf("stream status = %d, want 200", resp.StatusCode)
}
if got := readBody(t, resp); len(got) != 100 {
t.Fatalf("stream body length = %d, want 100", len(got))
}
req, _ := http.NewRequest(http.MethodGet,
srv.URL+"/rest/stream.view?"+f.authQuery()+"&id="+dto.FileID(f.file)+"&format=raw", nil)
req.Header.Set("Range", "bytes=0-9")
rangeResp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("range request: %v", err)
}
defer rangeResp.Body.Close()
if rangeResp.StatusCode != http.StatusPartialContent {
t.Fatalf("range status = %d, want 206", rangeResp.StatusCode)
}
if cr := rangeResp.Header.Get("Content-Range"); cr != "bytes 0-9/100" {
t.Errorf("Content-Range = %q, want bytes 0-9/100", cr)
}
body, _ := io.ReadAll(rangeResp.Body)
if len(body) != 10 {
t.Errorf("range body length = %d, want 10", len(body))
}
}
// TestStreamRejectsTranscode proves a non-raw format is refused honestly rather
// than silently served untranscoded.
func TestStreamRejectsTranscode(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/stream.view?"+f.authQuery()+"&id="+dto.FileID(f.file)+"&format=mp3&f=json"))
root := jsonEnvelope(t, body)
if root["status"] != "failed" {
t.Fatalf("format=mp3 status = %v, want failed; body=%s", root["status"], body)
}
if !strings.Contains(root["error"].(map[string]any)["message"].(string), "transcod") {
t.Errorf("error message = %v, want a transcoding refusal", root["error"])
}
}
func TestGetCoverArt(t *testing.T) {
srv, f := newFixture(t)
resp := get(t, srv.URL+"/rest/getCoverArt.view?"+f.authQuery()+"&id="+dto.AlbumID(f.album))
if resp.StatusCode != http.StatusOK {
t.Fatalf("cover status = %d, want 200", resp.StatusCode)
}
if got := readBody(t, resp); !strings.HasPrefix(got, "\x89PNG") {
t.Errorf("cover bytes = %q", got)
}
}
// TestScrobbleWritesProgress proves scrobble lands in the progress store.
func TestScrobbleWritesProgress(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/scrobble.view?"+f.authQuery()+
"&id="+dto.FileID(f.file)+"&submission=true&position=30000&f=json"))
if root := jsonEnvelope(t, body); root["status"] != "ok" {
t.Fatalf("scrobble status = %v; body=%s", root["status"], body)
}
var position, duration int64
if err := f.db.QueryRow(
`SELECT position, duration FROM progress WHERE user_id = ? AND item_id = ? AND file_id = ?`,
f.userID, f.album, f.file).Scan(&position, &duration); err != nil {
t.Fatalf("progress row: %v", err)
}
if position != 30000 || duration != 60000 {
t.Errorf("progress = (%d,%d), want (30000,60000)", position, duration)
}
}
// TestAccessScopedToGrants proves a user without a grant cannot stream.
func TestAccessScopedToGrants(t *testing.T) {
srv, f := newFixture(t)
// Revoke the grant, then request the same file.
if _, err := f.db.Exec(`DELETE FROM library_grants WHERE user_id = ?`, f.userID); err != nil {
t.Fatal(err)
}
body := readBody(t, get(t, srv.URL+"/rest/stream.view?"+f.authQuery()+"&id="+dto.FileID(f.file)+"&format=raw&f=json"))
root := jsonEnvelope(t, body)
if root["status"] != "failed" {
t.Fatalf("revoked grant stream status = %v, want failed; body=%s", root["status"], body)
}
}