high · 7.5 No patch yet

pyLoad: Api.set_user_permission never invalidates the target's session

GHSA-889w-m37p-88m5 · published 9 Oct 2026

How serious is it

  • Severity: high, CVSS score 7.5 out of 10.
  • No exploit prediction (EPSS) score yet.
  • Not on the CISA list of exploited vulnerabilities.
  • No public exploit in Exploit-DB.

Is your app affected by GHSA-889w-m37p-88m5?

Paste this prompt into Claude Code, Cursor or any AI coding tool: it runs a free Shippable scan of your project and fixes what it finds. Built with Lovable, Bolt or v0? Scan your live app. Or upload your package file.

Who is affected

  • pyload-ng (PyPI) >= 0.5.0b3.dev98, <= 0.5.0b3.dev101

How to check

  1. Run pip show pyload-ng in your project to see the installed version, including copies pulled in by other packages.
  2. Or paste your lockfile into the checker: it lists every package with a known vulnerability, this one included.
  3. Or run npx shippable-dev scan localhost:3000 in your project folder. It reads your lockfile and flags this vulnerability along with every other known one.

How to fix

Copies a prompt for Claude Code, Cursor or Codex: it checks your lockfiles for this and upgrades it.
  1. No fixed version of pyload-ng yet. Replace or remove it, or apply the workaround in the advisory below.
  2. If a vulnerable version was live, check your logs and rotate the secrets that server could read.

The advisory

### 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 instanc…

Check your whole app

Dependencies are only half of it. Free and read-only: before you push, run npx shippable-dev scan localhost:3000 in your project folder, no account needed. For your live app, you prove it's yours with a one-line tag first.

References

Merged from GitHub Advisory Database. Last updated 23 Oct 2026. All vulnerabilities · RSS · JSON