GHSA-889W-M37P-88M5

GHSA-889W-M37P-88M5 is a high-severity security vulnerability in pyload-ng (pip), affecting versions >= 0.5.0b3.dev98, <= 0.5.0b3.dev101. No fixed version is listed yet.

Does this CVE actually affect you?

Kodem shows which CVEs are reachable and running in your applications, so you fix what's exploitable, not just what's listed.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Runtime intelligence, not another scanner.

Summary

pyLoad: Api.setuserpermission never invalidates the target's session

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 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).

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-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.
  • The admin's 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.
  • 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: ['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.

Reproduce from nothing

# 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

Impact

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-poc.zip

GHSA-889W-M37P-88M5 has a CVSS score of 7.5 (High). The vector is network-reachable, low privileges required, and no user interaction. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. No fixed version is listed yet, so configuration controls and monitoring matter more in the interim.

Affected versions

pyload-ng (>= 0.5.0b3.dev98, <= 0.5.0b3.dev101)

Security releases

Not available

Kodem intelligence

Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.

Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.

Already deployed Kodem?

See it in your environmentNew to Kodem? Get a demo →

Remediation advice

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.

Frequently Asked Questions

  1. What is GHSA-889W-M37P-88M5? GHSA-889W-M37P-88M5 is a high-severity security vulnerability in pyload-ng (pip), affecting versions >= 0.5.0b3.dev98, <= 0.5.0b3.dev101. No fixed version is listed yet.
  2. How severe is GHSA-889W-M37P-88M5? GHSA-889W-M37P-88M5 has a CVSS score of 7.5 (High). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
  3. Which versions of pyload-ng are affected by GHSA-889W-M37P-88M5? pyload-ng (pip) versions >= 0.5.0b3.dev98, <= 0.5.0b3.dev101 is affected.
  4. Is there a fix for GHSA-889W-M37P-88M5? No fixed version is listed for GHSA-889W-M37P-88M5 yet. Monitor the advisory for updates and apply mitigations in the interim.
  5. Is GHSA-889W-M37P-88M5 exploitable, and should I be worried? Whether GHSA-889W-M37P-88M5 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
  6. What actually determines whether GHSA-889W-M37P-88M5 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.

Other vulnerabilities in pyload-ng

Stop the waste.
Protect your environment with Kodem.