From 2fe192607466100d0f3de750d742d1e70fab9450 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 15 May 2026 09:23:53 -0700 Subject: [PATCH] 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. --- core/metadata/artwork.py | 42 +++++++++++++++++++++++++++++----------- webui/static/helper.js | 1 + 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/core/metadata/artwork.py b/core/metadata/artwork.py index d405fa01..0508122f 100644 --- a/core/metadata/artwork.py +++ b/core/metadata/artwork.py @@ -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^|[?&])(?P' + '|'.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) diff --git a/webui/static/helper.js b/webui/static/helper.js index 26683d8c..9a565df3 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -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' },