{"id":"GHSA-889w-m37p-88m5","title":"pyLoad: Api.set_user_permission never invalidates the target's session","summary":"pyLoad: Api.set_user_permission never invalidates the target's session","severity":"high","cvss":7.5,"cwe":["CWE-613"],"vendor":"pyload-ng","product":"pyload-ng","ecosystem":"pip","affected":["pyload-ng >= 0.5.0b3.dev98, <= 0.5.0b3.dev101"],"published":"2026-10-09","updated":"2026-10-09","sourceUpdated":"2026-10-09T17:09:08Z","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-889w-m37p-88m5","references":[{"url":"https://github.com/pyload/pyload/security/advisories/GHSA-889w-m37p-88m5"},{"url":"https://github.com/pyload/pyload/commit/3e726ba271a6b1015e3a0ea6dc7e46a8f71a62ad"},{"url":"https://github.com/advisories/GHSA-889w-m37p-88m5"}],"tags":["ghsa","pip"],"ingestedAt":"2026-10-09T18:07:39.422Z","slug":"GHSA-889w-m37p-88m5","body":"## Overview\n\n### 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)\n\n## Affected packages\n\n- `pyload-ng >= 0.5.0b3.dev98, <= 0.5.0b3.dev101`\n\n## Remediation\n\nRefer to the advisory for the patched release.","depth":"twilight","depthScore":41,"depthScoreParts":{"impact":41.3,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}