GHSA-889w-m37p-88m5: CSRF

Published Oct 9, 2026
·
Updated

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/setuserpermission (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 sessionlifetime 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 setuserpermission(self, user: str, permission: int, role: int) -> None: self.pyload.db.setpermission(user, permission) self.pyload.db.setrole(user, role)

It carries no @permission(...) decorator and its body never calls clearallusersessions or any other session-invalidation routine.

The only call site in the entire codebase that pairs a permission/role change with clearallusersessions is the WebUI's updateusers() route, src/pyload/webui/app/blueprints/jsonblueprint.py:442-444:

api.setuserpermission(name, data["permission"], data["role"]) if waschanged: clearallusersessions(name)

A repo-wide grep for setuserpermission|setUserPermission returns exactly 3 hits: the @legacy alias registration (core/api/init.py:1652), the method definition itself (:1654), and this one call site (jsonblueprint.py:442). No other caller exists.

pyLoad's own public RPC dispatcher reaches Api.setuserpermission directly, bypassing jsonblueprint.py entirely. src/pyload/webui/app/blueprints/apiblueprint.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)(jsonrequestbody)) — i.e. it invokes any method on the Api object by name, including setuserpermission (also reachable via form/multipart bodies at :72/:77). The only authorization gate before that dispatch, at apiblueprint.py:50, is:

if not api.isauthorized(func, {"role": userinfo["role"], "permission": userinfo["permission"]}):

isauthorized (core/api/init.py:1419-1432) returns True outright when the caller's role is Role.ADMIN; otherwise it requires funcname in permmap. setuserpermission has no @permission decorator, so it is absent from permmap (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 — parsepermissions(session) derives the caller's effective admin/permission bits from session.get("role") and session.get("perms"). - These values are written once by setsession() (helpers.py:223-234), called only from appblueprint.py:88 (login) and :100 (autologin). A repo-wide grep for beforerequest inside src/pyload/ returns nothing — no hook anywhere refreshes a live session from the database. - isauthenticated(session) (helpers.py:261-266) is exactly: user = session.get("name") authenticated = session.get("authenticated", False) return authenticated and api.userexists(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 apikeyauth (helpers.py:393-404), when no X-API-Key header is supplied, flask.g.userinfo 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 userinfo that apiblueprint.py:50 feeds into isauthorized() for every /api/<func> call made over a cookie session.

The exposure window is bounded, not indefinite: default.cfg:47 sets sessionlifetime = 44640 (minutes, ≈31 days), wired into PERMANENTSESSIONLIFETIME at webui/app/init.py:130-131, and SESSIONREFRESHEACHREQUEST = 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/setuserpermission (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.setuserpermission updates pyload.db.setpermission / pyload.db.setrole 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. apikeyauth's cookie branch rebuilds userinfo from the stale session fields (role/perms as of step 1), isauthorized() / parsepermissions() evaluate against those stale values, and the call succeeds as if the revocation never happened. 4. This persists for up to ~31 days (default sessionlifetime) 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 clearallusersessions(name) call into Api.setuserpermission itself (ideally also adopting the apikey-cache purge pattern already used in removeuser, 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/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.testclient()) 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 (apiblueprint.rpc(), apikeyauth, isauthorized(), parsepermissions(), 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/getuserdir (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 WTFCSRFMETHODS default excludes GET), keeping the reproduction focused on session staleness rather than CSRF plumbing. - The admin's POST /api/setuserpermission call used X-API-Key header authentication rather than a cookie. This is a precondition, not a simplification of the bug: apikeyauth (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.setuserpermission's body (core/api/init.py:1652-1656) does not branch on how the caller authenticated, and the isauthorized() gate that runs before it (apiblueprint.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. - Full script, README with exact from-nothing run instructions, and the verbatim captured output are in the evidence bundle: ghsa/poc-bundles/pyload-poc/ (zipped copy: ghsa/poc-bundles/pyload-poc.zip).

Output (ran: 2026-09-10)

STEP 1: victim logs in through the real WebUI /login route, keeping the session cookie ============================================================================== POST /login -> status 302 Set-Cookie: ['pyloadsession8000=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/getuserdir, @permission(Perms.SETTINGS)) and it succeeds ============================================================================== GET /api/getuserdir (victim cookie) -> status 200 body: "/tmp/pyloadpocuserdiry9fl5y10"

============================================================================== 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 jsonblueprint.py's updateusers()) ============================================================================== POST /api/setuserpermission (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 setuserpermission: {'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/getuserdir (SAME victim cookie, post-demotion) -> status 200 body: "/tmp/pyloadpocuserdiry9fl5y10" 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/getuserdir (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.

Reproduce from nothing

sh 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/testapi.py -q # -> 12 passed

/tmp/pyload-poc-venv/bin/python3 /path/to/ghsa/poc-bundles/pyload-poc/pocstalesession.py

Impact The header-present branch of apikeyauth (helpers.py:370) reads role/permission fresh from the database via getuserbyid on every request. This bug is specific to the cookie/session-based auth path (helpers.py:393-404).

pyload-poc.zip

Affected Software

1 affected component
pip/pyload-ng>=0.5.0b3.dev98<=0.5.0b3.dev101

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Compensating control

    Move the clear_all_user_sessions(name) call into Api.set_user_permission so permission or role changes invalidate the target user's existing sessions across the /api/<func> RPC and WebUI entry points.

Event History

Oct 9, 2026
Advisory Published
via GitHub·05:09 PM
Data Sourced
via GitHub·05:09 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Which users remain affected after their access is revoked or reduced?

A non-admin user who was already logged in when an administrator changes their role or permissions can retain the authorization data stored in their existing Flask cookie session. This affects both WebUI requests and subsequent calls to the documented /api/<func> RPC surface made with that cookie.

2

Does the demoted user need to take any action to retain access?

No. If the user has an existing session at the time of the permission change, their session continues to use the pre-revocation role and permission bits without any further action from them.

3

How long can stale privileges remain usable?

They can remain usable until the session expires or the user voluntarily logs out. The stated default session_lifetime is approximately 31 days.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203