Summary
pyLoad: Api.set_user_permission never invalidates the target's session
Advisory details
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 fromsession.get("role")andsession.get("perms").- These values are written once by
set_session()(helpers.py:223-234), called only fromapp_blueprint.py:88(login) and:100(autologin). A repo-wide grep forbefore_requestinsidesrc/pyload/returns nothing — no hook anywhere refreshes a live session from the database. is_authenticated(session)(helpers.py:261-266) is exactly:
It re-validates only that the username still exists — never that the session's cacheduser = session.get("name") authenticated = session.get("authenticated", False) return authenticated and api.user_exists(user)role/permsstill match the current database values.- Inside
apikey_auth(helpers.py:393-404), when noX-API-Keyheader is supplied,flask.g.user_infois 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 theuser_infothatapi_blueprint.py:50feeds intois_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.
- Victim user logs in normally, obtains a Flask session cookie (
A) withrole/permsreflecting their current (elevated) permissions. - Admin revokes or downgrades the victim's permissions via
POST /api/set_user_permission(or legacyPOST /api/setUserPermission) with the victim's username and the new, lowerpermission/rolevalues — usingcurl, a script, or any RPC client, rather than the WebUI's "Manage Users" HTML page.Api.set_user_permissionupdatespyload.db.set_permission/pyload.db.set_roleand returns; no session anywhere is touched. - 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 rebuildsuser_infofrom the stale session fields (role/permsas of step 1),is_authorized()/parse_permissions()evaluate against those stale values, and the call succeeds as if the revocation never happened. - 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/<func> 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/pyloadat the pinned commit31e341fd41d2dac9fffa3683a756edc54bcdeb77(developbranch), 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 realpyload.core.Core/pyload.webui.appFlask app object, not a bound TCP server. This is the same bootstrap the project's owntests/integration/conftest.pyfixture 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 real127.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-adminvictimuser with thePerms.SETTINGSpermission bit. - Elevated-action probe:
GET /api/get_userdir(
References
Related vulnerabilities
All Supply chain →- MEDIUMCVE-2026-84321
Nginx UI: Standalone short tokens survive administrative revocation in cache and authenticate again after account re-enable
- MEDIUMGHSA-hjx8-qv73-f7cm
Vikunja: Webhooks and link shares survive every revocation path, so a removed collaborator keeps a live feed
- MEDIUMGHSA-4hv6-xc92-j86g
Vikunja: WebSocket authentication ignores server-side session state, so revoked sessions keep receiving live pushes
- HIGHGHSA-jq7h-wrvp-3rgx
pyLoad: Privilege revocation and password change through the REST API do not invalidate the user's session
- MEDIUMCVE-2026-107719
fast-jwt: Verifier cache accepts expired JWTs without iat.
- MEDIUMCVE-2026-107721
fast-jwt clockTolerance: Infinity silently bypasses both exp and nbf validation (and persists in the verifier cache)