Compare commits

...
Author SHA1 Message Date
QuickandClaude Opus 4.8 5318d767f1 fix(audiobooks): gate ABS RSS feed download surface on the same privilege
Adversarial review found that the /file/{ino} download gate was
bypassable through the ABS RSS feed routes: any authenticated user with
item access could POST /api/feeds/item/{itemId}/open to mint a public
feed, then GET the unauthenticated /feed/{slug}/file/{ino} enclosure to
download the audiobook bytes — defeating the new handleFileStream gate.

Close it with two defense layers, both reusing the DownloadPolicy:

- handleOpenItemFeed now returns 403 when the account may not download,
  so a restricted user cannot create a public-download feed in the first
  place (the gate point the issue called out).
- handlePublicFeed and handlePublicFeedFile re-check the feed OWNER's
  current download privilege and fail closed (404, hiding the feed) when
  it is revoked or the resolver errors — so feeds created while permitted
  stop serving once permission is withdrawn, mirroring the native
  download service's re-check-on-serve behavior.

Adds regression tests covering feed creation denial, the happy path, and
fail-closed XML + enclosure serving after revocation / on resolver error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:40:50 -04:00
QuickandClaude Opus 4.8 6128ef14e6 fix(audiobooks): enforce download privilege on ABS file routes (#141)
The Audiobookshelf-compat layer ignored the per-account download
privilege (models.User.DownloadAllowed). Restricted users could both see
the offline-save UI in ABS clients and successfully download audiobook
files via GET /api/items/{id}/file/{ino}[/download].

Two independent gaps are closed:

1. handleFileStream now consults a new abs.DownloadPolicy dependency and
   returns 403 when the account may not download. Both /file/{ino} and
   the /download variant are gated; the check fails closed on a resolver
   error. Streaming is unaffected — ABS playback uses session-scoped
   public-track URLs, not the file route.

2. The login envelope advertises the real "download" permission instead
   of a hardcoded true, so compliant clients hide the offline-save UI for
   restricted users. The other hardcoded flags (update/delete/upload/
   accessExplicitContent) are left as-is with a comment: the compat layer
   does not implement those mutations, so deriving them is out of scope.

ABSDownloadPolicy mirrors the native download service
(internal/download/service.go): it loads the user by ID and reports
DownloadAllowed, keeping the privilege decision in one place rather than
re-deriving it from the access filter. It reuses the same user repository
already wired for the ABS access resolver, so no new plumbing is added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:35:38 -04:00
10 changed files with 439 additions and 2 deletions
+1
View File
@@ -1889,6 +1889,7 @@ func main() {
Pool: deps.DB,
},
AccessResolver: audiobooks.NewABSAccessResolver(absUserRepo, userStoreProvider),
DownloadPolicy: audiobooks.NewABSDownloadPolicy(absUserRepo),
Recs: recommendations.NewRepo(deps.DB),
Detail: absDetailSvc,
SessionMgr: sessionMgr,
+10
View File
@@ -59,6 +59,16 @@ func (h *Handler) handleFileStream(w http.ResponseWriter, r *http.Request) {
return
}
// Enforce the account's download privilege. Both /file/{ino} variants are
// the offline-save surface (streaming uses the session-scoped public-track
// route), so a restricted user must be hard-gated here — mirrors the native
// download path's models.User.DownloadAllowed check. Fail closed: a
// resolution error denies rather than serving the file.
if ok, err := h.downloadAllowed(r.Context(), a.UserID); err != nil || !ok {
http.Error(w, "downloads are not permitted for this account", http.StatusForbidden)
return
}
contentID := chi.URLParam(r, "libraryItemId")
inoStr := chi.URLParam(r, "ino")
@@ -0,0 +1,146 @@
package abs
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/models"
)
// fakeDownloadPolicy is an in-memory abs.DownloadPolicy for the file-route
// download-privilege tests.
type fakeDownloadPolicy struct {
allowed map[string]bool
err error
}
func (f *fakeDownloadPolicy) DownloadAllowed(_ context.Context, userID string) (bool, error) {
if f.err != nil {
return false, f.err
}
return f.allowed[userID], nil
}
// dispatchFileStream invokes handleFileStream with the chi route params and ABS
// auth context the bearerAuth middleware would normally inject.
func dispatchFileStream(h *Handler, path, contentID, ino, userID string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("libraryItemId", contentID)
rctx.URLParams.Add("ino", ino)
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
ctx = context.WithValue(ctx, ctxKey{}, ctxAuth{UserID: userID})
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
h.handleFileStream(rec, req)
return rec
}
// newFileStreamHandler builds a Handler whose media store holds one audio file
// for contentID, with the supplied download policy.
func newFileStreamHandler(t *testing.T, contentID string, policy DownloadPolicy) *Handler {
t.Helper()
mediaStore := &filesMediaStore{
contentID: contentID,
files: []*models.MediaFile{{ID: 1, FilePath: makeTempAudio(t)}},
}
return New(Dependencies{MediaStore: mediaStore, DownloadPolicy: policy})
}
// TestFileStream_DeniesWhenDownloadDisabled is the regression guard for issue
// #141: a restricted user (DownloadAllowed=false) must get 403 from both the
// bare /file/{ino} route and the /download variant, and the bytes must not be
// served.
func TestFileStream_DeniesWhenDownloadDisabled(t *testing.T) {
policy := &fakeDownloadPolicy{allowed: map[string]bool{"1": false}}
h := newFileStreamHandler(t, "book-1", policy)
for _, path := range []string{
"/api/items/book-1/file/0",
"/api/items/book-1/file/0/download",
} {
rec := dispatchFileStream(h, path, "book-1", "0", "1")
if rec.Code != http.StatusForbidden {
t.Errorf("path %s: status = %d, want 403; body=%s", path, rec.Code, rec.Body.String())
}
}
}
// TestFileStream_AllowsWhenDownloadEnabled confirms the gate does not regress
// the happy path: a permitted user still receives the file.
func TestFileStream_AllowsWhenDownloadEnabled(t *testing.T) {
policy := &fakeDownloadPolicy{allowed: map[string]bool{"1": true}}
h := newFileStreamHandler(t, "book-1", policy)
rec := dispatchFileStream(h, "/api/items/book-1/file/0/download", "book-1", "0", "1")
if rec.Code != http.StatusOK && rec.Code != http.StatusPartialContent {
t.Fatalf("status = %d, want 200/206; body=%s", rec.Code, rec.Body.String())
}
if rec.Body.Len() == 0 {
t.Error("expected audio bytes for a permitted download")
}
}
// TestFileStream_FailsClosedOnPolicyError verifies that a resolver error denies
// the download (fail closed) rather than serving the file.
func TestFileStream_FailsClosedOnPolicyError(t *testing.T) {
policy := &fakeDownloadPolicy{err: errors.New("user store unavailable")}
h := newFileStreamHandler(t, "book-1", policy)
rec := dispatchFileStream(h, "/api/items/book-1/file/0/download", "book-1", "0", "1")
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 on resolver error; body=%s", rec.Code, rec.Body.String())
}
}
// TestFileStream_NilPolicyAllows documents that when no DownloadPolicy is wired
// (non-production), the handler preserves its prior allow-everything behavior.
func TestFileStream_NilPolicyAllows(t *testing.T) {
h := newFileStreamHandler(t, "book-1", nil)
rec := dispatchFileStream(h, "/api/items/book-1/file/0/download", "book-1", "0", "1")
if rec.Code != http.StatusOK && rec.Code != http.StatusPartialContent {
t.Fatalf("status = %d, want 200/206 with nil policy; body=%s", rec.Code, rec.Body.String())
}
}
// TestLoginEnvelope_DownloadPermissionReflectsPolicy asserts the login envelope
// advertises the real download privilege so compliant clients hide the
// offline-save UI for restricted users (issue #141, gap 2).
func TestLoginEnvelope_DownloadPermissionReflectsPolicy(t *testing.T) {
cases := []struct {
name string
allowed bool
}{
{"allowed", true},
{"denied", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h := New(Dependencies{
MediaStore: &noopMediaStore{},
DownloadPolicy: &fakeDownloadPolicy{allowed: map[string]bool{"7": tc.allowed}},
})
req := httptest.NewRequest(http.MethodPost, "/login", nil)
env := h.loginEnvelope(req, time.Now(), "7", "Reader", "a", "r")
user, ok := env["user"].(map[string]any)
if !ok {
t.Fatalf("user is not a map: %T", env["user"])
}
perms, ok := user["permissions"].(map[string]any)
if !ok {
t.Fatalf("permissions is not a map: %T", user["permissions"])
}
if got := perms["download"]; got != tc.allowed {
t.Errorf("permissions.download = %v, want %v", got, tc.allowed)
}
})
}
}
+33 -1
View File
@@ -195,6 +195,22 @@ type Recommender interface {
Similar(ctx context.Context, contentID string, limit int) ([]string, error)
}
// DownloadPolicy reports whether an ABS-authenticated account may download
// (offline-save) media. It mirrors the native download path, which loads the
// user and rejects with download.ErrDownloadNotAllowed when
// models.User.DownloadAllowed is false (see internal/download/service.go).
//
// The ABS file routes (/items/{id}/file/{ino} and the /download variant) are
// the offline-save surface; streaming uses session-scoped track URLs and is
// unaffected. May be nil in non-production wiring (e.g. tests), in which case
// the handler preserves its prior allow-everything behavior — production wires
// a real resolver so restricted users are gated.
type DownloadPolicy interface {
// DownloadAllowed reports whether the account identified by the ABS user
// ID (the numeric users.id, as a string) may download media.
DownloadAllowed(ctx context.Context, userID string) (bool, error)
}
// ---------------------------------------------------------------------------
// Config provider
// ---------------------------------------------------------------------------
@@ -226,7 +242,11 @@ type Dependencies struct {
Config ConfigProvider
Publisher EventPublisher // may be nil
Recommender Recommender // may be nil
LoginLimiter *LoginLimiter // may be nil — one is created if absent
// DownloadPolicy gates the offline-save file routes on the account's
// download privilege. May be nil; when unset the handler allows downloads
// (preserving prior behavior in non-production wiring).
DownloadPolicy DownloadPolicy
LoginLimiter *LoginLimiter // may be nil — one is created if absent
// InstallID returns the current plugin install ID for building
// host-proxy-routable URLs. Defaults to "silo.audiobooks" when nil.
InstallID func() string
@@ -571,6 +591,18 @@ func (h *Handler) accessFilterFromRequest(r *http.Request) (catalog.AccessFilter
return filter, true, err
}
// downloadAllowed reports whether the given ABS user may download (offline-save)
// media. When no DownloadPolicy is wired (non-production), it allows downloads
// to preserve the handler's prior behavior; production always supplies a real
// resolver. Errors from the resolver propagate to the caller, which fails
// closed (the file routes treat a resolution error as a denial).
func (h *Handler) downloadAllowed(ctx context.Context, userID string) (bool, error) {
if h.deps.DownloadPolicy == nil {
return true, nil
}
return h.deps.DownloadPolicy.DownloadAllowed(ctx, userID)
}
func emptyAccessFilter() catalog.AccessFilter {
return catalog.AccessFilter{}
}
+13 -1
View File
@@ -230,6 +230,18 @@ func (h *Handler) loginEnvelope(
nowMs := now.UnixMilli()
// Advertise the real download privilege so compliant ABS clients hide the
// offline-save UI for restricted users (the file routes enforce it
// server-side regardless). On a resolver error, fail closed to false: the
// server-side gate is the real guard, and hiding the UI when uncertain is
// the safe default. The remaining flags (update/delete/upload/
// accessExplicitContent) stay hardcoded true because this compat layer does
// not implement those mutations — deriving them is out of scope for #141.
downloadPerm, err := h.downloadAllowed(r.Context(), userID)
if err != nil {
downloadPerm = false
}
user := map[string]any{
"id": userID,
"username": name,
@@ -246,7 +258,7 @@ func (h *Handler) loginEnvelope(
"lastSeen": nowMs,
"createdAt": nowMs,
"permissions": map[string]any{
"download": true,
"download": downloadPerm,
"update": true,
"delete": true,
"upload": true,
@@ -0,0 +1,109 @@
package abs
import (
"context"
"errors"
"net/http"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
var errPolicyUnavailable = errors.New("download policy unavailable")
// newFeedsHarnessWithPolicy builds the RSS feeds harness with an explicit
// DownloadPolicy so the download-privilege gate can be exercised.
func newFeedsHarnessWithPolicy(t *testing.T, policy DownloadPolicy, knownItems ...string) (*Handler, *memRSSFeedStore) {
t.Helper()
known := map[string]*models.MediaItem{}
for _, id := range knownItems {
known[id] = nil
}
store := newMemRSSFeedStore()
h := New(Dependencies{
MediaStore: &stubMediaStore{known: known},
RSSFeedStore: store,
DownloadPolicy: policy,
})
return h, store
}
// TestFeed_Open_DeniedWhenDownloadDisabled is part of the issue #141 fix: an
// RSS feed mints public, unauthenticated /feed/{slug}/file/{ino} enclosures, so
// a restricted user must not be able to create one (it would otherwise bypass
// the handleFileStream download gate).
func TestFeed_Open_DeniedWhenDownloadDisabled(t *testing.T) {
policy := &fakeDownloadPolicy{allowed: map[string]bool{"1": false}}
h, store := newFeedsHarnessWithPolicy(t, policy, "book-1")
rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open",
map[string]string{"itemId": "book-1"}, []byte(`{}`), "1", "", h.handleOpenItemFeed)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body=%s", rec.Code, rec.Body.String())
}
if n := len(store.rows); n != 0 {
t.Errorf("a feed was persisted (%d rows) despite the download gate", n)
}
}
// TestFeed_Open_AllowedWhenDownloadEnabled confirms the gate does not regress
// the happy path.
func TestFeed_Open_AllowedWhenDownloadEnabled(t *testing.T) {
policy := &fakeDownloadPolicy{allowed: map[string]bool{"1": true}}
h, _ := newFeedsHarnessWithPolicy(t, policy, "book-1")
rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open",
map[string]string{"itemId": "book-1"}, []byte(`{}`), "1", "", h.handleOpenItemFeed)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
}
// seedFeed inserts an open feed owned by ownerID directly into the store,
// modeling a feed created while the owner could download.
func seedFeed(t *testing.T, store *memRSSFeedStore, slug, ownerID, itemID string) {
t.Helper()
if err := store.CreateFeed(context.Background(), RSSFeed{
ID: "feed-" + slug,
UserID: ownerID,
LibraryItemID: itemID,
Slug: slug,
}); err != nil {
t.Fatalf("seed feed: %v", err)
}
}
// TestPublicFeed_FailsClosedAfterRevocation verifies that an existing feed
// stops serving once the owner's download privilege is revoked: both the XML
// and the file enclosure must report 404 (hiding the feed) rather than serving.
func TestPublicFeed_FailsClosedAfterRevocation(t *testing.T) {
policy := &fakeDownloadPolicy{allowed: map[string]bool{"1": false}}
h, store := newFeedsHarnessWithPolicy(t, policy, "book-1")
seedFeed(t, store, "revoked-feed", "1", "book-1")
xml := dispatchABSWithParams(http.MethodGet, "/feed/revoked-feed.xml",
map[string]string{"slug": "revoked-feed.xml"}, nil, "", "", h.handlePublicFeed)
if xml.Code != http.StatusNotFound {
t.Errorf("public feed XML status = %d, want 404 after revocation; body=%s", xml.Code, xml.Body.String())
}
file := dispatchABSWithParams(http.MethodGet, "/feed/revoked-feed/file/1",
map[string]string{"slug": "revoked-feed", "ino": "1"}, nil, "", "", h.handlePublicFeedFile)
if file.Code != http.StatusNotFound {
t.Errorf("public feed file status = %d, want 404 after revocation; body=%s", file.Code, file.Body.String())
}
}
// TestPublicFeed_FailsClosedOnPolicyError ensures a resolver error hides the
// feed rather than serving bytes (fail closed).
func TestPublicFeed_FailsClosedOnPolicyError(t *testing.T) {
policy := &fakeDownloadPolicy{err: errPolicyUnavailable}
h, store := newFeedsHarnessWithPolicy(t, policy, "book-1")
seedFeed(t, store, "err-feed", "1", "book-1")
file := dispatchABSWithParams(http.MethodGet, "/feed/err-feed/file/1",
map[string]string{"slug": "err-feed", "ino": "1"}, nil, "", "", h.handlePublicFeedFile)
if file.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404 on resolver error; body=%s", file.Code, file.Body.String())
}
}
@@ -59,6 +59,15 @@ func (h *Handler) handleOpenItemFeed(w http.ResponseWriter, r *http.Request) {
http.Error(w, "feed store unavailable", http.StatusServiceUnavailable)
return
}
// A public RSS feed exposes /feed/{slug}/file/{ino} enclosures that serve
// raw audio bytes without auth (the slug is the capability). That is an
// offline-save surface, so a restricted account must not be able to mint
// one — otherwise the handleFileStream download gate is trivially bypassed.
if ok, err := h.downloadAllowed(r.Context(), a.UserID); err != nil || !ok {
http.Error(w, "downloads are not permitted for this account", http.StatusForbidden)
return
}
itemID := chi.URLParam(r, "itemId")
access, err := h.accessFilterForAuth(r.Context(), a)
if err != nil {
@@ -165,6 +174,12 @@ func (h *Handler) handlePublicFeed(w http.ResponseWriter, r *http.Request) {
http.Error(w, "feed get failed", http.StatusInternalServerError)
return
}
// Mirror handlePublicFeedFile: hide the feed (and its enclosure URLs) once
// the owner's download privilege is gone, failing closed on resolver error.
if ok, derr := h.downloadAllowed(r.Context(), f.UserID); derr != nil || !ok {
http.Error(w, "feed not found", http.StatusNotFound)
return
}
item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), f.LibraryItemID, emptyAccessFilter())
if err != nil || item == nil {
@@ -224,6 +239,15 @@ func (h *Handler) handlePublicFeedFile(w http.ResponseWriter, r *http.Request) {
http.Error(w, "feed get failed", http.StatusInternalServerError)
return
}
// Fail closed if the feed owner's download privilege was revoked after the
// feed was created (or a resolver error occurs): an existing feed must not
// keep serving bytes once the account loses the download permission. Hide
// the feed (404) rather than 403 so the route does not confirm slug
// existence to an unauthenticated caller.
if ok, derr := h.downloadAllowed(r.Context(), f.UserID); derr != nil || !ok {
http.Error(w, "feed not found", http.StatusNotFound)
return
}
inoStr := chi.URLParam(r, "ino")
ino, parseErr := strconv.Atoi(inoStr)
if parseErr != nil {
+37
View File
@@ -49,3 +49,40 @@ func (r *ABSAccessResolver) ResolveABSAccess(ctx context.Context, userID, profil
ProfileID: scope.ProfileID,
}, nil
}
// ABSDownloadPolicy reports whether an ABS-authenticated account may download
// (offline-save) media, backing the abs.DownloadPolicy dependency. It mirrors
// the native download service (internal/download/service.go), which loads the
// user and checks models.User.DownloadAllowed — keeping the privilege decision
// in one place rather than re-deriving it from the access filter.
type ABSDownloadPolicy struct {
users access.UserRepository
}
// NewABSDownloadPolicy constructs the download-privilege resolver. Returns nil
// when no user repository is available; abs.Handler treats a nil policy as
// "allow", so callers must wire a real repository to enforce the gate.
func NewABSDownloadPolicy(users access.UserRepository) *ABSDownloadPolicy {
if users == nil {
return nil
}
return &ABSDownloadPolicy{users: users}
}
// DownloadAllowed loads the account by its numeric ABS user ID and reports its
// download privilege. A malformed ID or a load failure returns an error, which
// the caller fails closed on (denying the download).
func (p *ABSDownloadPolicy) DownloadAllowed(ctx context.Context, userID string) (bool, error) {
if p == nil || p.users == nil {
return true, nil
}
uid, err := strconv.Atoi(userID)
if err != nil {
return false, fmt.Errorf("invalid ABS user id %q: %w", userID, err)
}
user, err := p.users.GetByID(ctx, uid)
if err != nil {
return false, fmt.Errorf("loading user %d: %w", uid, err)
}
return user.DownloadAllowed, nil
}
@@ -0,0 +1,61 @@
package audiobooks
import (
"context"
"errors"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
// fakeUserRepo is a minimal access.UserRepository for ABSDownloadPolicy tests.
type fakeUserRepo struct {
users map[int]*models.User
err error
}
func (f *fakeUserRepo) GetByID(_ context.Context, id int) (*models.User, error) {
if f.err != nil {
return nil, f.err
}
u, ok := f.users[id]
if !ok {
return nil, errors.New("not found")
}
return u, nil
}
func TestABSDownloadPolicy_ReportsUserPrivilege(t *testing.T) {
repo := &fakeUserRepo{users: map[int]*models.User{
1: {ID: 1, DownloadAllowed: true},
2: {ID: 2, DownloadAllowed: false},
}}
p := NewABSDownloadPolicy(repo)
if got, err := p.DownloadAllowed(context.Background(), "1"); err != nil || !got {
t.Errorf("user 1: got (%v, %v), want (true, nil)", got, err)
}
if got, err := p.DownloadAllowed(context.Background(), "2"); err != nil || got {
t.Errorf("user 2: got (%v, %v), want (false, nil)", got, err)
}
}
func TestABSDownloadPolicy_InvalidIDErrors(t *testing.T) {
p := NewABSDownloadPolicy(&fakeUserRepo{users: map[int]*models.User{}})
if _, err := p.DownloadAllowed(context.Background(), "not-a-number"); err == nil {
t.Error("expected error for non-numeric user id")
}
}
func TestABSDownloadPolicy_LoadErrorPropagates(t *testing.T) {
p := NewABSDownloadPolicy(&fakeUserRepo{err: errors.New("db down")})
if _, err := p.DownloadAllowed(context.Background(), "1"); err == nil {
t.Error("expected load error to propagate")
}
}
func TestNewABSDownloadPolicy_NilRepo(t *testing.T) {
if p := NewABSDownloadPolicy(nil); p != nil {
t.Errorf("expected nil policy for nil repo, got %#v", p)
}
}
+5
View File
@@ -46,6 +46,10 @@ type ABSHandlerDeps struct {
Settings catalog.SettingsStore // encrypting decorator in production
Auth absAuthAdapter // see BuildABSHandler below
AccessResolver abs.AccessResolver
// DownloadPolicy gates the offline-save file routes on the account's
// download privilege (models.User.DownloadAllowed). When nil the ABS
// handler allows downloads, so production must supply a real resolver.
DownloadPolicy abs.DownloadPolicy
// Recs is the recommendations repository used to power the
// /items/{id}/similar endpoint via embedding nearest-neighbor search.
// Optional; when nil, ABSRecommender falls back to the shared-genre
@@ -150,6 +154,7 @@ func (s *Service) BuildABSHandler(deps ABSHandlerDeps) *abs.Handler {
TokenStore: tokenStore,
CredValidator: deps.Auth,
AccessResolver: deps.AccessResolver,
DownloadPolicy: deps.DownloadPolicy,
Config: configProvider,
Publisher: nil, // EventPublisher: no-op stub; Socket.io handles realtime
Recommender: buildABSRecommender(deps),