VAITP Dataset

← Back to the dataset

CVE-2026-31804

Unauthenticated SSRF in Tautulli's image proxy via the 'img' parameter.

  • CVSS 5.3
  • CWE-918
  • Input Validation and Sanitization
  • Remote

Tautulli is a Python based monitoring and tracking tool for Plex Media Server. Prior to version 2.17.0, the /pms_image_proxy endpoint accepts a user-supplied img parameter and forwards it to Plex Media Server's /photo/:/ transcode transcoder without authentication and without restricting the scheme or host. The endpoint is intentionally excluded from all authentication checks in webstart.py, any value of img beginning with http is passed directly to Plex, this causes the Plex Media Server process, which typically runs on the same host or internal network as Tautulli, with access to RFC-1918 address space, to issue an outbound HTTP request to any attacker-specified URL. This issue has been patched in version 2.17.0.

CVSS base score
5.3
Published
2026-03-30
OWASP
A10 Server-Side Request Forgery
Orthogonal defect classification
Checking
Code defect classification
Missing Check
Category
Input Validation and Sanitization
Subcategory
Server-Side Request Forgery (SSRF)
Accessibility scope
Remote
Impact
Information Disclosure
Affected component
Tautulli
Fixed by upgrading
Yes

Solution

Upgrade to Tautulli version 2.17.0.

Vulnerable code sample

import requests
from flask import Flask, request, Response, stream_with_context

# This is a representative example of the vulnerable code.
# The actual implementation in Tautulli's webstart.py might differ slightly
# but would follow the same vulnerable pattern.

app = Flask(__name__)

@app.route('/pms_image_proxy')
def pms_image_proxy():
    # The 'img' parameter is taken directly from the user's request arguments.
    image_url = request.args.get('img')

    if not image_url:
        return 'Missing img parameter', 400

    # The vulnerability lies here: any URL starting with 'http' is processed.
    # There is no validation to ensure the URL points to a trusted host (like the Plex server).
    # This allows an attacker to specify any URL, including internal network addresses.
    if image_url.lower().startswith('http'):
        try:
            # The server makes a GET request to the user-supplied URL.
            # This is the Server-Side Request Forgery (SSRF).
            proxied_request = requests.get(image_url, stream=True, timeout=5)

            # The response from the arbitrary URL is then streamed back to the user,
            # completing the proxy behavior.
            return Response(
                stream_with_context(proxied_request.iter_content(chunk_size=1024)),
                content_type=proxied_request.headers.get('Content-Type')
            )
        except requests.exceptions.RequestException:
            # If the request fails (e.g., timeout, connection error), return an error.
            return 'Failed to fetch image', 502

    # Logic for handling other types of image paths (e.g., local Plex paths)
    # would follow here, but is not relevant to the vulnerability.
    return 'Invalid img parameter', 400

Patched code sample

import os
from urllib.parse import urlsplit

# In a real Tautulli application, this information would be retrieved
# from the application's configuration.
# We simulate it here for demonstration purposes.
PLEX_CONFIG = {
    'host': '192.168.1.100',
    'port': 32400,
    'use_ssl': False
}

def get_plex_server_netloc():
    """
    Constructs the network location (hostname:port) of the configured
    Plex Media Server. This is used as a whitelist for allowed requests.
    """
    scheme = "https" if PLEX_CONFIG['use_ssl'] else "http"
    host = PLEX_CONFIG['host']
    port = PLEX_CONFIG['port']
    
    # Create a full URL and parse it to reliably get the netloc
    full_url = f"{scheme}://{host}:{port}"
    return urlsplit(full_url).netloc

def pms_image_proxy_fixed(img_url_param):
    """
    A representation of the fixed /pms_image_proxy endpoint.

    This function validates that the user-supplied 'img' parameter points
    explicitly to the configured Plex Media Server, preventing Server-Side
    Request Forgery (SSRF) attacks.
    """
    try:
        # Get the allowed network location (e.g., '192.168.1.100:32400')
        plex_server_netloc = get_plex_server_netloc()

        # Parse the URL provided by the user
        parsed_img_url = urlsplit(img_url_param)

        # 1. Validate the scheme is either http or https.
        if parsed_img_url.scheme not in ('http', 'https'):
            # The scheme is invalid (e.g., 'file://'), so reject the request.
            # In a web framework, this would return a 400 Bad Request.
            return None

        # 2. THE FIX: Validate that the hostname and port of the user-supplied
        # URL exactly match the configured Plex Media Server's hostname and port.
        if parsed_img_url.netloc != plex_server_netloc:
            # Hostname/port mismatch. This is a potential SSRF attack.
            # Reject the request.
            # In a web framework, this would return a 400 Bad Request.
            return None

        # If both checks pass, the URL is considered safe to forward to Plex.
        # The logic to proxy the request to the Plex transcoder would go here.
        print(f"Validation successful: Forwarding request for '{img_url_param}'")
        return img_url_param

    except (ValueError, TypeError):
        # Handle malformed or non-string URLs.
        # In a web framework, this would return a 400 Bad Request.
        return None

# --- Demonstration of the fix ---

# Example 1: A legitimate request for an image on the configured Plex server.
# This request will be allowed.
legitimate_url = "http://192.168.1.100:32400/photo/:/transcode?width=150&height=150&url=/library/metadata/123/thumb/456"
pms_image_proxy_fixed(legitimate_url)

print("-" * 20)

# Example 2: An SSRF attempt to target an external, attacker-controlled server.
# This request will be blocked by the hostname check.
ssrf_external_url = "http://attacker-website.com/collect_data"
if not pms_image_proxy_fixed(ssrf_external_url):
    print(f"Blocked malicious request to external host: '{ssrf_external_url}'")

print("-" * 20)

# Example 3: An SSRF attempt to target a different internal service (e.g., a router).
# This request will also be blocked by the hostname check.
ssrf_internal_url = "http://192.168.1.1/admin"
if not pms_image_proxy_fixed(ssrf_internal_url):
    print(f"Blocked malicious request to internal host: '{ssrf_internal_url}'")

Payload

/pms_image_proxy?img=http://127.0.0.1:8080

Cite this entry

@misc{vaitp:cve202631804,
  title        = {{Unauthenticated SSRF in Tautulli's image proxy via the 'img' parameter.}},
  author       = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
  year         = {2026},
  note         = {VAITP Python Vulnerability Dataset, entry CVE-2026-31804},
  howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2026-31804/}}
}
Introducing the "VAITP dataset": a specialized repository of Python vulnerabilities and patches, meticulously compiled for the use of the security research community. As Python's prominence grows, understanding and addressing potential security vulnerabilities become crucial. Crafted by and for the cybersecurity community, this dataset offers a valuable resource for researchers, analysts, and developers to analyze and mitigate the security risks associated with Python. Through the comprehensive exploration of vulnerabilities and corresponding patches, the VAITP dataset fosters a safer and more resilient Python ecosystem, encouraging collaborative advancements in programming security.

The supreme art of war is to subdue the enemy without fighting.

Sun Tzu – “The Art of War”

:: Shaping the future through research and ingenuity ::