GHSA-889w-m37p-88m5High· 7.5▾ TwilightpyLoad: Api.set_user_permission never invalidates the target's session
▾ Twilight zone — High severity, or a signal on a lesser flaw
impact 41.3 · likelihood 0 · exploitation 0
Need a working PoC? Pro members can cast a request and our team develops one — it lands right here.
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.
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 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").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.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.
Starting privilege: an already-authenticated non-admin user, plus an admin who legitimately uses pyLoad's own documented API instead of the WebUI page.
A) with role/perms reflecting
their current (elevated) permissions.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.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.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).
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.
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.
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.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.pyload/pyload (pyLoad's own automatically-created default admin account) and a
freshly created non-admin victim user with the Perms.SETTINGS permission bit.GET /api/get_userdir (core/api/__init__.py:1434-1437, decorated
@permission(Perms.SETTINGS) @get), chosen because it is gated on an ordinary non-admin permission bit
and because it is a GET request, so it needs no CSRF token (Flask-WTF's WTF_CSRF_METHODS default
excludes GET), keeping the reproduction focused on session staleness rather than CSRF plumbing.POST /api/set_user_permission call used X-API-Key header authentication rather than
a cookie. This is a precondition, not a simplification of the bug: apikey_auth
(helpers.py:335-413) marks the whole /api/<func> route CSRF-exempt at decoration time, then, only
in the cookie-session branch (no API key), manually calls csrf.protect() (helpers.py:397) before
dispatch -- so a cookie-authenticated admin call needs a valid CSRF token, and an X-API-Key call
does not (helpers.py:361-384 returns before that check is reached). This has no bearing on the
vulnerability itself: Api.set_user_permission's body (core/api/__init__.py:1652-1656) does not
branch on how the caller authenticated, and the is_authorized() gate that runs before it
(api_blueprint.py:50) only checks the ADMIN CALLER's own role, never the target user's session. A
cookie-authenticated admin request (fetching a CSRF token the same way the victim's login step does)
reaches the identical code path and was not separately re-run.ghsa/poc-bundles/pyload-poc/ (zipped copy:
ghsa/poc-bundles/pyload-poc.zip).STEP 1: victim logs in through the real WebUI /login route, keeping the session cookie
==============================================================================
POST /login -> status 302
Set-Cookie: ['pyload_session_8000=Kgq1peWlETCpxj4MSOSY-yxy-v3EYgIjiLxYT7kLgO0; Expires=Sun, 11 Oct 2026 12:30:51 GMT; HttpOnly; Path=/; SameSite=Lax']
victim's cached session contents right after login: role=1 perms=128 authenticated=True
==============================================================================
STEP 2: BASELINE -- victim, using cookie A, calls a SETTINGS-gated GET endpoint (/api/get_userdir, @permission(Perms.SETTINGS)) and it succeeds
==============================================================================
GET /api/get_userdir (victim cookie) -> status 200
body: "/tmp/pyload_poc_userdir_y9fl5y10"
==============================================================================
STEP 3: admin revokes the victim's permissions via pyLoad's own public /api/<func> RPC dispatcher (X-API-Key header, NOT the WebUI 'Manage Users' page, NOT json_blueprint.py's update_users())
==============================================================================
POST /api/set_user_permission (admin API key) -> status 200
body: null
==============================================================================
STEP 4: confirm via a fresh DB read that the victim's permission really is downgraded in the database
==============================================================================
victim DB row after set_user_permission: {'id': 2, 'name': 'victim', 'permission': 0, 'role': 1, 'template': 'default', 'email': ''}
==============================================================================
STEP 5 (the bug): using the victim's ORIGINAL, UNCHANGED cookie A from step 1, repeat the exact same SETTINGS-gated call
==============================================================================
GET /api/get_userdir (SAME victim cookie, post-demotion) -> status 200
body: "/tmp/pyload_poc_userdir_y9fl5y10"
RESULT: call STILL SUCCEEDS after the permission revocation. The stale cookie session retains the pre-revocation SETTINGS bit.
victim's cached session contents after admin's revocation (unchanged since login): role=1 perms=128
==============================================================================
STEP 6 (control): the SAME demoted victim, authenticating via the X-API-Key header instead of the cookie, correctly gets the NEW, reduced permission immediately
==============================================================================
GET /api/get_userdir (victim X-API-Key, post-demotion) -> status 401
body: {"error": "Access denied"}
RESULT: API-key path correctly reflects the demotion immediately (access denied), confirming the bug is cookie-session-specific.
(Log lines from pyLoad's own logger, interleaved with this output in the raw capture, are omitted here
for readability; the full unedited capture, including those log lines, is output.txt in the evidence
bundle.)
This matches the predicted behavior in Sections 4 and 6 exactly: the demoted victim's original cookie
still returns 200 with the pre-revocation permission's data (step 5), while the same demotion is
correctly and immediately visible through X-API-Key auth (step 6, 401). Nothing in the live run
contradicted the source-level analysis; no additional gate blocked the bypass, and no easier bypass
was found.
The run was repeated from a completely freshly built virtualenv (pip install -e ".[test]" into a new
venv, no reuse of any prior install) with identical outcomes -- only the random API key values, the
signed session cookie value, and the temp directory path differ between runs, as expected.
# starting from a pyload/pyload checkout pinned at 31e341fd41d2dac9fffa3683a756edc54bcdeb77
cd /path/to/pyload-clone-at-31e341f
python3 -m venv /tmp/pyload-poc-venv
/tmp/pyload-poc-venv/bin/pip install -U pip
/tmp/pyload-poc-venv/bin/pip install -e ".[test]"
# optional sanity check against the project's own test suite
/tmp/pyload-poc-venv/bin/python -m pytest tests/integration/test_api.py -q # -> 12 passed
/tmp/pyload-poc-venv/bin/python3 /path/to/ghsa/poc-bundles/pyload-poc/poc_stale_session.py
The header-present branch of apikey_auth (helpers.py:370) reads role/permission fresh from the database via get_user_by_id
on every request. This bug is specific to the cookie/session-based auth path (helpers.py:393-404).
pyload-ng >= 0.5.0b3.dev98, <= 0.5.0b3.dev101Refer to the advisory for the patched release.
Connected by shared product, vendor, weakness, or advisory.
GHSA-jq7h-wrvp-3rgxHigh· 7.5pyLoad: Privilege revocation and password change through the REST API do not invalidate the user's session
GHSA-p3pr-8f3m-4qp8Medium· 6.4pyLoad WindowsPhoneNotify addon: non-admin SETTINGS user triggers SSRF via unguarded http.client notification host
GHSA-fr26-jjhm-638cHighpyLoad: Tar extraction creates device nodes and FIFOs (member types not filtered; tarfile extractall without filter=)
GHSA-68w4-83fh-f2w8High· 8.1pyload-ng: getUserData/get_userdata exposed at Perms.ANY allow any authenticated account to brute-force the administrator password
GHSA-9q47-3cm2-2rp8MediumpyLoad: Rate-Limit Bypass and Audit-Log Spoofing via Trusted Client-Controlled `X-Forwarded-For` Header
CVE-2026-48484Medium· 6.5pyLoad is a free and open-source download manager written in Python