Skip to content

Cross-library file exfiltration via unscoped bulk download endpoint

Moderate
advplyr published GHSA-6rvg-w3f5-9gq5 Apr 24, 2026

Package

audiobookshelf

Affected versions

v2.32.1

Patched versions

v2.32.2

Description

Summary

The GET /api/libraries/:id/download endpoint validates that the requesting user has access to the library specified in the URL path, but fetches downloadable items solely by attacker-provided IDs without constraining them to that library. An authenticated user with download permission and access to any one library can exfiltrate the full file contents of items belonging to any other library, including libraries they are explicitly denied access to.

Severity

Medium (CVSS 3.1: 6.5)

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

  • Attack Vector: Network — the endpoint is a standard HTTP API route
  • Attack Complexity: Low — exploitation requires a single crafted request; no race conditions or special configuration
  • Privileges Required: Low — requires an authenticated user with canDownload permission and access to at least one library
  • User Interaction: None
  • Scope: Unchanged — the impact stays within the audiobookshelf application's authorization boundary
  • Confidentiality Impact: High — full file contents (audiobooks, podcasts, ebooks) from any library can be exfiltrated
  • Integrity Impact: None — the endpoint is read-only
  • Availability Impact: None — no denial of service

Affected Component

  • server/controllers/LibraryController.jsdownloadMultiple (lines 1422–1459)

CWE

  • CWE-863: Incorrect Authorization

Description

Missing library scope in bulk download query

The downloadMultiple handler accepts a comma-separated list of library item IDs via the ids query parameter and fetches them from the database without constraining the query to the library specified in the URL path:

// server/controllers/LibraryController.js:1433-1440
const itemIds = req.query.ids.split(',')

const libraryItems = await Database.libraryItemModel.findAll({
  attributes: ['id', 'libraryId', 'path', 'isFile'],
  where: {
    id: itemIds
    // Missing: libraryId: req.library.id
  }
})

The LibraryItem Sequelize model has no defaultScope, beforeFind hook, or any other data-layer mechanism that would automatically restrict queries by libraryId. The Database.libraryItemModel getter returns the raw Sequelize model without any wrapper or proxy.

Inconsistency with other endpoints in the same controller

The codebase is aware of the need for library scoping — nearly every other method in LibraryController that queries library items includes a libraryId filter. For example:

removeAllMetadataFiles (line 1338):

const libraryItemsWithMetadata = await Database.libraryItemModel.findAll({
  attributes: ['id', 'libraryFiles'],
  where: [
    {
      libraryId: req.library.id  // Properly scoped
    },
    // ...
  ]
})

removeLibraryItemsWithIssues (line 656):

const libraryItemsWithIssues = await Database.libraryItemModel.findAll({
  where: {
    libraryId: req.library.id,  // Properly scoped
    // ...
  }
})

Single-item download (LibraryItemController.middleware, line 1177):

if (!req.user.checkCanAccessLibraryItem(req.libraryItem)) {
  return res.sendStatus(403)  // Checks libraryId via checkCanAccessLibrary()
}

The downloadMultiple endpoint is the only bulk operation on library items that omits library scoping.

Execution chain

  1. Attacker authenticates and obtains a valid session/token (requires canDownload permission and access to at least one library)
  2. Attacker sends GET /api/libraries/<accessible-library-id>/download?ids=<target-item-id-from-other-library>
  3. LibraryController.middleware runs: checkCanAccessLibrary(req.params.id) passes because the URL contains a library the attacker can access
  4. downloadMultiple runs: req.user.canDownload passes
  5. The database query fetches items by ID only — the target item from the inaccessible library is returned
  6. zipHelpers.zipDirectoriesPipe reads the target item's filesystem path and streams it as a ZIP to the attacker
  7. The attacker receives the full file contents of the item from the library they cannot access

Proof of Concept

# Alice has access to library 11111111-... but NOT to the library containing item 44444444-...
curl -s -H 'Authorization: Bearer $ALICE_TOKEN' \
  'http://127.0.0.1:3891/audiobookshelf/api/libraries/11111111-1111-4111-8111-111111111111/download?ids=44444444-4444-4444-8444-444444444444' \
  -o leak.zip

unzip -l leak.zip
# Shows files from the restricted library

unzip -p leak.zip secret.mp3
# Extracts the audio file from the restricted library

Impact

  • Confidentiality breach: Any authenticated user with download permission can exfiltrate the full file contents (audiobooks, podcasts, ebooks, metadata) of items from libraries they are explicitly denied access to
  • Access control bypass: The per-library permission model (librariesAccessible) is rendered ineffective for bulk downloads
  • Data exfiltration at scale: Multiple item IDs can be provided in a single request, enabling bulk exfiltration

Recommended Remediation

Option 1: Add libraryId constraint to the database query (preferred)

Add libraryId: req.library.id to the where clause so the query only returns items belonging to the authorized library:

// server/controllers/LibraryController.js — downloadMultiple
const libraryItems = await Database.libraryItemModel.findAll({
  attributes: ['id', 'libraryId', 'path', 'isFile'],
  where: {
    id: itemIds,
    libraryId: req.library.id  // Scope to the authorized library
  }
})

This is the lowest-layer fix and is consistent with how every other method in the controller scopes its queries.

Option 2: Validate each item's library membership after fetching

If there is a reason to keep the query broad, filter results after fetching and optionally log the discrepancy:

const libraryItems = await Database.libraryItemModel.findAll({
  attributes: ['id', 'libraryId', 'path', 'isFile'],
  where: {
    id: itemIds
  }
})

const authorizedItems = libraryItems.filter((li) => li.libraryId === req.library.id)
if (authorizedItems.length < libraryItems.length) {
  Logger.warn(`[LibraryController] User "${req.user.username}" attempted to download items from unauthorized libraries`)
}

Option 1 is preferred as it prevents unauthorized data from ever being loaded from the database.

Credit

This vulnerability was discovered and reported by bugbunny.ai.

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
None
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

CVE ID

CVE-2026-42883

Weaknesses

No CWEs

Credits