{"answers":{"affected":"2 open source packages ship an affected version, including pyload-ng.","exploited":"There is no known exploitation of GHSA-889w-m37p-88m5 in the wild and no public exploit code.","fix":"No fix has been published for GHSA-889w-m37p-88m5 yet. Limit exposure of the affected software until one is available.","what":"GHSA-889w-m37p-88m5 is a missing authorization vulnerability in pyload-ng, published on 9 Oct 2026. An attacker needs administrator access to exploit it, with no action from the victim."},"fix_prompt":"Check this codebase for GHSA-889w-m37p-88m5 and fix it if it is affected.\n\nWhat it is: GHSA-889w-m37p-88m5 is a missing authorization vulnerability in pyload-ng, published on 9 Oct 2026. An attacker needs administrator access to exploit it, with no action from the victim.\nExploitation: There is no known exploitation of GHSA-889w-m37p-88m5 in the wild and no public exploit code.\n\nAffected packages and fixed versions:\n- pyload-ng (PyPI): affected from 0.5.0b3.dev98 up to 0.5.0b3.dev101; no fix listed\n- pyload-ng (PyPI): affected from 0.5.0b3.dev98 up to 0.5.0b3.dev101; no fix listed\n\nTask:\n1. Search the dependency manifests and lockfiles (for example package.json, package-lock.json, requirements.txt, poetry.lock, uv.lock, pom.xml, build.gradle, go.mod, Cargo.lock, Gemfile.lock) and any Dockerfiles for the affected packages, including transitive dependencies.\n2. If an affected version is in use, upgrade it to the fixed version for its release line with the project's package manager, so the lockfile is regenerated. Prefer the smallest upgrade that fixes it.\n3. If there is no fix, or the upgrade would break the build, do not guess: explain the risk and the safest workaround instead.\n4. Run the tests and summarise what changed and why.\n5. If nothing in the project is affected, say so and change nothing.\n\nReference: https://insights.sbomify.com/GHSA-889w-m37p-88m5/ (Markdown: https://insights.sbomify.com/GHSA-889w-m37p-88m5/index.md)","id":"GHSA-889w-m37p-88m5","markdown":"https://insights.sbomify.com/GHSA-889w-m37p-88m5/index.md","name":"GHSA-889w-m37p-88m5","record":{"aliases":[],"analysis":{"component_type":"web_application","fix_type":"patch","impacts":["privilege_escalation","security_bypass"],"network_reachable":"yes","non_default_config":false,"product_category":"pyLoad download manager","requires_auth":"high_privilege","summary":"Admins revoking user permissions via API fail to invalidate sessions, allowing demoted users to retain elevated privileges for up to 31 days.","user_interaction":"not_required","vuln_class":"missing_authorization"},"analysis_generated_by":"qwen3.8-flash-next","assigner":null,"cpe":[],"cvss":{"score":null,"severity":null,"source":null,"vector":null,"version":null},"cwes":[],"description":"### Summary\nAn admin who revokes or downgrades a non-admin user's permissions through pyLoad's own documented\n`/api/\u003cfunc\u003e` RPC surface — e.g. `POST /api/set_user_permission` (or its legacy alias\n`/api/setUserPermission`) — updates the target's database row but never invalidates that user's\nexisting Flask cookie session. The demoted user keeps their pre-revocation `role`/`permission` bits\non every subsequent WebUI page load and every subsequent `/api/\u003cfunc\u003e` call made with that cookie,\nfor up to the default `session_lifetime` of ~31 days, or until they voluntarily log out. No action\nby the demoted user is required beyond already being logged in at the time of revocation.\n\n### Details\nThe vulnerable method, verbatim, at `src/pyload/core/api/__init__.py:1652-1656`:\n\n```\n    @legacy(\"setUserPermission\")\n    @post\n    def set_user_permission(self, user: str, permission: int, role: int) -\u003e None:\n        self.pyload.db.set_permission(user, permission)\n        self.pyload.db.set_role(user, role)\n```\n\nIt carries no `@permission(...)` decorator and its body never calls `clear_all_user_sessions` or any\nother session-invalidation routine.\n\nThe **only** call site in the entire codebase that pairs a permission/role change with\n`clear_all_user_sessions` is the WebUI's `update_users()` route,\n`src/pyload/webui/app/blueprints/json_blueprint.py:442-444`:\n\n```\n        api.set_user_permission(name, data[\"permission\"], data[\"role\"])\n        if was_changed:\n            clear_all_user_sessions(name)\n```\n\nA repo-wide grep for `set_user_permission|setUserPermission` returns exactly 3 hits: the `@legacy`\nalias registration (`core/api/__init__.py:1652`), the method definition itself (`:1654`), and this one\ncall site (`json_blueprint.py:442`). No other caller exists.\n\npyLoad's own public RPC dispatcher reaches `Api.set_user_permission` directly, bypassing\n`json_blueprint.py` entirely. `src/pyload/webui/app/blueprints/api_blueprint.py:21-26` registers\n`rpc(func, args=\"\")` on `/api/\u003cfunc\u003e` and `/api/\u003cfunc\u003e/\u003cargs\u003e` for `GET`/`POST`; line 68 dispatches\nwith `response = jsonify(getattr(api, func)(**json_request_body))` — i.e. it invokes any method on\nthe `Api` object by name, including `set_user_permission` (also reachable via form/multipart bodies\nat `:72`/`:77`). The only authorization gate before that dispatch, at `api_blueprint.py:50`, is:\n\n```\nif not api.is_authorized(func, {\"role\": user_info[\"role\"], \"permission\": user_info[\"permission\"]}):\n```\n\n`is_authorized` (`core/api/__init__.py:1419-1432`) returns `True` outright when the **caller's**\nrole is `Role.ADMIN`; otherwise it requires `func_name in perm_map`. `set_user_permission` has no\n`@permission` decorator, so it is absent from `perm_map` (the module comment at `core/api/__init__.py:37`\nstates \"unlisted functions are for admins only\"). This gate concerns only the caller's own\nauthorization — it says nothing about, and never touches, the **target** user's existing session.\n\n### The stale session survives\nThe demoted user's session was populated once, at login, and is never refreshed:\n\n- `helpers.py:170,179,183-185` — `parse_permissions(session)` derives the caller's effective\n  admin/permission bits from `session.get(\"role\")` and `session.get(\"perms\")`.\n- These values are written once by `set_session()` (`helpers.py:223-234`), called only from\n  `app_blueprint.py:88` (login) and `:100` (autologin). A repo-wide grep for `before_request` inside\n  `src/pyload/` returns nothing — no hook anywhere refreshes a live session from the database.\n- `is_authenticated(session)` (`helpers.py:261-266`) is exactly:\n  ```\n  user = session.get(\"name\")\n  authenticated = session.get(\"authenticated\", False)\n  return authenticated and api.user_exists(user)\n  ```\n  It re-validates only that the username still exists — never that the session's cached `role`/`perms`\n  still match the current database values.\n- Inside `apikey_auth` (`helpers.py:393-404`), when no `X-API-Key` header is supplied, `flask.g.user_info`\n  is built as `{\"id\": s[\"id\"], \"name\": s[\"name\"], \"role\": s[\"role\"], \"permission\": s[\"perms\"]}` —\n  straight from the same stale, login-time-cached session fields. This is exactly the `user_info` that\n  `api_blueprint.py:50` feeds into `is_authorized()` for every `/api/\u003cfunc\u003e` call made over a cookie\n  session.\n\nThe exposure window is bounded, not indefinite: `default.cfg:47` sets `session_lifetime = 44640`\n(minutes, ≈31 days), wired into `PERMANENT_SESSION_LIFETIME` at `webui/app/__init__.py:130-131`, and\n`SESSION_REFRESH_EACH_REQUEST = False` at `:128` means the window is a hard ~31 days from login, not\nextended by continued activity.\n\n## Attack Sequence\nStarting privilege: an already-authenticated non-admin user, plus an admin who legitimately uses\npyLoad's own documented API instead of the WebUI page.\n\n1. Victim user logs in normally, obtains a Flask session cookie (`A`) with `role`/`perms` reflecting\n   their current (elevated) permissions.\n2. Admin revokes or downgrades the victim's permissions via\n   `POST /api/set_user_permission` (or legacy `POST /api/setUserPermission`) with the victim's\n   username and the new, lower `permission`/`role` values — using `curl`, a script, or any RPC client,\n   rather than the WebUI's \"Manage Users\" HTML page. `Api.set_user_permission` updates\n   `pyload.db.set_permission` / `pyload.db.set_role` and returns; no session anywhere is touched.\n3. Using cookie `A` (never invalidated), the victim loads any WebUI admin-gated page, or calls any\n   `/api/\u003cfunc\u003e` endpoint gated only on role/permission. `apikey_auth`'s cookie branch rebuilds\n   `user_info` from the stale session fields (`role`/`perms` as of step 1), `is_authorized()` /\n   `parse_permissions()` evaluate against those stale values, and the call succeeds as if the\n   revocation never happened.\n4. This persists for up to ~31 days (default `session_lifetime`) or until the victim logs out,\n   whichever comes first.\n\nThis sequence was confirmed against a real, locally-run pyLoad instance, not just traced from source.\nSee Section 10 for the exact commands, real HTTP status codes and response bodies, and the\nprecondition that the admin step here used `X-API-Key` header auth rather than a cookie (explained\nthere).\n\n## Suggested Fix\nMove the `clear_all_user_sessions(name)` call into `Api.set_user_permission` itself (ideally also\nadopting the apikey-cache purge pattern already used in `remove_user`,\n`core/api/__init__.py:1626-1636`), so that every entry point — WebUI and `/api/\u003cfunc\u003e` RPC alike —\ninherits session invalidation automatically, rather than requiring each caller to remember to invoke\nit separately.\n\n### PoC\nThis was executed for real against pyLoad's own code, not merely predicted from reading it. The\nverifier pass that produced Sections 1-9 (`ghsa/CLAIMS-pyload.md`) source-traced every claim but never\nran a PoC; this section closes that gap.\n\n### Method and preconditions\n\n- Target: `pyload/pyload` at the pinned commit `31e341fd41d2dac9fffa3683a756edc54bcdeb77`\n  (`develop` branch), installed editable (`pip install -e \".[test]\"`) into a fresh Python 3.12.3\n  virtualenv. No source files were modified.\n- Transport: Flask's in-process test client (`app.test_client()`) against the real\n  `pyload.core.Core` / `pyload.webui.app` Flask app object, not a bound TCP server. This is the same\n  bootstrap the project's own `tests/integration/conftest.py` fixture uses, and it exercises the\n  identical route/view/decorator code (`api_blueprint.rpc()`, `apikey_auth`, `is_authorized()`,\n  `parse_permissions()`, Flask-WTF CSRF protection) that a real `127.0.0.1:8000`-bound server would\n  run. No real network socket was opened and no third-party or public host was touched, only this\n  locally-run copy of the target's own cloned code.\n- Two users: `pyload`/`pyload` (pyLoad's own automatically-created default admin account) and a\n  freshly created non-admin `victim` user with the `Perms.SETTINGS` permission bit.\n- Elevated-action probe: `GET /api/get_userdir` (","ecosystems":["PyPI"],"exploits":{"exploitdb":null,"first_seen":null,"github_poc":null,"max_poc_stars":null,"metasploit":null,"nuclei":null,"refs":[],"tier":null},"has_fix":false,"headline":"pyLoad: Api.set_user_permission never invalidates the target's session","id":"GHSA-889w-m37p-88m5","indexable":true,"kev":null,"kind":"advisory","links":{"ecosystem":"PyPI","product":null,"products":[],"type":"missing_authorization","year":"2026"},"n_packages":2,"n_references":null,"packages":[{"ecosystem":"PyPI","fixed":[],"introduced":["0.5.0b3.dev98"],"last_affected":["0.5.0b3.dev101"],"package":"pyload-ng","purl":null,"source":"GHSA-889w-m37p-88m5"},{"ecosystem":"PyPI","fixed":[],"introduced":["0.5.0b3.dev98"],"last_affected":["0.5.0b3.dev101"],"package":"pyload-ng","purl":"pkg:pypi/pyload-ng","source":"GHSA-889w-m37p-88m5"}],"products":null,"published":"2026-10-09","related":[],"sources":[{"class":"attribution","licence":"CC BY 4.0","name":"GitHub Advisory Database","prefix":"GHSA"}],"ssvc":{"automatable":null,"exploitation":null,"technical_impact":null},"state":null,"updated":null,"vendors":null},"summary":"Admins revoking user permissions via API fail to invalidate sessions, allowing demoted users to retain elevated privileges for up to 31 days.","url":"https://insights.sbomify.com/GHSA-889w-m37p-88m5/"}