Stop leaking Plex / Jellyfin / Navidrome tokens into app.log
The artwork URL normalizer was logging the full constructed media- server URL on every cover-art lookup at INFO level, including the auth query params (X-Plex-Token / X-Emby-Token / Subsonic t+s+p). Those lines pile up in app.log on disk -- anyone with read access to the log file gains full read access to the user's media server. Also dropped the noisy per-call "Plex/Jellyfin/Navidrome config - base_url: ..., token: ..." INFO lines that fired on every thumbnail. Even the truncated `token[:10]` form is enough partial-known-plaintext to be uncomfortable to leak. - New `_redact_url_secrets` helper masks the values of X-Plex-Token, X-Emby-Token, api_key, apikey, Subsonic t / s / p, generic token / password query params. Regex anchored on `?` or `&` boundary so short keys like `t` don't false-match inside `format=Jpg`. - "Fixed URL: ..." log calls moved from INFO to DEBUG so they don't persist by default, and the URL passed in is run through the redactor first. - Per-call "Plex config - ..." / "Jellyfin config - ..." / "Navidrome config - ..." INFO lines removed entirely. Config inspection has dedicated UI; per-thumbnail spam belongs to no one. - Error-path logging (line 149) also routed through the redactor in case the failing URL had auth params attached. Users with existing app.log files containing the leaked tokens should rotate / wipe the log. Plex tokens can be regenerated by signing out of all devices in Plex settings; Jellyfin api_keys can be revoked from the dashboard; Navidrome users should rotate the account password.
This commit is contained in:
parent
15c41fcc64
commit
2fe1926074
2 changed files with 32 additions and 11 deletions
|
|
@ -28,6 +28,33 @@ __all__ = [
|
|||
logger = _create_logger("metadata.artwork")
|
||||
|
||||
|
||||
# Query-string keys whose values must be masked when a media-server
|
||||
# URL ends up in a log line. Plex uses X-Plex-Token, Jellyfin uses
|
||||
# X-Emby-Token / api_key, Navidrome's Subsonic auth uses t (token) +
|
||||
# s (salt) + p (password fallback). Logs end up persisted to disk —
|
||||
# leaking any of these gives full read access to the user's library.
|
||||
_REDACT_QUERY_KEYS = (
|
||||
'x-plex-token', 'x-emby-token', 'api_key', 'apikey',
|
||||
't', 's', 'p', 'token', 'password',
|
||||
)
|
||||
# Anchor on `?` / `&` (or string start) so short keys like `t` only
|
||||
# match at parameter boundaries — not as a substring of `format=Jpg`.
|
||||
_REDACT_QUERY_RE = re.compile(
|
||||
r'(?i)(?P<lead>^|[?&])(?P<key>' + '|'.join(re.escape(k) for k in _REDACT_QUERY_KEYS) + r')=([^&\s]+)'
|
||||
)
|
||||
|
||||
|
||||
def _redact_url_secrets(url: str | None) -> str:
|
||||
"""Mask sensitive query parameters in a URL so the result is safe
|
||||
to log. Returns ``''`` for None/empty input. Idempotent."""
|
||||
if not url:
|
||||
return ''
|
||||
return _REDACT_QUERY_RE.sub(
|
||||
lambda m: f"{m.group('lead')}{m.group('key')}=***REDACTED***",
|
||||
str(url),
|
||||
)
|
||||
|
||||
|
||||
def normalize_image_url(thumb_url: str | None) -> str | None:
|
||||
"""Convert media-server image URLs into browser-safe URLs."""
|
||||
if not thumb_url:
|
||||
|
|
@ -62,7 +89,6 @@ def normalize_image_url(thumb_url: str | None) -> str | None:
|
|||
plex_config = cfg.get_plex_config()
|
||||
plex_base_url = plex_config.get('base_url', '')
|
||||
plex_token = plex_config.get('token', '')
|
||||
logger.info("Plex config - base_url: %s, token: %s...", plex_base_url, plex_token[:10])
|
||||
|
||||
if plex_base_url and plex_token:
|
||||
# Extract the path from URL
|
||||
|
|
@ -76,18 +102,13 @@ def normalize_image_url(thumb_url: str | None) -> str | None:
|
|||
|
||||
# Construct proper Plex URL with token
|
||||
fixed_url = f"{plex_base_url.rstrip('/')}{path}?X-Plex-Token={plex_token}"
|
||||
logger.info("Fixed URL: %s", fixed_url)
|
||||
logger.debug("Fixed URL: %s", _redact_url_secrets(fixed_url))
|
||||
return _browser_safe_image_url(fixed_url)
|
||||
|
||||
elif active_server == 'jellyfin':
|
||||
jellyfin_config = cfg.get_jellyfin_config()
|
||||
jellyfin_base_url = jellyfin_config.get('base_url', '')
|
||||
jellyfin_token = jellyfin_config.get('api_key', '')
|
||||
logger.info(
|
||||
"Jellyfin config - base_url: %s, token: %s...",
|
||||
jellyfin_base_url,
|
||||
jellyfin_token[:10] if jellyfin_token else 'None',
|
||||
)
|
||||
|
||||
if jellyfin_base_url:
|
||||
# Extract the path from URL
|
||||
|
|
@ -105,7 +126,7 @@ def normalize_image_url(thumb_url: str | None) -> str | None:
|
|||
fixed_url = f"{jellyfin_base_url.rstrip('/')}{path}{separator}X-Emby-Token={jellyfin_token}"
|
||||
else:
|
||||
fixed_url = f"{jellyfin_base_url.rstrip('/')}{path}"
|
||||
logger.info("Fixed URL: %s", fixed_url)
|
||||
logger.debug("Fixed URL: %s", _redact_url_secrets(fixed_url))
|
||||
return _browser_safe_image_url(fixed_url)
|
||||
|
||||
elif active_server == 'navidrome':
|
||||
|
|
@ -113,7 +134,6 @@ def normalize_image_url(thumb_url: str | None) -> str | None:
|
|||
navidrome_base_url = navidrome_config.get('base_url', '')
|
||||
navidrome_username = navidrome_config.get('username', '')
|
||||
navidrome_password = navidrome_config.get('password', '')
|
||||
logger.info("Navidrome config - base_url: %s, username: %s", navidrome_base_url, navidrome_username)
|
||||
|
||||
if navidrome_base_url and navidrome_username and navidrome_password:
|
||||
# Extract the path from URL
|
||||
|
|
@ -137,7 +157,7 @@ def normalize_image_url(thumb_url: str | None) -> str | None:
|
|||
|
||||
# Construct proper Navidrome Subsonic URL
|
||||
fixed_url = f"{navidrome_base_url.rstrip('/')}{path}{separator}{auth_params}"
|
||||
logger.info("Fixed URL: %s", fixed_url)
|
||||
logger.debug("Fixed URL: %s", _redact_url_secrets(fixed_url))
|
||||
return _browser_safe_image_url(fixed_url)
|
||||
|
||||
logger.warning("No configuration found for %s or unsupported server type", active_server)
|
||||
|
|
@ -146,7 +166,7 @@ def normalize_image_url(thumb_url: str | None) -> str | None:
|
|||
return _browser_safe_image_url(thumb_url)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Error fixing image URL '%s': %s", thumb_url, exc)
|
||||
logger.error("Error fixing image URL '%s': %s", _redact_url_secrets(thumb_url), exc)
|
||||
return _browser_safe_image_url(thumb_url)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3416,6 +3416,7 @@ const WHATS_NEW = {
|
|||
'2.5.2': [
|
||||
// --- May 13, 2026 — 2.5.2 release ---
|
||||
{ date: 'May 13, 2026 — 2.5.2 release' },
|
||||
{ title: 'Stop Leaking Plex / Jellyfin / Navidrome Tokens Into app.log', desc: 'security: artwork URL fixer was logging full media-server URLs (including the X-Plex-Token / X-Emby-Token / Subsonic auth params) at INFO level on every cover-art lookup. tokens piled up in app.log on disk — anyone with read access to the log file gained full read access to the user\'s media server. fix: log lines moved to DEBUG (so they don\'t persist by default) and routed through a new `_redact_url_secrets` helper that masks the values of `X-Plex-Token` / `X-Emby-Token` / `api_key` / `apikey` / Subsonic `t` / `s` / `p` / generic `token` / `password` query params. anchor regex on `?` or `&` boundary so short keys like `t` don\'t false-match inside `format=Jpg`. also dropped the noisy per-call "Plex/Jellyfin/Navidrome config - base_url: ..., token: ..." INFO lines that fired on every thumbnail. wipe your existing app.log if your config has been logged.', page: 'settings' },
|
||||
{ title: 'AcoustID + Quarantine Modal: Three Bug Fixes', desc: 'github issues #607 + #608. (1) live recordings no longer false-quarantine when AcoustID returns the live recording with a bare title — common case where venue/live annotation lives on the release entity, not the recording entity itself ("Clarity (Live at ...)" expected, AcoustID returns bare "Clarity"). new pure helper `core/matching/version_mismatch.py:is_acceptable_version_mismatch` accepts the mismatch only when one-sided live + bare AND fingerprint score >= 0.85 AND bare titles agree (>=0.7) AND artist matches (>=0.6). other version mismatches (instrumental, remix, acoustic, demo) stay strict — those have distinct fingerprints + MB always annotates them in the recording title. 23 boundary tests pin every shape; existing test_acoustid_version_mismatch suite passes unchanged. (2) audio-mismatch failure message no longer reports "identified as \'\' by \'\' (artist=100%)" when AcoustID returns multiple recordings — prior code mixed recordings[0]\'s strings (which can be empty) with best_rec\'s scores. now uses matched_title/matched_artist consistently in both the high-confidence-skip path and the final fail message. (3) quarantine modal Approve/Delete buttons no longer silently no-op when filename contains an apostrophe — id is now wrapped via `escapeHtml(JSON.stringify(id))` so quotes / backslashes / unicode all round-trip safely through the HTML attribute → JS string boundary. (4) bonus UX: quarantine entry expanded view now shows source uploader (username) + original soulseek filename when the sidecar carries that context, helping trace which uploader the bad file came from.', page: 'downloads' },
|
||||
{ title: 'Reorganize: Read Embedded Tags Instead Of Metadata API', desc: 'github issue #592: optional reorganize mode that uses each file\'s embedded tags as the canonical source of truth instead of doing a fresh metadata-source api lookup. useful for well-tagged libraries where api drift can produce inconsistent renames. zero api calls. opt-in via new "metadata mode" dropdown in the per-album reorganize modal + bulk reorganize-all modal — default stays "api" so existing pipelines are untouched. tag-mode reads via the same mutagen path the audit-trail modal uses (id3 / vorbis / mp4 all covered). honors id3-style "5/12" track and disc shapes, multi-value Artists tags, year normalization across 5 date formats, releasetype canonical tokens (album/single/ep/compilation). partial-album reorganize respects "totaldiscs" tag so disc 1 of a 2-disc set still routes into "Disc 1/" subfolder. plan items carry per-item `api_album` so each file\'s own album metadata flows through post-process, not a shared one. logic lifted to pure helper `core/library/reorganize_tag_source.py` with 49 boundary tests + 6 planner-level integration tests pinning every shape: missing essentials, multi-artist split across 9 separators, defensive paths, api-mode regression guard. 3171 tests pass — no regression.', page: 'tools' },
|
||||
{ title: 'Dashboard Cursor-Following Accent Blob + Darker Cards', desc: 'subtle two-layer accent blob that follows your cursor across the bento. soft halo with cursor lag for a liquid trailing feel + brighter inner core that screen-blends on top. both layers gently pulse on different rhythms (5.5s halo, 3.7s core) so it feels alive. mouse leaves a card or sits in a gap → blob freezes for 1.5s then drifts back to grid center. card backgrounds darkened to near-black with stronger borders for contrast. respects the existing reduce visual effects setting (settings → ui) — blob fully disabled when on. performant: rAF-only-while-moving, single layout flush per frame, batched read/write of getBoundingClientRect.', page: 'home' },
|
||||
|
|
|
|||
Loading…
Reference in a new issue