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.
435 lines
15 KiB
Go
435 lines
15 KiB
Go
package jellyfin
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/mithral/vellum/internal/apitoken"
|
|
"github.com/mithral/vellum/internal/auth"
|
|
"github.com/mithral/vellum/internal/core"
|
|
"github.com/mithral/vellum/internal/jellyfin/dto"
|
|
"github.com/mithral/vellum/migrations"
|
|
)
|
|
|
|
// These tests exist because internal/jellyfin is the most client-visible surface
|
|
// in the tree — real Jellyfin apps parse its JSON — and it had no tests at all.
|
|
// The two that matter most are the grant check (a wrong one leaks another
|
|
// user's library) and the transcode flag (a client told transcoding is
|
|
// available waits forever for a stream that never starts).
|
|
|
|
type fixture struct {
|
|
srv *httptest.Server
|
|
db *sql.DB
|
|
itemID int64
|
|
fileID int64
|
|
libID int64
|
|
admin string // token
|
|
granted string // token, non-admin, granted the library
|
|
none string // token, non-admin, no grants
|
|
}
|
|
|
|
func testDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
db, err := core.OpenDB(filepath.Join(t.TempDir(), "jellyfin.db"))
|
|
if err != nil {
|
|
t.Fatalf("OpenDB: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil {
|
|
t.Fatalf("Migrate: %v", err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
func newFixture(t *testing.T) *fixture {
|
|
t.Helper()
|
|
db := testDB(t)
|
|
ctx := context.Background()
|
|
|
|
// A real file on disk: the stream handler opens it, so a row alone is not
|
|
// enough to prove the streaming path.
|
|
root := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(root, "movie.mp4"), []byte("0123456789abcdefghij"), 0o644); err != nil {
|
|
t.Fatalf("write media: %v", err)
|
|
}
|
|
|
|
libID := mustExec(t, db, `INSERT INTO libraries (kind, name, root_path) VALUES ('video', 'Movies', ?)`, root)
|
|
itemID := mustExec(t, db, `
|
|
INSERT INTO media_items (library_id, kind, path, title, sort_title, year)
|
|
VALUES (?, 'video', 'movie.mp4', 'A Movie', 'a movie', 1999)`, libID)
|
|
fileID := mustExec(t, db, `
|
|
INSERT INTO media_files (item_id, path, size, mtime, container, duration_ms,
|
|
vcodec, acodec, width, height)
|
|
VALUES (?, 'movie.mp4', 20, '1700000000000000000', 'mp4', 5000, 'h264', 'aac', 640, 360)`, itemID)
|
|
|
|
// Three users: an admin, a non-admin granted the library, and a non-admin
|
|
// with no grants at all.
|
|
hash, err := auth.HashPassword("pw")
|
|
if err != nil {
|
|
t.Fatalf("hash: %v", err)
|
|
}
|
|
adminID := mustExec(t, db, `INSERT INTO users (username, display_name, password_hash, is_admin) VALUES ('admin','Admin',?,1)`, hash)
|
|
grantedID := mustExec(t, db, `INSERT INTO users (username, display_name, password_hash, is_admin) VALUES ('granted','Granted',?,0)`, hash)
|
|
noneID := mustExec(t, db, `INSERT INTO users (username, display_name, password_hash, is_admin) VALUES ('none','None',?,0)`, hash)
|
|
mustExec(t, db, `INSERT INTO library_grants (user_id, library_id, can_read) VALUES (?, ?, 1)`, grantedID, libID)
|
|
|
|
token := func(userID int64) string {
|
|
secret, err := apitoken.Mint(ctx, db, userID, apitoken.KindJellyfin, "test")
|
|
if err != nil {
|
|
t.Fatalf("mint: %v", err)
|
|
}
|
|
return secret
|
|
}
|
|
|
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
authn := auth.NewAuthenticator(db, "vellum_session", time.Hour, false, log)
|
|
mux := http.NewServeMux()
|
|
NewHandlers(db, log, authn).Register(mux)
|
|
srv := httptest.NewServer(mux)
|
|
t.Cleanup(srv.Close)
|
|
|
|
return &fixture{
|
|
srv: srv, db: db, itemID: itemID, fileID: fileID, libID: libID,
|
|
admin: token(adminID), granted: token(grantedID), none: token(noneID),
|
|
}
|
|
}
|
|
|
|
func mustExec(t *testing.T, db *sql.DB, q string, args ...any) int64 {
|
|
t.Helper()
|
|
res, err := db.Exec(q, args...)
|
|
if err != nil {
|
|
t.Fatalf("exec %s: %v", q, err)
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return id
|
|
}
|
|
|
|
// do issues a request with an optional token supplied the given way.
|
|
func (f *fixture) do(t *testing.T, method, path, headerName, token string) (*http.Response, string) {
|
|
t.Helper()
|
|
req, err := http.NewRequest(method, f.srv.URL+path, nil)
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
if headerName != "" && token != "" {
|
|
if headerName == "query" {
|
|
req.URL.RawQuery = "api_key=" + token
|
|
} else {
|
|
req.Header.Set(headerName, token)
|
|
}
|
|
}
|
|
resp, err := f.srv.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatalf("do: %v", err)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
_ = resp.Body.Close()
|
|
return resp, string(body)
|
|
}
|
|
|
|
// ── auth surfaces ───────────────────────────────────────────────────────
|
|
|
|
// All three credential forms must work. The query form matters most: <video>
|
|
// and <audio> elements cannot set headers, so a client that can only use
|
|
// ?api_key= must still authenticate.
|
|
func TestAuthAcceptsEveryCredentialForm(t *testing.T) {
|
|
f := newFixture(t)
|
|
for _, tc := range []struct{ name, header, token string }{
|
|
{"X-Emby-Token", "X-Emby-Token", f.admin},
|
|
{"X-MediaBrowser-Token", "X-MediaBrowser-Token", f.admin},
|
|
{"api_key query", "query", f.admin},
|
|
{"MediaBrowser header", "Authorization", `MediaBrowser Token="` + f.admin + `"`},
|
|
} {
|
|
resp, _ := f.do(t, "GET", "/Users/Me", tc.header, tc.token)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("%s: status = %d, want 200", tc.name, resp.StatusCode)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAuthRejectsMissingAndBadTokens(t *testing.T) {
|
|
f := newFixture(t)
|
|
for _, tc := range []struct{ name, header, token string }{
|
|
{"no token", "", ""},
|
|
{"garbage token", "X-Emby-Token", "deadbeef"},
|
|
} {
|
|
resp, _ := f.do(t, "GET", "/Users/Me", tc.header, tc.token)
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("%s: status = %d, want 401", tc.name, resp.StatusCode)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── the security property ───────────────────────────────────────────────
|
|
|
|
// A user with no grants must see nothing, and an item they cannot read must be
|
|
// INDISTINGUISHABLE from one that does not exist. A 403 would confirm the
|
|
// library is there, which leaks its existence.
|
|
func TestGrantScopingIsIndistinguishableFromAbsent(t *testing.T) {
|
|
f := newFixture(t)
|
|
|
|
// Empty listing for a user with no grants.
|
|
resp, body := f.do(t, "GET", "/Items?Recursive=true", "X-Emby-Token", f.none)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("no-grant /Items status = %d", resp.StatusCode)
|
|
}
|
|
var listing struct{ TotalRecordCount int }
|
|
if err := json.Unmarshal([]byte(body), &listing); err != nil {
|
|
t.Fatalf("decode listing: %v (body %s)", err, body)
|
|
}
|
|
if listing.TotalRecordCount != 0 {
|
|
t.Errorf("no-grant user sees %d items, want 0", listing.TotalRecordCount)
|
|
}
|
|
|
|
// Forbidden item.
|
|
unreadable := dto.ItemID(f.itemID)
|
|
respForbidden, bodyForbidden := f.do(t, "GET", "/Items/"+unreadable, "X-Emby-Token", f.none)
|
|
// Absent item.
|
|
respAbsent, bodyAbsent := f.do(t, "GET", "/Items/"+dto.ItemID(999999), "X-Emby-Token", f.none)
|
|
|
|
if respForbidden.StatusCode != http.StatusNotFound {
|
|
t.Errorf("unreadable item status = %d, want 404", respForbidden.StatusCode)
|
|
}
|
|
if respForbidden.StatusCode != respAbsent.StatusCode {
|
|
t.Errorf("unreadable=%d absent=%d: the difference leaks existence",
|
|
respForbidden.StatusCode, respAbsent.StatusCode)
|
|
}
|
|
if bodyForbidden != bodyAbsent {
|
|
t.Errorf("bodies differ and leak existence:\n forbidden=%s\n absent=%s",
|
|
bodyForbidden, bodyAbsent)
|
|
}
|
|
|
|
// Positive control: the granted user does get it, and so does the admin.
|
|
for _, tc := range []struct{ name, token string }{{"granted", f.granted}, {"admin", f.admin}} {
|
|
resp, _ := f.do(t, "GET", "/Items/"+unreadable, "X-Emby-Token", tc.token)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("%s user status = %d, want 200", tc.name, resp.StatusCode)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── the transcode refusal ───────────────────────────────────────────────
|
|
|
|
// Vellum never transcodes. A client that disables BOTH direct play and direct
|
|
// stream is asking for a transcode, and must be told so rather than handed a
|
|
// TranscodingUrl that will never resolve.
|
|
func TestTranscodeDemandIsRefused(t *testing.T) {
|
|
f := newFixture(t)
|
|
id := dto.ItemID(f.itemID)
|
|
|
|
resp, body := f.do(t, "GET",
|
|
"/Items/"+id+"/PlaybackInfo?EnableDirectPlay=false&EnableDirectStream=false",
|
|
"X-Emby-Token", f.admin)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
var pi struct {
|
|
ErrorCode string `json:"ErrorCode"`
|
|
MediaSources []struct {
|
|
SupportsDirectPlay bool `json:"SupportsDirectPlay"`
|
|
SupportsDirectStream bool `json:"SupportsDirectStream"`
|
|
SupportsTranscoding bool `json:"SupportsTranscoding"`
|
|
} `json:"MediaSources"`
|
|
}
|
|
if err := json.Unmarshal([]byte(body), &pi); err != nil {
|
|
t.Fatalf("decode: %v (body %s)", err, body)
|
|
}
|
|
if pi.ErrorCode != "NoCompatibleStream" {
|
|
t.Errorf("ErrorCode = %q, want NoCompatibleStream", pi.ErrorCode)
|
|
}
|
|
|
|
// And the normal case must advertise direct play with transcoding OFF.
|
|
resp, body = f.do(t, "GET", "/Items/"+id+"/PlaybackInfo", "X-Emby-Token", f.admin)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("playbackinfo status = %d", resp.StatusCode)
|
|
}
|
|
if err := json.Unmarshal([]byte(body), &pi); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(pi.MediaSources) == 0 {
|
|
t.Fatal("no MediaSources")
|
|
}
|
|
ms := pi.MediaSources[0]
|
|
if !ms.SupportsDirectPlay || !ms.SupportsDirectStream {
|
|
t.Errorf("direct play/stream = %v/%v, want true/true", ms.SupportsDirectPlay, ms.SupportsDirectStream)
|
|
}
|
|
if ms.SupportsTranscoding {
|
|
t.Error("SupportsTranscoding is TRUE; this server cannot transcode and a client will wait forever")
|
|
}
|
|
}
|
|
|
|
// ── streaming ───────────────────────────────────────────────────────────
|
|
|
|
// The stream must work with ONLY ?api_key= (media elements cannot set headers)
|
|
// and must honour Range, or seeking breaks.
|
|
func TestStreamWithAPIKeyHonoursRange(t *testing.T) {
|
|
f := newFixture(t)
|
|
req, err := http.NewRequest("GET", f.srv.URL+"/Videos/"+dto.ItemID(f.itemID)+"/stream?api_key="+f.admin, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req.Header.Set("Range", "bytes=0-9")
|
|
resp, err := f.srv.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusPartialContent {
|
|
t.Fatalf("status = %d, want 206", resp.StatusCode)
|
|
}
|
|
if cr := resp.Header.Get("Content-Range"); cr == "" {
|
|
t.Error("no Content-Range on a 206")
|
|
}
|
|
got, _ := io.ReadAll(resp.Body)
|
|
if string(got) != "0123456789" {
|
|
t.Errorf("body = %q, want the first ten bytes", got)
|
|
}
|
|
}
|
|
|
|
// A token must not stream a file it cannot read.
|
|
func TestStreamRefusesUnreadableItem(t *testing.T) {
|
|
f := newFixture(t)
|
|
resp, _ := f.do(t, "GET", "/Videos/"+dto.ItemID(f.itemID)+"/stream", "X-Emby-Token", f.none)
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Errorf("no-grant stream status = %d, want 404", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// ── authenticate ────────────────────────────────────────────────────────
|
|
|
|
// postLogin calls /Users/AuthenticateByName and returns the status code.
|
|
func (f *fixture) postLogin(t *testing.T, username, pw string) int {
|
|
t.Helper()
|
|
req, err := http.NewRequest("POST", f.srv.URL+"/Users/AuthenticateByName",
|
|
strings.NewReader(`{"Username":"`+username+`","Pw":"`+pw+`"}`))
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := f.srv.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatalf("do: %v", err)
|
|
}
|
|
_ = resp.Body.Close()
|
|
return resp.StatusCode
|
|
}
|
|
|
|
// This surface must share the login limiter with the web login, or a Jellyfin
|
|
// client is an unthrottled way to guess the same password.
|
|
func TestAuthenticateByNameIsRateLimited(t *testing.T) {
|
|
f := newFixture(t)
|
|
|
|
for i := 1; i <= 10; i++ {
|
|
if code := f.postLogin(t, "admin", "wrong"); code != http.StatusUnauthorized {
|
|
t.Fatalf("attempt %d: status = %d, want 401", i, code)
|
|
}
|
|
}
|
|
if code := f.postLogin(t, "admin", "wrong"); code != http.StatusTooManyRequests {
|
|
t.Errorf("11th attempt: status = %d, want 429", code)
|
|
}
|
|
// The window is per ip and username: another account still logs in.
|
|
if code := f.postLogin(t, "granted", "pw"); code != http.StatusOK {
|
|
t.Errorf("another user: status = %d, want 200", code)
|
|
}
|
|
}
|
|
|
|
// A correct password clears the window, so a user who fumbled a few guesses is
|
|
// not locked out of their own account for the rest of it.
|
|
func TestAuthenticateByNameSuccessResetsTheWindow(t *testing.T) {
|
|
f := newFixture(t)
|
|
|
|
for i := 1; i <= 5; i++ {
|
|
if code := f.postLogin(t, "admin", "wrong"); code != http.StatusUnauthorized {
|
|
t.Fatalf("attempt %d: status = %d, want 401", i, code)
|
|
}
|
|
}
|
|
if code := f.postLogin(t, "admin", "pw"); code != http.StatusOK {
|
|
t.Fatalf("login after failures: status = %d, want 200", code)
|
|
}
|
|
// Had the success not cleared the counter, the sixth of these would be 429.
|
|
for i := 1; i <= 10; i++ {
|
|
if code := f.postLogin(t, "admin", "wrong"); code != http.StatusUnauthorized {
|
|
t.Fatalf("post-reset attempt %d: status = %d, want 401", i, code)
|
|
}
|
|
}
|
|
if code := f.postLogin(t, "admin", "wrong"); code != http.StatusTooManyRequests {
|
|
t.Errorf("11th post-reset attempt: status = %d, want 429", code)
|
|
}
|
|
}
|
|
|
|
func TestAuthenticateByName(t *testing.T) {
|
|
f := newFixture(t)
|
|
|
|
// Good credentials mint a token that then works.
|
|
req, _ := http.NewRequest("POST", f.srv.URL+"/Users/AuthenticateByName",
|
|
strings.NewReader(`{"Username":"admin","Pw":"pw"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := f.srv.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
_ = resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d body=%s", resp.StatusCode, body)
|
|
}
|
|
var out struct {
|
|
AccessToken string
|
|
User struct{ Name string }
|
|
}
|
|
if err := json.Unmarshal(body, &out); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if out.AccessToken == "" {
|
|
t.Fatal("no AccessToken returned")
|
|
}
|
|
if out.User.Name == "" {
|
|
t.Error("no User in the response")
|
|
}
|
|
if resp, _ := f.do(t, "GET", "/Users/Me", "X-Emby-Token", out.AccessToken); resp.StatusCode != http.StatusOK {
|
|
t.Errorf("minted token does not authenticate: %d", resp.StatusCode)
|
|
}
|
|
|
|
// Bad credentials must not mint anything.
|
|
req, _ = http.NewRequest("POST", f.srv.URL+"/Users/AuthenticateByName",
|
|
strings.NewReader(`{"Username":"admin","Pw":"wrong"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err = f.srv.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, _ = io.ReadAll(resp.Body)
|
|
_ = resp.Body.Close()
|
|
if strings.Contains(string(body), "AccessToken") {
|
|
t.Errorf("bad credentials returned a token: %s", body)
|
|
}
|
|
}
|
|
|
|
// Discovery must work with no credential at all, or a client cannot find the
|
|
// server to log in to.
|
|
func TestSystemInfoPublicNeedsNoAuth(t *testing.T) {
|
|
f := newFixture(t)
|
|
resp, body := f.do(t, "GET", "/System/Info/Public", "", "")
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
var info struct{ ServerName, Version, ProductName string }
|
|
if err := json.Unmarshal([]byte(body), &info); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if info.ServerName == "" || info.Version == "" {
|
|
t.Errorf("incomplete discovery response: %s", body)
|
|
}
|
|
}
|