Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,48 @@

All notable changes to Library Manager will be documented in this file.

## [0.9.0-beta.153] - 2026-06-12

### Security
- **#255: Validate and sanitize BookDB response data** — Added `_sanitize_api_response()` to strip null bytes, control characters, HTML tags, and truncate oversized fields from all Skaldleita/BookDB API responses. Prevents path traversal, XSS, and memory issues from malformed responses.
- **#256: Signing nonce and tighter replay window** — Added UUID nonce (`X-LM-Nonce`) to signed request headers for replay attack prevention. Reduced timestamp tolerance from 300s to 120s. Backwards compatible with existing BookDB.
- **#236: SSRF prevention on plugin endpoints** — (from 0.9.0-beta.152)

### Fixed
- **#252: Rate limiter aligned with actual BookDB tiers** — Increased min_delay from 3.6s (1000/hr) to 12.0s (300/hr) to match BookDB's API key tier. Added adaptive backoff from Retry-After headers and specific 429 handling in audio identification.
- **#253: server_notice handled in audio identification** — BookDB's abort/notice signals are now processed in `identify_audio_with_bookdb()` (submit, poll, and result stages), matching existing coverage in `search_bookdb()`.

### Enhanced
- **#254: Differentiated BookDB source trust weighting** — `sl_source` field now maps to distinct source names (`bookdb_audio`, `bookdb_transcript`, `bookdb_scrape`) with graduated weights (65/55/45) instead of treating all BookDB audio results identically.

### Documentation
- **#257: ABS plugin architecture** — Added `docs/ABS-Plugin-Architecture.md` scoping Library Manager as an Audiobookshelf metadata provider plugin.

---

## [0.9.0-beta.152] - 2026-06-12

### Security
- **#219: Library boundary enforcement** on undo/replace/remove file operations
- **#237: XSS fix** — escapeHtml covers all 5 HTML entities including single quotes
- **#236: SSRF prevention** on custom plugin endpoints

### Fixed
- **#220: Atomic status guard** on apply_fix prevents race condition double-renames
- **#221: Worker stop checks** in all 4 legacy batch loops
- **#222-224: Rate limits and circuit breakers** for BookDB contribute, community consensus, Gemini language detect, OpenRouter identify, plus process endpoint re-entrancy guard
- **#225-226: Undo parent mkdir** and path sanitize false positive fix
- **#227: L1 preserves original metadata** on validation failure
- **#228-229: Pipeline stuck prevention** — L3 trust_sl no longer deletes queue entry before L4, AI verify creates history on null drastic check
- **#230: Custom source weights** now actually used in confidence calculations
- **#231: Fixed broken route** in queue multi-edit modal
- **#232: Settings save** now persists sl_trust_mode and enable_isbn_lookup, removed duplicate field
- **#233: Double-click prevention** on all 11 history page action buttons
- **#234: Famous numeric titles** (1984, 2001, etc.) no longer flagged as garbage
- **#235: Confidence scale normalization** handles both 0-1 and 0-100 scales

---

## [0.9.0-beta.151] - 2026-06-05

### Changed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

**Smart Audiobook Library Organizer with Multi-Source Metadata & AI Verification**

[![Version](https://img.shields.io/badge/version-0.9.0--beta.152-blue.svg)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-0.9.0--beta.153-blue.svg)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io-blue.svg)](https://ghcr.io/deucebucket/library-manager)
[![License](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](LICENSE)

Expand Down
2 changes: 1 addition & 1 deletion app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
- Multi-provider AI (Gemini, OpenRouter, Ollama)
"""

APP_VERSION = "0.9.0-beta.152"
APP_VERSION = "0.9.0-beta.153"
GITHUB_REPO = "deucebucket/library-manager" # Your GitHub repo

# Versioning Guide:
Expand Down Expand Up @@ -738,7 +738,7 @@
try:
with open(ERROR_REPORTS_PATH, 'r') as f:
reports = json.load(f)
except:

Check failure on line 741 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (E722)

app.py:741:13: E722 Do not use bare `except`
reports = []

# Add new report (keep last 100 reports to avoid file bloat)
Expand All @@ -762,7 +762,7 @@
try:
with open(ERROR_REPORTS_PATH, 'r') as f:
return json.load(f)
except:

Check failure on line 765 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (E722)

app.py:765:9: E722 Do not use bare `except`
return []
return []

Expand Down Expand Up @@ -1717,7 +1717,7 @@
continue
result = call_gemini(prompt, merged_config)
if result:
logger.info(f"[PROVIDER CHAIN] Success with gemini")

Check failure on line 1720 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (F541)

app.py:1720:33: F541 f-string without any placeholders help: Remove extraneous `f` prefix
return result

elif provider == 'openrouter':
Expand All @@ -1726,13 +1726,13 @@
continue
result = call_openrouter(prompt, merged_config)
if result:
logger.info(f"[PROVIDER CHAIN] Success with openrouter")

Check failure on line 1729 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (F541)

app.py:1729:33: F541 f-string without any placeholders help: Remove extraneous `f` prefix
return result

elif provider == 'ollama':
result = call_ollama(prompt, merged_config)
if result:
logger.info(f"[PROVIDER CHAIN] Success with ollama")

Check failure on line 1735 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (F541)

app.py:1735:33: F541 f-string without any placeholders help: Remove extraneous `f` prefix
return result

else:
Expand Down Expand Up @@ -1834,7 +1834,7 @@
return result
elif result and result.get('transcript'):
# Got transcript but no match - still useful, return for potential AI fallback
logger.info(f"[AUDIO CHAIN] BookDB returned transcript only")

Check failure on line 1837 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (F541)

app.py:1837:37: F541 f-string without any placeholders help: Remove extraneous `f` prefix
return result
elif result is None and attempt < max_retries - 1:
# Connection might be down, wait and retry
Expand Down Expand Up @@ -2166,11 +2166,11 @@
device = "cuda"
# int8 works on all CUDA devices including GTX 1080 (compute 6.1)
# float16 only works on newer GPUs (compute 7.0+)
logger.info(f"[WHISPER] Using CUDA GPU acceleration (10x faster)")

Check failure on line 2169 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (F541)

app.py:2169:29: F541 f-string without any placeholders help: Remove extraneous `f` prefix
else:
logger.info(f"[WHISPER] Using CPU (no CUDA GPU detected)")

Check failure on line 2171 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (F541)

app.py:2171:29: F541 f-string without any placeholders help: Remove extraneous `f` prefix
except ImportError:
logger.info(f"[WHISPER] Using CPU (ctranslate2 not available)")

Check failure on line 2173 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (F541)

app.py:2173:25: F541 f-string without any placeholders help: Remove extraneous `f` prefix

_whisper_model = WhisperModel(model_name, device=device, compute_type=compute_type)
_whisper_model_name = model_name
Expand Down Expand Up @@ -2381,7 +2381,7 @@
if sample_path and os.path.exists(sample_path):
try:
os.unlink(sample_path)
except:

Check failure on line 2384 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (E722)

app.py:2384:13: E722 Do not use bare `except`
pass

return result
Expand Down
34 changes: 34 additions & 0 deletions library_manager/providers/bookdb.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"""

import os
import re
import time
import logging
import subprocess
Expand Down Expand Up @@ -50,6 +51,33 @@ def get_and_clear_server_abort():
_abort_state.notice = None
return notice

def _sanitize_api_response(data, context='bookdb'):
"""Sanitize string fields from API responses to prevent path traversal, XSS, and oversized data."""
if not isinstance(data, dict):
return data

MAX_FIELD_LENGTH = 500
sanitized = {}
string_fields = ('title', 'author', 'author_name', 'narrator', 'series', 'series_name', 'name', 'variant', 'edition')

for key, value in data.items():
if key in string_fields and isinstance(value, str):
original = value
# Strip null bytes and control characters (keep newlines for descriptions)
value = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', value)
# Strip HTML tags
value = re.sub(r'<[^>]+>', '', value)
# Truncate oversized fields
if len(value) > MAX_FIELD_LENGTH:
value = value[:MAX_FIELD_LENGTH]
# Log if sanitization changed the value
if value != original:
logger.warning(f"[{context}] Sanitized '{key}': {original[:100]!r} → {value[:100]!r}")
sanitized[key] = value

return sanitized


# Skaldleita API endpoint (our metadata service, legacy name: BookDB)
BOOKDB_API_URL = "https://bookdb.deucebucket.com" # URL unchanged for backwards compatibility
# Public API key for Library Manager users (no config needed)
Expand Down Expand Up @@ -184,6 +212,7 @@ def search_bookdb(title, author=None, api_key=None, retry_count=0, bookdb_url=No
API_CIRCUIT_BREAKER['bookdb']['failures'] = 0

data = resp.json()
data = _sanitize_api_response(data)

# Issue #208: honor Skaldleita server_notice. Log every notice; on
# action=abort_task, stash in thread-local so the watch-folder worker
Expand Down Expand Up @@ -226,6 +255,9 @@ def search_bookdb(title, author=None, api_key=None, retry_count=0, bookdb_url=No
if not best_book:
best_book = books[0]

if best_book:
best_book = _sanitize_api_response(best_book)

# Build result - handle standalone books (no series) and series books
result = {
'title': best_book.get('title') if best_book else (series.get('name') if series else None),
Expand Down Expand Up @@ -460,6 +492,7 @@ def identify_audio_with_bookdb(audio_file, extract_seconds=90, bookdb_url=None):
'confidence': 'high' if best_match or sl_source == 'database' else 'medium',
'transcript': transcript[:500],
}
result = _sanitize_api_response(result, context='SKALDLEITA')

if result['author'] and result['title']:
logger.info(f"[SKALDLEITA] Identified: {result['author']} - {result['title']}" +
Expand Down Expand Up @@ -626,6 +659,7 @@ def lookup_community_consensus(title, author=None, bookdb_url=None):
__all__ = [
'BOOKDB_API_URL',
'BOOKDB_PUBLIC_KEY',
'_sanitize_api_response',
'get_signed_headers',
'search_bookdb',
'identify_audio_with_bookdb',
Expand Down
2 changes: 2 additions & 0 deletions library_manager/providers/fingerprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import requests

from library_manager.providers.bookdb import _sanitize_api_response
from library_manager.providers.rate_limiter import handle_rate_limit_response

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -157,6 +158,7 @@ def lookup_fingerprint(
# Return book if linked to database, otherwise contributed metadata
result = data.get('book') or data.get('contributed_metadata')
if result:
result = _sanitize_api_response(result, context='FINGERPRINT')
result['confidence'] = data.get('confidence', 0)
title = result.get('title', 'Unknown')
logger.info(f"[FINGERPRINT] Match found: {title} "
Expand Down
Loading