mirror of
https://github.com/wizarrrr/wizarr.git
synced 2026-07-30 14:57:08 -04:00
* fix: stop image-proxy tokens disclosing media server credentials The /image-proxy token was base64-encoded JSON containing the upstream URL, and for Plex/Jellyfin/Emby/Komga/Audiobookshelf that URL carries the admin API token as a query parameter. Since the recently_added_media widget ships in the default first wizard step, any invited user could view source, decode a poster URL and recover the server admin token. Two independent layers: 1. Strip credentials before they enter the payload. Known credential query parameters and any user:pass@ userinfo are removed whenever we have a server_id, because the proxy already re-attaches the correct auth header from the MediaServer row at fetch time. Requests with no server_id keep the URL, since there it is the only way to authenticate. 2. Encrypt the payload with AES-GCM under a key derived from SECRET_KEY, so the token is opaque rather than merely encoded, as the module docstring always claimed. This also covers the no-server_id case and any credential form not on the strip list, and it hides the internal host and port. AES-GCM authenticates as well as encrypts, so it replaces the separate truncated-HMAC signature. The nonce is derived from the plaintext (SIV-style) so identical payloads still produce identical tokens and the existing token and image caches stay effective across workers. A nonce is only ever reused for a byte-identical plaintext under the same key. Existing tokens stop validating on upgrade. They live at most 24 hours and are regenerated on render, so the only effect is a re-fetch. * fix: stop cinema-posters leaking Jellyfin/Emby admin api_key Jellyfin and Emby get_movie_posters embedded ?api_key=<admin token> in raw poster URLs, and the public unauthenticated /cinema-posters route returned them verbatim, disclosing the admin key to any visitor. Route both through the image proxy like Plex already does, so the credential is re-attached server-side as a header and never reaches the client. Adds a regression test asserting Jellyfin and Emby poster URLs are proxied and carry no api_key. * harden image-proxy tokens: host-scoping, canonical tokens, bounded cache, tests Follow-ups from the review of the disclosure fix. Host-scope credential stripping to the media server's own host. A foreign artwork URL (e.g. a podcast cover on a CDN) now keeps a token/api_key param it legitimately needs instead of being stripped and then fetched unauthenticated. Confidentiality is still covered by the encryption layer. Reject non-canonical token encodings. urlsafe_b64decode silently discards non-alphabet bytes, so a valid token could be spliced with junk into unlimited distinct strings that all decrypt identically, each becoming its own cache key on the unauthenticated route. Enforce a canonical round-trip. Bound _token_cache with a hard cap and run cleanup on the validate path. Add tests: deterministic-nonce (SIV) distinctness, per-server-type header re-attachment for all five types, host-scoped stripping, token canonicality, and cache bounding. * harden image proxy: fail-closed secret, no-redirect capped fetch Defense-in-depth on the unauthenticated /image-proxy route. _get_secret raises instead of falling back to a public constant when SECRET_KEY is unset, so a misconfigured instance fails closed rather than issuing forgeable, decryptable tokens. Token validation moved inside the route try, so that misconfig surfaces as a 502 instead of an uncaught 500. The fetch no longer follows redirects (an unfollowed 3xx becomes a logged 502), which stops the media-server auth header being replayed to a redirect target. The body is streamed with a 25 MB hard cap plus an early Content-Length reject, replacing the unbounded r.content read. Tests cover fail-closed generation, mid-stream abort with connection release, the 3xx-to-502 path, and the Content-Length reject. * fix: make Jellyfin/Emby cinema posters actually load (Recursive) get_movie_posters queried /Items without Recursive, so on a normal library it only saw the root folders and returned nothing: the Jellyfin/Emby cinema background was silently blank. Verified against a real 268-movie Jellyfin: the query returns 0 items without Recursive and 20 with it. This is why the /cinema-posters api_key leak was latent rather than active on a standard install: with an empty poster list there was nothing to leak. Enabling Recursive on its own would have turned it into an active, unauthenticated admin-key disclosure. It is only safe here because the previous commit routes these URLs through the image proxy, so the api_key never reaches the client. Verified end to end against the real Jellyfin: posters now populate, carry no api_key, and render through the proxy (the credential is re-attached server-side as a header). Adds a regression test that the query is Recursive. * domain-separate the nonce key and add nosniff to the proxy route Two review follow-ups on the token fix: - Derive the SIV-style nonce from its own key (wizarr.image-proxy.nonce.v1) instead of reusing the AES key, matching the two-key structure of SIV modes. The nonce is embedded in the token and never recomputed on validation, so existing tokens still decrypt; only newly minted tokens change, which is a one-time re-fetch as before. - Send X-Content-Type-Options: nosniff on both /image-proxy responses. The Content-Type is upstream-controlled and the route is public and cacheable, so forbid MIME sniffing. * judge token-cache freshness by bucket, not cache insertion time The validate_token cache-hit path checked time-since-insertion against TOKEN_EXPIRY. A token re-validated near end of life was re-cached with a fresh timestamp, so the cache kept serving it for another full TOKEN_EXPIRY (roughly doubling its lifetime) even though its own bucket had expired. Both the cache-hit and decrypt paths now derive freshness from the token's bucket via a shared _bucket_within_expiry helper, and cache entries carry the bucket, so a cached entry can never outlive the token it stands in for. * fix: scope image proxy credentials to server origin * fix: validate image proxy tokens before cache hits --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
926 lines
30 KiB
Python
926 lines
30 KiB
Python
"""Confidentiality and integrity tests for image-proxy tokens.
|
|
|
|
Proxy tokens are embedded in pages served to invited users (the
|
|
``recently_added_media`` wizard widget, among others), so they must never
|
|
disclose the media server's admin credentials or the internal upstream URL.
|
|
"""
|
|
|
|
import base64
|
|
import json
|
|
from typing import ClassVar
|
|
from urllib.parse import unquote_plus
|
|
|
|
import pytest
|
|
from requests.structures import CaseInsensitiveDict
|
|
|
|
import app.services.image_proxy as image_proxy_module
|
|
from app.services.image_proxy import ImageProxyService
|
|
from app.services.media.emby import EmbyClient
|
|
from app.services.media.jellyfin import JellyfinClient
|
|
|
|
PLEX_TOKEN = "sUpErSeCrEtAdM1nT0k3n" # test fixture, not a real credential
|
|
POSTER_URL = (
|
|
"http://plex.internal:32400/library/metadata/12345/thumb/1700000000"
|
|
f"?X-Plex-Token={PLEX_TOKEN}"
|
|
)
|
|
POSTER_URL_CLEAN = "http://plex.internal:32400/library/metadata/12345/thumb/1700000000"
|
|
|
|
|
|
def _decode(token: str) -> bytes:
|
|
"""Base64url-decode a token the way an attacker with the HTML would."""
|
|
padding = (4 - len(token) % 4) % 4
|
|
return base64.urlsafe_b64decode(token + ("=" * padding))
|
|
|
|
|
|
class _FrozenTime:
|
|
def __init__(self, current: float):
|
|
self.current = current
|
|
|
|
def time(self) -> float:
|
|
return self.current
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_caches():
|
|
"""validate_token() short-circuits on the token cache, so isolate every test."""
|
|
ImageProxyService._token_cache.clear()
|
|
ImageProxyService._image_cache.clear()
|
|
ImageProxyService._total_image_bytes = 0
|
|
ImageProxyService._cipher_key_cache.clear()
|
|
ImageProxyService._server_url_cache.clear()
|
|
ImageProxyService._server_header_cache.clear()
|
|
yield
|
|
ImageProxyService._token_cache.clear()
|
|
ImageProxyService._image_cache.clear()
|
|
ImageProxyService._total_image_bytes = 0
|
|
ImageProxyService._cipher_key_cache.clear()
|
|
ImageProxyService._server_url_cache.clear()
|
|
ImageProxyService._server_header_cache.clear()
|
|
|
|
|
|
# ─── Confidentiality ────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_token_does_not_disclose_admin_token(app):
|
|
"""The whole point: nothing recoverable from the token the browser receives."""
|
|
with app.app_context():
|
|
token = ImageProxyService.generate_token(POSTER_URL, server_id=1)
|
|
|
|
assert PLEX_TOKEN not in token
|
|
|
|
raw = _decode(token)
|
|
assert PLEX_TOKEN.encode() not in raw
|
|
assert b"X-Plex-Token" not in raw
|
|
assert b"plex.internal" not in raw
|
|
assert b"library/metadata" not in raw
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"param",
|
|
[
|
|
"X-Plex-Token",
|
|
"x-plex-token",
|
|
"X-Emby-Token",
|
|
"X-MediaBrowser-Token",
|
|
"X-API-Key",
|
|
"api_key",
|
|
"apikey",
|
|
"token",
|
|
],
|
|
)
|
|
def test_known_credential_params_are_stripped(app, session, param):
|
|
from app.models import MediaServer
|
|
|
|
server = MediaServer(
|
|
name="Media",
|
|
server_type="jellyfin",
|
|
url="http://media.internal:8096",
|
|
api_key="SECRETVALUE",
|
|
)
|
|
session.add(server)
|
|
session.commit()
|
|
|
|
url = f"http://media.internal:8096/Items/1/Images/Primary?{param}=SECRETVALUE&maxWidth=300"
|
|
|
|
with app.app_context():
|
|
token = ImageProxyService.generate_token(url, server_id=server.id)
|
|
ImageProxyService._token_cache.clear()
|
|
mapping = ImageProxyService.validate_token(token)
|
|
|
|
assert mapping is not None
|
|
assert "SECRETVALUE" not in mapping["url"]
|
|
# Non-credential parameters must survive or artwork requests break
|
|
assert "maxWidth=300" in mapping["url"]
|
|
|
|
|
|
def test_userinfo_credentials_are_stripped(app, session):
|
|
from app.models import MediaServer
|
|
|
|
server = MediaServer(
|
|
name="Plex",
|
|
server_type="plex",
|
|
url="http://admin:hunter2@plex.internal:32400",
|
|
api_key="key",
|
|
)
|
|
session.add(server)
|
|
session.commit()
|
|
|
|
with app.app_context():
|
|
token = ImageProxyService.generate_token(
|
|
"http://admin:hunter2@plex.internal:32400/thumb.jpg",
|
|
server_id=server.id,
|
|
)
|
|
ImageProxyService._token_cache.clear()
|
|
mapping = ImageProxyService.validate_token(token)
|
|
|
|
assert mapping is not None
|
|
assert mapping["url"] == "http://plex.internal:32400/thumb.jpg"
|
|
|
|
|
|
def test_url_kept_without_server_id_but_still_opaque(app):
|
|
"""Legacy installs have no MediaServer row, so the URL is the only auth path.
|
|
|
|
It must still be unreadable by the client.
|
|
"""
|
|
with app.app_context():
|
|
token = ImageProxyService.generate_token(POSTER_URL, server_id=None)
|
|
ImageProxyService._token_cache.clear()
|
|
mapping = ImageProxyService.validate_token(token)
|
|
|
|
assert mapping is not None
|
|
assert mapping["url"] == POSTER_URL
|
|
assert PLEX_TOKEN.encode() not in _decode(token)
|
|
|
|
|
|
# ─── Integrity ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_round_trip(app, session):
|
|
from app.models import MediaServer
|
|
|
|
server = MediaServer(
|
|
name="Plex",
|
|
server_type="plex",
|
|
url="http://plex.internal:32400",
|
|
api_key=PLEX_TOKEN,
|
|
)
|
|
session.add(server)
|
|
session.commit()
|
|
|
|
with app.app_context():
|
|
token = ImageProxyService.generate_token(POSTER_URL, server_id=server.id)
|
|
ImageProxyService._token_cache.clear()
|
|
mapping = ImageProxyService.validate_token(token)
|
|
|
|
assert mapping == {"url": POSTER_URL_CLEAN, "server_id": server.id}
|
|
|
|
|
|
def test_tampered_token_rejected(app):
|
|
with app.app_context():
|
|
token = ImageProxyService.generate_token(POSTER_URL, server_id=1)
|
|
ImageProxyService._token_cache.clear()
|
|
|
|
idx = len(token) // 2
|
|
replacement = "A" if token[idx] != "A" else "B"
|
|
tampered = token[:idx] + replacement + token[idx + 1 :]
|
|
|
|
assert ImageProxyService.validate_token(tampered) is None
|
|
|
|
|
|
def test_hand_crafted_payload_rejected(app):
|
|
"""An attacker cannot mint a token for an arbitrary URL (SSRF)."""
|
|
payload = json.dumps(
|
|
{
|
|
"url": "http://169.254.169.254/latest/meta-data/",
|
|
"server_id": None,
|
|
"bucket": 0,
|
|
}
|
|
)
|
|
forged = base64.urlsafe_b64encode(payload.encode()).decode().rstrip("=")
|
|
|
|
with app.app_context():
|
|
assert ImageProxyService.validate_token(forged) is None
|
|
|
|
|
|
def test_token_from_a_different_secret_rejected(app):
|
|
with app.app_context():
|
|
token = ImageProxyService.generate_token(POSTER_URL, server_id=1)
|
|
|
|
ImageProxyService._token_cache.clear()
|
|
ImageProxyService._cipher_key_cache.clear()
|
|
|
|
original = app.config["SECRET_KEY"]
|
|
app.config["SECRET_KEY"] = "a-completely-different-secret"
|
|
try:
|
|
with app.app_context():
|
|
assert ImageProxyService.validate_token(token) is None
|
|
finally:
|
|
app.config["SECRET_KEY"] = original
|
|
|
|
|
|
def test_token_cache_does_not_bypass_secret_rotation(app):
|
|
"""A cached mapping belongs to the key that authenticated its token."""
|
|
with app.app_context():
|
|
token = ImageProxyService.generate_token(POSTER_URL, server_id=1)
|
|
|
|
original = app.config["SECRET_KEY"]
|
|
app.config["SECRET_KEY"] = "a-completely-different-secret"
|
|
try:
|
|
with app.app_context():
|
|
assert ImageProxyService.validate_token(token) is None
|
|
finally:
|
|
app.config["SECRET_KEY"] = original
|
|
|
|
|
|
@pytest.mark.parametrize("token", ["", "not-a-token", "a.b", "AAAA"])
|
|
def test_malformed_tokens_rejected(app, token):
|
|
with app.app_context():
|
|
assert ImageProxyService.validate_token(token) is None
|
|
|
|
|
|
# ─── Caching behaviour ──────────────────────────────────────────────────────
|
|
|
|
|
|
def test_token_is_stable_for_the_same_url(app):
|
|
"""Deterministic tokens keep the token/image caches effective across workers."""
|
|
with app.app_context():
|
|
first = ImageProxyService.generate_token(POSTER_URL, server_id=1)
|
|
ImageProxyService._token_cache.clear()
|
|
second = ImageProxyService.generate_token(POSTER_URL, server_id=1)
|
|
|
|
assert first == second
|
|
|
|
|
|
def test_expired_token_rejected(app, monkeypatch):
|
|
with app.app_context():
|
|
token = ImageProxyService.generate_token(POSTER_URL, server_id=1)
|
|
|
|
ImageProxyService._token_cache.clear()
|
|
|
|
real_time = image_proxy_module.time.time
|
|
skew = ImageProxyService.TOKEN_EXPIRY + ImageProxyService.TOKEN_BUCKET_SECONDS
|
|
|
|
class _LaterTime:
|
|
@staticmethod
|
|
def time():
|
|
return real_time() + skew
|
|
|
|
monkeypatch.setattr(image_proxy_module, "time", _LaterTime)
|
|
|
|
with app.app_context():
|
|
assert ImageProxyService.validate_token(token) is None
|
|
|
|
|
|
def test_cache_does_not_extend_token_lifetime(app, monkeypatch):
|
|
"""Re-validating a token near expiry must not extend it via the token cache.
|
|
|
|
The cache-hit path used to trust its own insertion time, so a token
|
|
re-validated (and re-cached) just before expiry stayed servable for another
|
|
full TOKEN_EXPIRY. Expiry is now judged from the token's bucket instead.
|
|
"""
|
|
with app.app_context():
|
|
token = ImageProxyService.generate_token(POSTER_URL, server_id=1)
|
|
ImageProxyService._token_cache.clear()
|
|
|
|
real_time = image_proxy_module.time.time
|
|
|
|
def _shifted(offset):
|
|
return _FrozenTime(real_time() + offset)
|
|
|
|
# Validate just inside the window: still valid, and repopulates the cache.
|
|
near_expiry = (
|
|
ImageProxyService.TOKEN_EXPIRY - ImageProxyService.TOKEN_BUCKET_SECONDS
|
|
)
|
|
monkeypatch.setattr(image_proxy_module, "time", _shifted(near_expiry))
|
|
with app.app_context():
|
|
assert ImageProxyService.validate_token(token) is not None
|
|
assert token in ImageProxyService._token_cache # cache was repopulated
|
|
|
|
# Past the real expiry: the cached entry must not keep serving the token,
|
|
# even though it was inserted only a few hours earlier.
|
|
past_expiry = (
|
|
ImageProxyService.TOKEN_EXPIRY + 5 * ImageProxyService.TOKEN_BUCKET_SECONDS
|
|
)
|
|
monkeypatch.setattr(image_proxy_module, "time", _shifted(past_expiry))
|
|
with app.app_context():
|
|
assert ImageProxyService.validate_token(token) is None
|
|
|
|
|
|
def test_cached_image_does_not_outlive_token_bucket(app, client, monkeypatch):
|
|
"""An image-cache hit must still reject its expired access token."""
|
|
start = 1_800_000_000.0 # Exact hourly bucket boundary
|
|
clock = _FrozenTime(start)
|
|
monkeypatch.setattr(image_proxy_module, "time", clock)
|
|
|
|
with app.app_context():
|
|
token = ImageProxyService.generate_token(POSTER_URL, server_id=None)
|
|
|
|
clock.current = start + ImageProxyService.TOKEN_EXPIRY - 60
|
|
ImageProxyService.cache_image(token, b"cached-image", "image/jpeg")
|
|
|
|
# The image itself is fresh, but its token is just over 24 hours old.
|
|
clock.current = start + ImageProxyService.TOKEN_EXPIRY + 1
|
|
response = client.get(f"/image-proxy?token={token}")
|
|
|
|
assert response.status_code == 403
|
|
|
|
|
|
def test_cached_image_fails_closed_without_secret(app, client):
|
|
"""An image-cache hit cannot bypass SECRET_KEY validation."""
|
|
with app.app_context():
|
|
token = ImageProxyService.generate_token(POSTER_URL, server_id=None)
|
|
ImageProxyService.cache_image(token, b"cached-image", "image/jpeg")
|
|
|
|
original = app.config["SECRET_KEY"]
|
|
app.config["SECRET_KEY"] = ""
|
|
try:
|
|
response = client.get(f"/image-proxy?token={token}")
|
|
assert response.status_code == 502
|
|
finally:
|
|
app.config["SECRET_KEY"] = original
|
|
|
|
|
|
# ─── End-to-end through the /image-proxy route ──────────────────────────────
|
|
|
|
|
|
def test_proxy_reattaches_credentials_server_side(app, client, session, monkeypatch):
|
|
"""The stripped credential must come back as a header on the upstream request.
|
|
|
|
This is what keeps artwork rendering after the token is removed from the URL.
|
|
"""
|
|
from app.models import MediaServer
|
|
|
|
server = MediaServer(
|
|
name="Plex",
|
|
server_type="plex",
|
|
url="http://plex.internal:32400",
|
|
api_key=PLEX_TOKEN,
|
|
)
|
|
session.add(server)
|
|
session.commit()
|
|
|
|
ImageProxyService._server_header_cache.clear()
|
|
|
|
captured = {}
|
|
|
|
class _FakeResponse:
|
|
status_code = 200
|
|
headers: ClassVar = {"Content-Type": "image/jpeg"}
|
|
content = b"\xff\xd8\xff\xe0-jpeg-bytes"
|
|
|
|
def raise_for_status(self):
|
|
return None
|
|
|
|
def iter_content(self, chunk_size):
|
|
yield self.content
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
return False
|
|
|
|
class _FakeSession:
|
|
def get(self, url, headers=None, timeout=None, **kwargs):
|
|
captured["url"] = url
|
|
captured["headers"] = headers or {}
|
|
captured["kwargs"] = kwargs
|
|
return _FakeResponse()
|
|
|
|
monkeypatch.setattr(
|
|
ImageProxyService,
|
|
"get_session",
|
|
classmethod(lambda cls, url, server_id: _FakeSession()),
|
|
)
|
|
|
|
with app.app_context():
|
|
token = ImageProxyService.generate_token(POSTER_URL, server_id=server.id)
|
|
|
|
resp = client.get(f"/image-proxy?token={token}")
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.data == _FakeResponse.content
|
|
|
|
# The credential was stripped from the URL...
|
|
assert "X-Plex-Token" not in captured["url"]
|
|
assert PLEX_TOKEN not in captured["url"]
|
|
# ...and re-attached as a header from the MediaServer row
|
|
assert captured["headers"].get("X-Plex-Token") == PLEX_TOKEN
|
|
# Hardening: the proxied fetch does not follow redirects.
|
|
assert captured["kwargs"].get("allow_redirects") is False
|
|
# Hardening: upstream-controlled Content-Type on a public, cacheable route
|
|
# must forbid MIME sniffing.
|
|
assert resp.headers.get("X-Content-Type-Options") == "nosniff"
|
|
|
|
|
|
def test_proxy_does_not_send_media_credentials_to_foreign_origin(
|
|
app, client, session, monkeypatch
|
|
):
|
|
"""External artwork may keep its own query auth but gets no media-server key."""
|
|
from app.models import MediaServer
|
|
|
|
server = MediaServer(
|
|
name="Audiobookshelf",
|
|
server_type="audiobookshelf",
|
|
url="http://abs.internal:13378",
|
|
api_key="ABS-ADMIN-KEY",
|
|
)
|
|
session.add(server)
|
|
session.commit()
|
|
|
|
external_url = "https://cdn.example.com/cover.jpg?token=CDN-SIGNATURE"
|
|
captured = {}
|
|
|
|
class _FakeResponse:
|
|
status_code = 200
|
|
headers: ClassVar = {"Content-Type": "image/jpeg"}
|
|
|
|
def raise_for_status(self):
|
|
return None
|
|
|
|
def iter_content(self, chunk_size):
|
|
yield b"image"
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
return False
|
|
|
|
class _FakeSession:
|
|
def get(self, url, headers=None, timeout=None, **kwargs):
|
|
captured["url"] = url
|
|
captured["headers"] = headers or {}
|
|
return _FakeResponse()
|
|
|
|
monkeypatch.setattr(
|
|
ImageProxyService,
|
|
"get_session",
|
|
classmethod(lambda cls, url, server_id: _FakeSession()),
|
|
)
|
|
|
|
with app.app_context():
|
|
token = ImageProxyService.generate_token(external_url, server_id=server.id)
|
|
|
|
response = client.get(f"/image-proxy?token={token}")
|
|
|
|
assert response.status_code == 200
|
|
assert captured["url"] == external_url
|
|
assert "Authorization" not in captured["headers"]
|
|
|
|
|
|
def test_proxy_reconstructs_configured_basic_auth(app, client, session, monkeypatch):
|
|
"""Stripped URL userinfo is restored as server-side Basic authentication."""
|
|
from app.models import MediaServer
|
|
|
|
server = MediaServer(
|
|
name="Plex behind Basic Auth",
|
|
server_type="plex",
|
|
url="http://proxy-user:proxy-pass@plex.internal:32400",
|
|
api_key=PLEX_TOKEN,
|
|
)
|
|
session.add(server)
|
|
session.commit()
|
|
|
|
upstream_url = (
|
|
"http://proxy-user:proxy-pass@plex.internal:32400/thumb.jpg"
|
|
f"?X-Plex-Token={PLEX_TOKEN}"
|
|
)
|
|
captured = {}
|
|
|
|
class _FakeResponse:
|
|
status_code = 200
|
|
headers: ClassVar = {"Content-Type": "image/jpeg"}
|
|
|
|
def raise_for_status(self):
|
|
return None
|
|
|
|
def iter_content(self, chunk_size):
|
|
yield b"image"
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
return False
|
|
|
|
class _FakeSession:
|
|
def get(self, url, headers=None, timeout=None, **kwargs):
|
|
captured["url"] = url
|
|
captured["headers"] = headers or {}
|
|
return _FakeResponse()
|
|
|
|
monkeypatch.setattr(
|
|
ImageProxyService,
|
|
"get_session",
|
|
classmethod(lambda cls, url, server_id: _FakeSession()),
|
|
)
|
|
|
|
with app.app_context():
|
|
token = ImageProxyService.generate_token(upstream_url, server_id=server.id)
|
|
|
|
response = client.get(f"/image-proxy?token={token}")
|
|
|
|
assert response.status_code == 200
|
|
assert captured["url"] == "http://plex.internal:32400/thumb.jpg"
|
|
assert captured["headers"]["Authorization"] == (
|
|
"Basic cHJveHktdXNlcjpwcm94eS1wYXNz"
|
|
)
|
|
assert captured["headers"]["X-Plex-Token"] == PLEX_TOKEN
|
|
|
|
|
|
# ─── Movie-poster builders must not leak credentials ────────────────────────
|
|
#
|
|
# GHSA-gw9v-5c74-gwmr sibling path: the public, unauthenticated /cinema-posters
|
|
# route returns get_movie_posters() output verbatim. Plex already proxies these,
|
|
# but Jellyfin and Emby previously appended ?api_key=<admin token> to the raw
|
|
# URL, disclosing the admin key to anyone — no invite required. Both must now go
|
|
# through the opaque image proxy, exactly like Plex.
|
|
|
|
JELLYFIN_EMBY_ADMIN_KEY = "jf-EmBy-AdM1n-K3y" # test fixture, not a real credential
|
|
|
|
|
|
class _FakeItemsResponse:
|
|
def __init__(self, payload):
|
|
self._payload = payload
|
|
|
|
def json(self):
|
|
return self._payload
|
|
|
|
|
|
def _poster_client(cls):
|
|
"""Build a Jellyfin/Emby client without __init__ (no network, no DB)."""
|
|
client = object.__new__(cls)
|
|
client.url = "http://media.internal:8096"
|
|
client.token = JELLYFIN_EMBY_ADMIN_KEY
|
|
client.server_id = 1
|
|
client.get = lambda endpoint, params=None: _FakeItemsResponse(
|
|
{"Items": [{"Id": "movie-1"}, {"Id": "movie-2"}]}
|
|
)
|
|
return client
|
|
|
|
|
|
@pytest.mark.parametrize("cls", [JellyfinClient, EmbyClient])
|
|
def test_movie_posters_are_proxied_not_credential_bearing(app, cls):
|
|
"""get_movie_posters must emit opaque proxy URLs, never a raw api_key URL."""
|
|
with app.app_context():
|
|
posters = _poster_client(cls).get_movie_posters(limit=10)
|
|
|
|
assert posters, "expected at least one poster URL"
|
|
for url in posters:
|
|
# Nothing recoverable by the (possibly anonymous) client...
|
|
assert JELLYFIN_EMBY_ADMIN_KEY not in url
|
|
assert "api_key" not in url.lower()
|
|
# ...and every poster goes through the opaque image proxy.
|
|
assert url.startswith("/image-proxy?token=")
|
|
|
|
# Defense in depth: opaque even to a client that decodes the token.
|
|
token = unquote_plus(url.split("token=", 1)[1])
|
|
assert JELLYFIN_EMBY_ADMIN_KEY.encode() not in _decode(token)
|
|
|
|
|
|
@pytest.mark.parametrize("cls", [JellyfinClient, EmbyClient])
|
|
def test_movie_posters_query_is_recursive(app, cls):
|
|
"""Without Recursive, /Items returns only root folders and no movies, so the
|
|
cinema background is silently empty. Guard that the query descends libraries."""
|
|
captured = {}
|
|
|
|
def fake_get(endpoint, params=None):
|
|
captured["params"] = params or {}
|
|
return _FakeItemsResponse({"Items": [{"Id": "movie-1"}]})
|
|
|
|
client = _poster_client(cls)
|
|
client.get = fake_get
|
|
with app.app_context():
|
|
client.get_movie_posters(limit=10)
|
|
|
|
assert captured["params"].get("Recursive") is True
|
|
|
|
|
|
# ─── Deterministic encryption safety ───────────────────────────────────────
|
|
|
|
|
|
def test_distinct_payloads_produce_distinct_tokens(app):
|
|
"""URL and server identity must both be authenticated token inputs."""
|
|
with app.app_context():
|
|
tok_a = ImageProxyService.generate_token(
|
|
"http://plex.internal:32400/library/metadata/1/thumb/1", server_id=1
|
|
)
|
|
ImageProxyService._token_cache.clear()
|
|
tok_b = ImageProxyService.generate_token(
|
|
"http://plex.internal:32400/library/metadata/2/thumb/1", server_id=1
|
|
)
|
|
ImageProxyService._token_cache.clear()
|
|
tok_a_other_server = ImageProxyService.generate_token(
|
|
"http://plex.internal:32400/library/metadata/1/thumb/1", server_id=2
|
|
)
|
|
|
|
assert tok_a != tok_b
|
|
assert tok_a != tok_a_other_server
|
|
|
|
|
|
# ─── Host-scoped credential stripping ────────────────────────────────────────
|
|
|
|
|
|
def test_credentials_stripped_only_from_matching_host(app, session):
|
|
from app.models import MediaServer
|
|
|
|
server = MediaServer(
|
|
name="Plex",
|
|
server_type="plex",
|
|
url="http://plex.internal:32400",
|
|
api_key="k",
|
|
)
|
|
session.add(server)
|
|
session.commit()
|
|
|
|
url = "http://plex.internal:32400/thumb?X-Plex-Token=SECRET&maxWidth=300"
|
|
with app.app_context():
|
|
token = ImageProxyService.generate_token(url, server_id=server.id)
|
|
ImageProxyService._token_cache.clear()
|
|
mapping = ImageProxyService.validate_token(token)
|
|
|
|
assert mapping is not None
|
|
assert "SECRET" not in mapping["url"]
|
|
assert "maxWidth=300" in mapping["url"]
|
|
|
|
|
|
def test_credentials_kept_on_foreign_host(app, session):
|
|
"""A token/api_key param on an external host (not the media server) may be
|
|
genuinely required, so it must survive; encryption keeps it opaque."""
|
|
from app.models import MediaServer
|
|
|
|
server = MediaServer(
|
|
name="ABS",
|
|
server_type="audiobookshelf",
|
|
url="http://abs.internal:13378",
|
|
api_key="abs-key",
|
|
)
|
|
session.add(server)
|
|
session.commit()
|
|
|
|
external = "https://cdn.example.com/podcast/cover.jpg?token=SIGNEDCDN&w=1"
|
|
with app.app_context():
|
|
token = ImageProxyService.generate_token(external, server_id=server.id)
|
|
ImageProxyService._token_cache.clear()
|
|
mapping = ImageProxyService.validate_token(token)
|
|
|
|
assert mapping is not None
|
|
assert mapping["url"] == external
|
|
|
|
|
|
# ─── Token canonicality & cache bounding ─────────────────────────────────────
|
|
|
|
|
|
def test_non_canonical_token_aliases_rejected(app):
|
|
"""urlsafe_b64decode silently drops junk bytes; without canonical-form
|
|
enforcement one valid token spawns unlimited accepted aliases, each its own
|
|
cache key (memory + upstream-fetch amplification on an unauthenticated route)."""
|
|
with app.app_context():
|
|
token = ImageProxyService.generate_token(POSTER_URL, server_id=1)
|
|
ImageProxyService._token_cache.clear()
|
|
|
|
# Baseline: the genuine token still validates.
|
|
assert ImageProxyService.validate_token(token) is not None
|
|
|
|
# Each alias decodes to the SAME ciphertext but is a different string.
|
|
for alias in (token + "!!!!", "~~~~" + token, token[:6] + "!!!!" + token[6:]):
|
|
ImageProxyService._token_cache.clear()
|
|
assert ImageProxyService.validate_token(alias) is None
|
|
|
|
|
|
def test_token_cache_is_bounded(app):
|
|
with app.app_context():
|
|
for i in range(ImageProxyService.TOKEN_CACHE_MAX_ENTRIES + 50):
|
|
ImageProxyService.generate_token(
|
|
f"http://plex.internal:32400/thumb/{i}", server_id=1
|
|
)
|
|
|
|
assert (
|
|
len(ImageProxyService._token_cache) <= ImageProxyService.TOKEN_CACHE_MAX_ENTRIES
|
|
)
|
|
|
|
|
|
# ─── Per-server-type header re-attachment ────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"server_type,header_name,expected",
|
|
[
|
|
("plex", "X-Plex-Token", "THE-KEY"),
|
|
("jellyfin", "X-MediaBrowser-Token", "THE-KEY"),
|
|
("emby", "X-Emby-Token", "THE-KEY"),
|
|
("komga", "X-API-Key", "THE-KEY"),
|
|
("audiobookshelf", "Authorization", "Bearer THE-KEY"),
|
|
],
|
|
)
|
|
def test_get_server_headers_per_type(app, session, server_type, header_name, expected):
|
|
from app.models import MediaServer
|
|
|
|
server = MediaServer(
|
|
name=server_type,
|
|
server_type=server_type,
|
|
url="http://media.internal",
|
|
api_key="THE-KEY",
|
|
)
|
|
session.add(server)
|
|
session.commit()
|
|
|
|
with app.app_context():
|
|
headers = ImageProxyService.get_server_headers(server.id, server.url)
|
|
|
|
assert headers.get(header_name) == expected
|
|
|
|
|
|
def test_get_server_headers_unknown_type_is_empty(app, session):
|
|
from app.models import MediaServer
|
|
|
|
server = MediaServer(
|
|
name="other",
|
|
server_type="navidrome",
|
|
url="http://other.internal",
|
|
api_key="THE-KEY",
|
|
)
|
|
session.add(server)
|
|
session.commit()
|
|
|
|
with app.app_context():
|
|
headers = ImageProxyService.get_server_headers(server.id, server.url)
|
|
|
|
assert headers == {}
|
|
|
|
|
|
# ─── Hardening: fail-closed secret, no redirects, response-size cap ──────────
|
|
|
|
|
|
def test_missing_secret_key_fails_closed(app):
|
|
"""With no SECRET_KEY the token is neither opaque nor unforgeable, so token
|
|
generation must raise rather than fall back to a public constant."""
|
|
original = app.config["SECRET_KEY"]
|
|
app.config["SECRET_KEY"] = ""
|
|
try:
|
|
with app.app_context(), pytest.raises(RuntimeError):
|
|
ImageProxyService.generate_token(
|
|
"http://plex.internal:32400/thumb.jpg", server_id=1
|
|
)
|
|
finally:
|
|
app.config["SECRET_KEY"] = original
|
|
|
|
|
|
def _poster_server(session):
|
|
from app.models import MediaServer
|
|
|
|
server = MediaServer(
|
|
name="Plex",
|
|
server_type="plex",
|
|
url="http://plex.internal:32400",
|
|
api_key=PLEX_TOKEN,
|
|
)
|
|
session.add(server)
|
|
session.commit()
|
|
return server
|
|
|
|
|
|
def test_proxy_rejects_oversize_upstream(app, client, session, monkeypatch):
|
|
"""A body over the hard cap must be abandoned mid-stream (not fully
|
|
buffered), the connection released, and redirects must not be followed."""
|
|
server = _poster_server(session)
|
|
|
|
monkeypatch.setattr(ImageProxyService, "IMAGE_PROXY_MAX_BYTES", 32)
|
|
events = {"pulled": 0, "closed": False}
|
|
captured = {}
|
|
|
|
class _BigResponse:
|
|
status_code = 200
|
|
headers: ClassVar = CaseInsensitiveDict({"Content-Type": "image/jpeg"})
|
|
|
|
def raise_for_status(self):
|
|
return None
|
|
|
|
def iter_content(self, chunk_size):
|
|
for _ in range(1000): # far more than the 32-byte cap allows
|
|
events["pulled"] += 1
|
|
yield b"\x00" * 16
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
events["closed"] = True
|
|
return False
|
|
|
|
class _BigSession:
|
|
def get(self, url, headers=None, timeout=None, **kwargs):
|
|
captured["kwargs"] = kwargs
|
|
return _BigResponse()
|
|
|
|
monkeypatch.setattr(
|
|
ImageProxyService,
|
|
"get_session",
|
|
classmethod(lambda cls, url, server_id: _BigSession()),
|
|
)
|
|
|
|
with app.app_context():
|
|
token = ImageProxyService.generate_token(
|
|
"http://plex.internal:32400/thumb.jpg", server_id=server.id
|
|
)
|
|
|
|
resp = client.get(f"/image-proxy?token={token}")
|
|
|
|
assert resp.status_code == 502
|
|
assert events["pulled"] < 10 # aborted mid-stream, not fully buffered
|
|
assert events["closed"] is True # context manager released the connection
|
|
assert captured["kwargs"].get("allow_redirects") is False
|
|
assert captured["kwargs"].get("stream") is True
|
|
|
|
|
|
def test_proxy_does_not_follow_redirects(app, client, session, monkeypatch):
|
|
"""An unfollowed 3xx becomes a 502; the redirect body is never read/served."""
|
|
server = _poster_server(session)
|
|
served = {"body": False}
|
|
|
|
class _RedirectResponse:
|
|
status_code = 302
|
|
headers: ClassVar = CaseInsensitiveDict(
|
|
{"Content-Type": "text/html", "Location": "http://evil.example/"}
|
|
)
|
|
|
|
def raise_for_status(self):
|
|
return None # requests does not raise on 3xx
|
|
|
|
def iter_content(self, chunk_size):
|
|
served["body"] = True
|
|
yield b"redirect-body"
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
return False
|
|
|
|
monkeypatch.setattr(
|
|
ImageProxyService,
|
|
"get_session",
|
|
classmethod(
|
|
lambda cls, url, server_id: type(
|
|
"_S", (), {"get": lambda s, *a, **k: _RedirectResponse()}
|
|
)()
|
|
),
|
|
)
|
|
|
|
with app.app_context():
|
|
token = ImageProxyService.generate_token(
|
|
"http://plex.internal:32400/thumb.jpg", server_id=server.id
|
|
)
|
|
|
|
resp = client.get(f"/image-proxy?token={token}")
|
|
|
|
assert resp.status_code == 502
|
|
assert served["body"] is False
|
|
|
|
|
|
def test_proxy_rejects_oversize_content_length(app, client, session, monkeypatch):
|
|
"""An honest over-cap Content-Length is refused before the body is read."""
|
|
server = _poster_server(session)
|
|
monkeypatch.setattr(ImageProxyService, "IMAGE_PROXY_MAX_BYTES", 100)
|
|
events = {"pulled": 0}
|
|
|
|
class _ClResponse:
|
|
status_code = 200
|
|
headers: ClassVar = CaseInsensitiveDict(
|
|
{"Content-Type": "image/jpeg", "Content-Length": "500"}
|
|
)
|
|
|
|
def raise_for_status(self):
|
|
return None
|
|
|
|
def iter_content(self, chunk_size):
|
|
events["pulled"] += 1
|
|
yield b"\x00" * 16
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
return False
|
|
|
|
monkeypatch.setattr(
|
|
ImageProxyService,
|
|
"get_session",
|
|
classmethod(
|
|
lambda cls, url, server_id: type(
|
|
"_S", (), {"get": lambda s, *a, **k: _ClResponse()}
|
|
)()
|
|
),
|
|
)
|
|
|
|
with app.app_context():
|
|
token = ImageProxyService.generate_token(
|
|
"http://plex.internal:32400/thumb.jpg", server_id=server.id
|
|
)
|
|
|
|
resp = client.get(f"/image-proxy?token={token}")
|
|
|
|
assert resp.status_code == 502
|
|
assert events["pulled"] == 0 # rejected on Content-Length before reading body
|