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
- Run
pip show pyload-ngin your project to see the installed version, including copies pulled in by other packages. - Or paste your lockfile into the checker: it lists every package with a known vulnerability, this one included.
- Or run
npx shippable-dev scan localhost:3000in 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.
- No fixed version of
pyload-ngyet. Replace or remove it, or apply the workaround in the advisory below. - 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
- github.com/pyload/pyload/security/advisories/GHSA-889w-m37p-88m5
- github.com/pyload/pyload/commit/3e726ba271a6b1015e3a0ea6dc7e46a8f71a62ad
- github.com/advisories/GHSA-889w-m37p-88m5
Merged from GitHub Advisory Database. Last updated 23 Oct 2026. All vulnerabilities · RSS · JSON