Patch Forecast
Yellow warning
Plan into this sprint.

GHSA-889w-m37p-88m5

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

Risk score
50/100
CVSS
7.5 v3.1
Published
9 Oct 2026

Open in the live forecast Today's forecast

What to know

What it is
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 us…
Affected
pyload-ng (pip) >= 0.5.0b3.dev98, <= 0.5.0b3.dev101
Fix
No patched version listed yet: check the advisory for mitigations.
Exploitation
No known exploitation

Why it is Yellow

  • CVSS 7.5 high
  • On your watchlist: PyPI

Affected packages

PackageEcosystemVulnerableFixed in
pyload-ngpip>= 0.5.0b3.dev98, <= 0.5.0b3.dev101No fix yet

References

Original description

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

Last activity 9 Oct 2026. Patch Forecast updates every hour.