GHSA-889w-m37p-88m5: pyLoad Api.set_user_permission Never Invalidates The Target's Vulnerability

HIGH
CVSS 7.5
CWE-613
Published
Advisory Summary

GHSA-889w-m37p-88m5 is a HIGH (CVSS 7.5) api.set_user_permission never invalidates the target's vulnerability (CWE-613: Insufficient Session Expiration) affecting pyLoad libraries in the PyPI ecosystem. The issue involves api.set_user_permission never invalidates the target's session. Remediation is available in the latest vendor maintenance releases.

GHSA-889w-m37p-88m5 at a Glance

CVE Identifier N/A
GHSA Identifier GHSA-889w-m37p-88m5
Severity HIGH
CVSS v3.1 Base Score 7.5
CWE Classification CWE-613: Insufficient Session Expiration
Affected Product pyLoad
Package Ecosystem PyPI
Published Date
Last Updated

Security Impact

CVSS: 7.5

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H).

Attack Vector Network (AV:N)
Attack Complexity High (AC:H)
Privileges Required Low (PR:L)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality High (C:H)
Integrity High (I:H)
Availability High (A:H)

How to Fix GHSA-889w-m37p-88m5

To remediate this vulnerability, upgrade all affected pyLoad dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

pyload-ng fixed: Latest secure release
pip install pyload-ng>=Latest secure release
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

pip show pyload-ng

How to Check if You Are Affected

To determine whether your Python environment is affected by GHSA-889w-m37p-88m5, inspect your installed packages using pip:

pip show pyload-ng

Analyze the terminal output to locate any version of pyLoad in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than the patched release), your application is affected.

Note: The pip show pyload-ng command checks currently installed packages in your active Python environment.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-613: Insufficient Session Expiration, where according to wasc, "insufficient session expiration is when a web site permits an attacker to reuse old session credentials or session ids for authorization.". In pyLoad, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

### Summary An admin who revokes or downgrades a non-admin user's permissions through pyLoad's own documented `/api/<func>` RPC surface — e.g. `POST /api/set_user_permission` (or its legacy alias `/api/setUserPermission`) — updates the target's database row but never invalidates that user's existing Flask cookie session. The demoted user keeps their pre-revocation `role`/`permission` bits on every subsequent WebUI page load and every subsequent `/api/<func>` call made with that cookie, for up to the default `session_lifetime` of ~31 days, or until they voluntarily log out. No action by the demoted user is required beyond already being logged in at the time of revocation. ### Details The vulnerable method, verbatim, at `src/pyload/core/api/__init__.py:1652-1656`: ``` @legacy("setUserPermission") @post def set_user_permission(self, user: str, permission: int, role: int) -> None: self.pyload.db.set_permission(user, permission) self.pyload.db.set_role(user, role) ``` It carries no `@permission(...)` decorator and its body never calls `clear_all_user_sessions` or any other session-invalidation routine. The **only** call site in the entire codebase that pairs a permission/role change with `clear_all_user_sessions` is the WebUI's `update_users()` route, `src/pyload/webui/app/blueprints/json_blueprint.py:442-444`: ``` api.set_user_permission(name, data["permission"], data["role"]) if was_changed: clear_all_user_sessions(name) ``` A repo-wide grep for `set_user_permission|setUserPermission` returns exactly 3 hits: the `@legacy` alias registration (`core/api/__init__.py:1652`), the method definition itself (`:1654`), and this one call site (`json_blueprint.py:442`). No other caller exists. pyLoad's own public RPC dispatcher reaches `Api.set_user_permission` directly, bypassing `json_blueprint.py` entirely. `src/pyload/webui/app/blueprints/api_blueprint.py:21-26` registers `rpc(func, args="")` on `/api/<func>` and `/api/<func>/<args>` for `GET`/`POST`; line 68 dispatches with `response = jsonify(getattr(api, func)(**json_request_body))` — i.e. it invokes any method on the `Api` object by name, including `set_user_permission` (also reachable via form/multipart bodies at `:72`/`:77`). The only authorization gate before that dispatch, at `api_blueprint.py:50`, is: ``` if not api.is_authorized(func, {"role": user_info["role"], "permission": user_info["permission"]}): ``` `is_authorized` (`core/api/__init__.py:1419-1432`) returns `True` outright when the **caller's** role is `Role.ADMIN`; otherwise it requires `func_name in perm_map`. `set_user_permission` has no `@permission` decorator, so it is absent from `perm_map` (the module comment at `core/api/__init__.py:37` states "unlisted functions are for admins only"). This gate concerns only the caller's own authorization — it says nothing about, and never touches, the **target** user's existing session. ### The stale session survives The demoted user's session was populated once, at login, and is never refreshed: - `helpers.py:170,179,183-185` — `parse_permissions(session)` derives the caller's effective admin/permission bits from `session.get("role")` and `session.get("perms")`. - These values are written once by `set_session()` (`helpers.py:223-234`), called only from `app_blueprint.py:88` (login) and `:100` (autologin). A repo-wide grep for `before_request` inside `src/pyload/` returns nothing — no hook anywhere refreshes a live session from the database. - `is_authenticated(session)` (`helpers.py:261-266`) is exactly: ``` user = session.get("name") authenticated = session.get("authenticated", False) return authenticated and api.user_exists(user) ``` It re-validates only that the username still exists — never that the session's cached `role`/`perms` still match the current database values. - Inside `apikey_auth` (`helpers.py:393-404`), when no `X-API-Key` header is supplied, `flask.g.user_info` is built as `{"id": s["id"], "name": s["name"], "role": s["role"], "permission": s["perms"]}` — straight from the same stale, login-time-cached session fields. This is exactly the `user_info` that `api_blueprint.py:50` feeds into `is_authorized()` for every `/api/<func>` call made over a cookie session. The exposure window is bounded, not indefinite: `default.cfg:47` sets `session_lifetime = 44640` (minutes, ≈31 days), wired into `PERMANENT_SESSION_LIFETIME` at `webui/app/__init__.py:130-131`, and `SESSION_REFRESH_EACH_REQUEST = False` at `:128` means the window is a hard ~31 days from login, not extended by continued activity. ## Attack Sequence Starting privilege: an already-authenticated non-admin user, plus an admin who legitimately uses pyLoad's own documented API instead of the WebUI page. 1. Victim user logs in normally, obtains a Flask session cookie (`A`) with `role`/`perms` reflecting their current (elevated) permissions. 2. Admin revokes or downgrades the victim's permissions via `POST /api/set_user_permission` (or legacy `POST /api/setUserPermission`) with the victim's username and the new, lower `permission`/`role` values — using `curl`, a script, or any RPC client, rather than the WebUI's "Manage Users" HTML page. `Api.set_user_permission` updates `pyload.db.set_permission` / `pyload.db.set_role` and returns; no session anywhere is touched. 3. Using cookie `A` (never invalidated), the victim loads any WebUI admin-gated page, or calls any `/api/<func>` endpoint gated only on role/permission. `apikey_auth`'s cookie branch rebuilds `user_info` from the stale session fields (`role`/`perms` as of step 1), `is_authorized()` / `parse_permissions()` evaluate against those stale values, and the call succeeds as if the revocation never happened. 4. This persists for up to ~31 days (default `session_lifetime`) or until the victim logs out, whichever comes first. This sequence was confirmed against a real, locally-run pyLoad instance, not just traced from source. See Section 10 for the exact commands, real HTTP status codes and response bodies, and the precondition that the admin step here used `X-API-Key` header auth rather than a cookie (explained there). ## Suggested Fix Move the `clear_all_user_sessions(name)` call into `Api.set_user_permission` itself (ideally also adopting the apikey-cache purge pattern already used in `remove_user`, `core/api/__init__.py:1626-1636`), so that every entry point — WebUI and `/api/<func>` RPC alike — inherits session invalidation automatically, rather than requiring each caller to remember to invoke it separately. ### PoC This was executed for real against pyLoad's own code, not merely predicted from reading it. The verifier pass that produced Sections 1-9 (`ghsa/CLAIMS-pyload.md`) source-traced every claim but never ran a PoC; this section closes that gap. ### Method and preconditions - Target: `pyload/pyload` at the pinned commit `31e341fd41d2dac9fffa3683a756edc54bcdeb77` (`develop` branch), installed editable (`pip install -e ".[test]"`) into a fresh Python 3.12.3 virtualenv. No source files were modified. - Transport: Flask's in-process test client (`app.test_client()`) against the real `pyload.core.Core` / `pyload.webui.app` Flask app object, not a bound TCP server. This is the same bootstrap the project's own `tests/integration/conftest.py` fixture uses, and it exercises the identical route/view/decorator code (`api_blueprint.rpc()`, `apikey_auth`, `is_authorized()`, `parse_permissions()`, Flask-WTF CSRF protection) that a real `127.0.0.1:8000`-bound server would run. No real network socket was opened and no third-party or public host was touched, only this locally-run copy of the target's own cloned code. - Two users: `pyload`/`pyload` (pyLoad's own automatically-created default admin account) and a freshly created non-admin `victim` user with the `Perms.SETTINGS` permission bit. - Elevated-action probe: `GET /api/get_userdir` (`core/api/__init__.py:1434-1437`, decorated `@permission(Perms.SETTINGS)` `@get`), chosen because it is gated on an ordinary non-admin permission bit and because it is a `GET` request, so it needs no CSRF token (Flask-WTF's `WTF_CSRF_METHODS` default excludes `GET`), keeping the reproduction focused on session staleness rather than CSRF plumbing. - The admin's `POST /api/set_user_permission` call used `X-API-Key` header authentication rather than a cookie. This is a precondition, not a simplification of the bug: `apikey_auth` (`helpers.py:335-413`) marks the whole `/api/<func>` route CSRF-exempt at decoration time, then, only in the cookie-session branch (no API key), manually calls `csrf.protect()` (`helpers.py:397`) before dispatch -- so a cookie-authenticated admin call needs a valid CSRF token, and an `X-API-Key` call does not (`helpers.py:361-384` returns before that check is reached). This has no bearing on the vulnerability itself: `Api.set_user_permission`'s body (`core/api/__init__.py:1652-1656`) does not branch on how the caller authenticated, and the `is_authorized()` gate that runs before it (`api_blueprint.py:50`) only checks the ADMIN CALLER's own role, never the target user's session. A cookie-authenticated admin request (fetching a CSRF token the same way the victim's login step does) reaches the identical code path and was not separately re-run. - Full script, README with exact from-nothing run instructions, and the verbatim captured output are in the evidence bundle: `ghsa/poc-bundles/pyload-poc/` (zipped copy: `ghsa/poc-bundles/pyload-poc.zip`). ### Output (ran: 2026-09-10) ``` STEP 1: victim logs in through the real WebUI /login route, keeping the session cookie ============================================================================== POST /login -> status 302 Set-Cookie: ['pyload_session_8000=Kgq1peWlETCpxj4MSOSY-yxy-v3EYgIjiLxYT7kLgO0; Expires=Sun, 11 Oct 2026 12:30:51 GMT; HttpOnly; Path=/; SameSite=Lax'] victim's cached session contents right after login: role=1 perms=128 authenticated=True ============================================================================== STEP 2: BASELINE -- victim, using cookie A, calls a SETTINGS-gated GET endpoint (/api/get_userdir, @permission(Perms.SETTINGS)) and it succeeds ============================================================================== GET /api/get_userdir (victim cookie) -> status 200 body: "/tmp/pyload_poc_userdir_y9fl5y10" ============================================================================== STEP 3: admin revokes the victim's permissions via pyLoad's own public /api/<func> RPC dispatcher (X-API-Key header, NOT the WebUI 'Manage Users' page, NOT json_blueprint.py's update_users()) ============================================================================== POST /api/set_user_permission (admin API key) -> status 200 body: null ============================================================================== STEP 4: confirm via a fresh DB read that the victim's permission really is downgraded in the database ============================================================================== victim DB row after set_user_permission: {'id': 2, 'name': 'victim', 'permission': 0, 'role': 1, 'template': 'default', 'email': ''} ============================================================================== STEP 5 (the bug): using the victim's ORIGINAL, UNCHANGED cookie A from step 1, repeat the exact same SETTINGS-gated call ============================================================================== GET /api/get_userdir (SAME victim cookie, post-demotion) -> status 200 body: "/tmp/pyload_poc_userdir_y9fl5y10" RESULT: call STILL SUCCEEDS after the permission revocation. The stale cookie session retains the pre-revocation SETTINGS bit. victim's cached session contents after admin's revocation (unchanged since login): role=1 perms=128 ============================================================================== STEP 6 (control): the SAME demoted victim, authenticating via the X-API-Key header instead of the cookie, correctly gets the NEW, reduced permission immediately ============================================================================== GET /api/get_userdir (victim X-API-Key, post-demotion) -> status 401 body: {"error": "Access denied"} RESULT: API-key path correctly reflects the demotion immediately (access denied), confirming the bug is cookie-session-specific. ``` (Log lines from pyLoad's own logger, interleaved with this output in the raw capture, are omitted here for readability; the full unedited capture, including those log lines, is `output.txt` in the evidence bundle.) This matches the predicted behavior in Sections 4 and 6 exactly: the demoted victim's original cookie still returns `200` with the pre-revocation permission's data (step 5), while the same demotion is correctly and immediately visible through `X-API-Key` auth (step 6, `401`). Nothing in the live run contradicted the source-level analysis; no additional gate blocked the bypass, and no easier bypass was found. The run was repeated from a completely freshly built virtualenv (`pip install -e ".[test]"` into a new venv, no reuse of any prior install) with identical outcomes -- only the random API key values, the signed session cookie value, and the temp directory path differ between runs, as expected. ### Reproduce from nothing ```sh # starting from a pyload/pyload checkout pinned at 31e341fd41d2dac9fffa3683a756edc54bcdeb77 cd /path/to/pyload-clone-at-31e341f python3 -m venv /tmp/pyload-poc-venv /tmp/pyload-poc-venv/bin/pip install -U pip /tmp/pyload-poc-venv/bin/pip install -e ".[test]" # optional sanity check against the project's own test suite /tmp/pyload-poc-venv/bin/python -m pytest tests/integration/test_api.py -q # -> 12 passed /tmp/pyload-poc-venv/bin/python3 /path/to/ghsa/poc-bundles/pyload-poc/poc_stale_session.py ``` ### Impact The header-present branch of `apikey_auth` (`helpers.py:370`) reads role/permission fresh from the database via `get_user_by_id` on every request. This bug is specific to the cookie/session-based auth path (`helpers.py:393-404`). [pyload-poc.zip](https://github.com/user-attachments/files/32062053/pyload-poc.zip)

GHSA-889w-m37p-88m5 Affected pyLoad Versions

1 component(s)
Product / Package Ecosystem Affected Versions Fixed Version
pyload-ng
PyPI
≥ 0.5.0b3.dev98
≤ 0.5.0b3.dev101

References

Frequently Asked Questions

What is GHSA-889w-m37p-88m5?
GHSA-889w-m37p-88m5 is a security vulnerability in pyLoad involving Api.set_user_permission never invalidates the target's session under CWE-613: Insufficient Session Expiration.
Which pyLoad versions are affected?
Affected versions include releases prior to current release patches across affected packages: pyload-ng.
What version fixes GHSA-889w-m37p-88m5?
Upgrading to current release patches (or the latest patched release for your respective branch) resolves the vulnerability.
Is pyload-ng affected?
Yes, pyload-ng is affected in releases prior to Latest secure release. Upgrade your dependency to Latest secure release or later.
What is the CVSS score of GHSA-889w-m37p-88m5?
GHSA-889w-m37p-88m5 has a CVSS v3.1 base score of 7.5 (HIGH) with vector string CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H.
What CWE is associated with GHSA-889w-m37p-88m5?
GHSA-889w-m37p-88m5 is classified under CWE-613: Insufficient Session Expiration (According to WASC, "Insufficient Session Expiration is when a web site permits an attacker to reuse old session credentials or session IDs for authorization.").
How can I check PyPI dependencies for GHSA-889w-m37p-88m5?
Run the dependency tree command: 'pip show pyload-ng'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-889w-m37p-88m5",
  "modified" : "2026-10-09T17:09:07Z",
  "published" : "2026-10-09T17:09:07Z",
  "aliases" : [ ],
  "summary" : "pyLoad: Api.set_user_permission never invalidates the target's session",
  "details" : "### Summary\nAn admin who revokes or downgrades a non-admin user's permissions through pyLoad's own documented\n`/api/<func>` 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/<func>` 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) -> 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/<func>` and `/api/<func>/<args>` 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/<func>` 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/<func>` 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/<func>` 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` (`core/api/__init__.py:1434-1437`, decorated\n  `@permission(Perms.SETTINGS)` `@get`), chosen because it is gated on an ordinary non-admin permission bit\n  and because it is a `GET` request, so it needs no CSRF token (Flask-WTF's `WTF_CSRF_METHODS` default\n  excludes `GET`), keeping the reproduction focused on session staleness rather than CSRF plumbing.\n- The admin's `POST /api/set_user_permission` call used `X-API-Key` header authentication rather than\n  a cookie. This is a precondition, not a simplification of the bug: `apikey_auth`\n  (`helpers.py:335-413`) marks the whole `/api/<func>` route CSRF-exempt at decoration time, then, only\n  in the cookie-session branch (no API key), manually calls `csrf.protect()` (`helpers.py:397`) before\n  dispatch -- so a cookie-authenticated admin call needs a valid CSRF token, and an `X-API-Key` call\n  does not (`helpers.py:361-384` returns before that check is reached). This has no bearing on the\n  vulnerability itself: `Api.set_user_permission`'s body (`core/api/__init__.py:1652-1656`) does not\n  branch on how the caller authenticated, and the `is_authorized()` gate that runs before it\n  (`api_blueprint.py:50`) only checks the ADMIN CALLER's own role, never the target user's session. A\n  cookie-authenticated admin request (fetching a CSRF token the same way the victim's login step does)\n  reaches the identical code path and was not separately re-run.\n- Full script, README with exact from-nothing run instructions, and the verbatim captured output are\n  in the evidence bundle: `ghsa/poc-bundles/pyload-poc/` (zipped copy:\n  `ghsa/poc-bundles/pyload-poc.zip`).\n\n### Output (ran: 2026-09-10)\n\n```\nSTEP 1: victim logs in through the real WebUI /login route, keeping the session cookie\n==============================================================================\nPOST /login -> status 302\nSet-Cookie: ['pyload_session_8000=Kgq1peWlETCpxj4MSOSY-yxy-v3EYgIjiLxYT7kLgO0; Expires=Sun, 11 Oct 2026 12:30:51 GMT; HttpOnly; Path=/; SameSite=Lax']\nvictim's cached session contents right after login: role=1 perms=128 authenticated=True\n\n==============================================================================\nSTEP 2: BASELINE -- victim, using cookie A, calls a SETTINGS-gated GET endpoint (/api/get_userdir, @permission(Perms.SETTINGS)) and it succeeds\n==============================================================================\nGET /api/get_userdir (victim cookie) -> status 200\nbody: \"/tmp/pyload_poc_userdir_y9fl5y10\"\n\n==============================================================================\nSTEP 3: admin revokes the victim's permissions via pyLoad's own public /api/<func> RPC dispatcher (X-API-Key header, NOT the WebUI 'Manage Users' page, NOT json_blueprint.py's update_users())\n==============================================================================\nPOST /api/set_user_permission (admin API key) -> status 200\nbody: null\n\n==============================================================================\nSTEP 4: confirm via a fresh DB read that the victim's permission really is downgraded in the database\n==============================================================================\nvictim DB row after set_user_permission: {'id': 2, 'name': 'victim', 'permission': 0, 'role': 1, 'template': 'default', 'email': ''}\n\n==============================================================================\nSTEP 5 (the bug): using the victim's ORIGINAL, UNCHANGED cookie A from step 1, repeat the exact same SETTINGS-gated call\n==============================================================================\nGET /api/get_userdir (SAME victim cookie, post-demotion) -> status 200\nbody: \"/tmp/pyload_poc_userdir_y9fl5y10\"\nRESULT: call STILL SUCCEEDS after the permission revocation. The stale cookie session retains the pre-revocation SETTINGS bit.\nvictim's cached session contents after admin's revocation (unchanged since login): role=1 perms=128\n\n==============================================================================\nSTEP 6 (control): the SAME demoted victim, authenticating via the X-API-Key header instead of the cookie, correctly gets the NEW, reduced permission immediately\n==============================================================================\nGET /api/get_userdir (victim X-API-Key, post-demotion) -> status 401\nbody: {\"error\": \"Access denied\"}\nRESULT: API-key path correctly reflects the demotion immediately (access denied), confirming the bug is cookie-session-specific.\n```\n\n(Log lines from pyLoad's own logger, interleaved with this output in the raw capture, are omitted here\nfor readability; the full unedited capture, including those log lines, is `output.txt` in the evidence\nbundle.)\n\nThis matches the predicted behavior in Sections 4 and 6 exactly: the demoted victim's original cookie\nstill returns `200` with the pre-revocation permission's data (step 5), while the same demotion is\ncorrectly and immediately visible through `X-API-Key` auth (step 6, `401`). Nothing in the live run\ncontradicted the source-level analysis; no additional gate blocked the bypass, and no easier bypass\nwas found.\n\nThe run was repeated from a completely freshly built virtualenv (`pip install -e \".[test]\"` into a new\nvenv, no reuse of any prior install) with identical outcomes -- only the random API key values, the\nsigned session cookie value, and the temp directory path differ between runs, as expected.\n\n### Reproduce from nothing\n\n```sh\n# starting from a pyload/pyload checkout pinned at 31e341fd41d2dac9fffa3683a756edc54bcdeb77\ncd /path/to/pyload-clone-at-31e341f\n\npython3 -m venv /tmp/pyload-poc-venv\n/tmp/pyload-poc-venv/bin/pip install -U pip\n/tmp/pyload-poc-venv/bin/pip install -e \".[test]\"\n\n# optional sanity check against the project's own test suite\n/tmp/pyload-poc-venv/bin/python -m pytest tests/integration/test_api.py -q   # -> 12 passed\n\n/tmp/pyload-poc-venv/bin/python3 /path/to/ghsa/poc-bundles/pyload-poc/poc_stale_session.py\n```\n\n### Impact\nThe header-present branch of `apikey_auth` (`helpers.py:370`) reads role/permission fresh from the database via `get_user_by_id`\non every request. This bug is specific to the cookie/session-based auth path (`helpers.py:393-404`).\n\n[pyload-poc.zip](https://github.com/user-attachments/files/32062053/pyload-poc.zip)",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "PyPI",
      "name" : "pyload-ng"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0.5.0b3.dev98"
      }, {
        "last_affected" : "0.5.0b3.dev101"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/pyload/pyload/security/advisories/GHSA-889w-m37p-88m5"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/pyload/pyload/commit/3e726ba271a6b1015e3a0ea6dc7e46a8f71a62ad"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/pyload/pyload"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-613" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-10-09T17:09:07Z",
    "nvd_published_at" : null
  }
}