GitHub Advisoryadvisory · PyPI
GHSA-889w-m37p-88m5 pyLoad: Api.set_user_permission never invalidates the target's session
HIGH
Official page ↗
- Ecosystem
- PyPI
- Packages
- pyload-ng
- Severity
- High
- CVSS vector
- CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H
- Weaknesses
- CWE-613
- Reviewed
- 2026-10-09
Affected packages
• PyPI pyload-ng: >= 0.5.0b3.dev98, <= 0.5.0b3.dev101 (no fix yet)
Description
Summary
An admin who revokes or downgrades a non-admin user's permissions through pyLoad's own documented
`/api/` 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/` 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/` and `/api//` 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/` 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/` 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/` 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…