PYSEC-2026-2990

Vulnerability from pysec - Published: 2026-07-13 15:02 - Updated: 2026-07-13 16:05
VLAI
Details

Summary

pyLoad caches role and permission in the session at login and continues to authorize requests using these cached values, even after an admin changes the user's role/permissions in the database.

As a result, an already logged-in user can keep old (revoked) privileges until logout/session expiry, enabling continued privileged actions.

This is a core authorization/session-consistency issue and is not resolved by toggling an optional security feature.

Details

The WebUI auth flow stores authorization state in session:

  • src/pyload/webui/app/helpers.py:187-200
  • set_session(...) writes:
    • "role": user_info["role"]
    • "perms": user_info["permission"]

Authorization checks later trust cached session values:

  • src/pyload/webui/app/helpers.py:134-151
  • parse_permissions(...) reads session.get("role") / session.get("perms")
  • src/pyload/webui/app/helpers.py:225-230
  • is_authenticated(...) only verifies authenticated and api.user_exists(user) (existence), not fresh role/permission
  • src/pyload/webui/app/helpers.py:267-275
  • login_required(...) uses parse_permissions(s) for allow/deny decisions
  • src/pyload/webui/app/helpers.py:356-365
  • API session auth path also trusts s["role"] and s["perms"]

Role/permission updates are written to DB but active sessions are not invalidated/refreshed:

  • src/pyload/webui/app/blueprints/json_blueprint.py:389-434
  • update_users(...) calls api.set_user_permission(...) and returns
  • src/pyload/core/api/__init__.py:1643-1645
  • set_user_permission(...) updates DB role/permission only

Default exposure window is long:

  • src/pyload/core/config/default.cfg:47
  • session_lifetime = 44640 minutes (~31 days)

Therefore, privilege revocation is not enforced immediately for active sessions.

Note on duplicates: - This appears distinct from CVE-2023-0227 (session validity after user deletion) because this report is about stale authorization after role/permission changes while the user still exists.

PoC

#!/usr/bin/env python3
"""
Repro: stale session privilege after role/permission changes.

This PoC is source-based and leaves no persistent state.
It validates that:
1) Role/permission are cached into session at login.
2) Authorization checks read role/permission from session, not fresh DB values.
3) User updates write DB permission/role without invalidating active sessions.
4) Default session lifetime is long, increasing stale-privilege exposure window.
"""

from __future__ import annotations

import pathlib
import re
from typing import Iterable


ROOT = pathlib.Path(__file__).resolve().parent / "pyload" / "src" / "pyload"


def read(rel: str) -> str:
    return (ROOT / rel).read_text(encoding="utf-8")


def has_any(text: str, patterns: Iterable[str]) -> bool:
    return all(re.search(p, text, re.MULTILINE) for p in patterns)


def main() -> None:
    helpers = read("webui/app/helpers.py")
    json_blueprint = read("webui/app/blueprints/json_blueprint.py")
    api_init = read("core/api/__init__.py")
    default_cfg = (ROOT / "core/config/default.cfg").read_text(encoding="utf-8")

    checks = {
        "set_session_caches_role_perms": has_any(
            helpers,
            [
                r'def\\s+set_session\\(',
                r'"role"\\s*:\\s*user_info\\["role"\\]',
                r'"perms"\\s*:\\s*user_info\\["permission"\\]',
            ],
        ),
        "is_authenticated_only_checks_user_exists": has_any(
            helpers,
            [
                r'def\\s+is_authenticated\\(',
                r'api\\s*=\\s*flask\\.current_app\\.config\\["PYLOAD_API"\\]',
                r'return\\s+authenticated\\s+and\\s+api\\.user_exists\\(user\\)',
            ],
        ),
        "parse_permissions_reads_session_cache": has_any(
            helpers,
            [
                r'def\\s+parse_permissions\\(',
                r'session\\.get\\("role"\\)\\s*==\\s*Role\\.ADMIN',
                r'session\\.get\\("perms"\\)',
            ],
        ),
        "login_required_uses_parse_permissions_session": has_any(
            helpers,
            [
                r'def\\s+login_required\\(',
                r'if\\s+is_authenticated\\(s\\):',
                r'perms\\s*=\\s*parse_permissions\\(s\\)',
            ],
        ),
        "api_session_auth_uses_cached_role_perms": has_any(
            helpers,
            [
                r'if\\s+is_authenticated\\(s\\):',
                r'"role"\\s*:\\s*s\\["role"\\]',
                r'"permission"\\s*:\\s*s\\["perms"\\]',
            ],
        ),
        "update_users_changes_db_without_session_invalidation": has_any(
            json_blueprint,
            [
                r'def\\s+update_users\\(',
                r'api\\.set_user_permission\\(name,\\s*data\\["permission"\\],\\s*data\\["role"\\]\\)',
                r'return\\s+jsonify\\(True\\)',
            ],
        ),
        "set_user_permission_only_updates_db": has_any(
            api_init,
            [
                r'def\\s+set_user_permission\\(',
                r'self\\.pyload\\.db\\.set_permission\\(user,\\s*permission\\)',
                r'self\\.pyload\\.db\\.set_role\\(user,\\s*role\\)',
            ],
        ),
        "default_session_lifetime_long": re.search(
            r'session_lifetime\\s*:\\s*"Session lifetime \\(minutes\\)"\\s*=\\s*44640',
            default_cfg,
            re.MULTILINE,
        )
        is not None,
    }

    for name, ok in checks.items():
        print(f"{name}={ok}")

    stale_privilege_repro_success = all(checks.values())
    print(f"stale_privilege_repro_success={stale_privilege_repro_success}")

    # Cleanup: this PoC creates/modifies no runtime/data files.
    print("cleanup_done=True")


if __name__ == "__main__":
    main()
set_session_caches_role_perms=True
is_authenticated_only_checks_user_exists=True
parse_permissions_reads_session_cache=True
login_required_uses_parse_permissions_session=True
api_session_auth_uses_cached_role_perms=True
update_users_changes_db_without_session_invalidation=True
set_user_permission_only_updates_db=True
default_session_lifetime_long=True
stale_privilege_repro_success=True
cleanup_done=True

Impact

  • Privilege revocation is not immediate for active sessions.
  • A user can continue using stale, previously granted privileges (including admin) after downgrade/restriction.
  • This can allow continued access to privileged WebUI/API actions until session expiry or manual logout/session reset.
Impacted products
Name purl
pyload-ng pkg:pypi/pyload-ng

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "pyload-ng",
        "purl": "pkg:pypi/pyload-ng"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "last_affected": "0.5.0b3.dev97"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ],
      "versions": [
        "0.5.0a5.dev528",
        "0.5.0a5.dev532",
        "0.5.0a5.dev535",
        "0.5.0a5.dev536",
        "0.5.0a5.dev537",
        "0.5.0a5.dev539",
        "0.5.0a5.dev540",
        "0.5.0a5.dev545",
        "0.5.0a5.dev562",
        "0.5.0a5.dev564",
        "0.5.0a5.dev565",
        "0.5.0a6.dev570",
        "0.5.0a6.dev578",
        "0.5.0a6.dev587",
        "0.5.0a7.dev596",
        "0.5.0a8.dev602",
        "0.5.0a9.dev615",
        "0.5.0a9.dev629",
        "0.5.0a9.dev632",
        "0.5.0a9.dev641",
        "0.5.0a9.dev643",
        "0.5.0a9.dev655",
        "0.5.0a9.dev806",
        "0.5.0b1.dev1",
        "0.5.0b1.dev2",
        "0.5.0b1.dev3",
        "0.5.0b1.dev4",
        "0.5.0b1.dev5",
        "0.5.0b2.dev10",
        "0.5.0b2.dev11",
        "0.5.0b2.dev12",
        "0.5.0b2.dev9",
        "0.5.0b3.dev13",
        "0.5.0b3.dev14",
        "0.5.0b3.dev17",
        "0.5.0b3.dev18",
        "0.5.0b3.dev19",
        "0.5.0b3.dev20",
        "0.5.0b3.dev21",
        "0.5.0b3.dev22",
        "0.5.0b3.dev24",
        "0.5.0b3.dev26",
        "0.5.0b3.dev27",
        "0.5.0b3.dev28",
        "0.5.0b3.dev29",
        "0.5.0b3.dev30",
        "0.5.0b3.dev31",
        "0.5.0b3.dev32",
        "0.5.0b3.dev33",
        "0.5.0b3.dev34",
        "0.5.0b3.dev35",
        "0.5.0b3.dev38",
        "0.5.0b3.dev39",
        "0.5.0b3.dev40",
        "0.5.0b3.dev41",
        "0.5.0b3.dev42",
        "0.5.0b3.dev43",
        "0.5.0b3.dev44",
        "0.5.0b3.dev45",
        "0.5.0b3.dev46",
        "0.5.0b3.dev47",
        "0.5.0b3.dev48",
        "0.5.0b3.dev49",
        "0.5.0b3.dev50",
        "0.5.0b3.dev51",
        "0.5.0b3.dev52",
        "0.5.0b3.dev53",
        "0.5.0b3.dev54",
        "0.5.0b3.dev57",
        "0.5.0b3.dev60",
        "0.5.0b3.dev62",
        "0.5.0b3.dev64",
        "0.5.0b3.dev65",
        "0.5.0b3.dev66",
        "0.5.0b3.dev67",
        "0.5.0b3.dev68",
        "0.5.0b3.dev69",
        "0.5.0b3.dev70",
        "0.5.0b3.dev71",
        "0.5.0b3.dev72",
        "0.5.0b3.dev73",
        "0.5.0b3.dev74",
        "0.5.0b3.dev75",
        "0.5.0b3.dev76",
        "0.5.0b3.dev77",
        "0.5.0b3.dev78",
        "0.5.0b3.dev79",
        "0.5.0b3.dev80",
        "0.5.0b3.dev81",
        "0.5.0b3.dev82",
        "0.5.0b3.dev85",
        "0.5.0b3.dev87",
        "0.5.0b3.dev88",
        "0.5.0b3.dev89",
        "0.5.0b3.dev90",
        "0.5.0b3.dev91",
        "0.5.0b3.dev92",
        "0.5.0b3.dev93",
        "0.5.0b3.dev94",
        "0.5.0b3.dev95",
        "0.5.0b3.dev96",
        "0.5.0b3.dev97"
      ]
    }
  ],
  "aliases": [
    "CVE-2026-41133",
    "GHSA-66hx-chf7-3332"
  ],
  "details": "### Summary\npyLoad caches `role` and `permission` in the session at login and continues to authorize requests using these cached values, even after an admin changes the user\u0027s role/permissions in the database.\n\nAs a result, an already logged-in user can keep old (revoked) privileges until logout/session expiry, enabling continued privileged actions.\n\nThis is a core authorization/session-consistency issue and is not resolved by toggling an optional security feature.\n\n### Details\nThe WebUI auth flow stores authorization state in session:\n\n- `src/pyload/webui/app/helpers.py:187-200`\n  - `set_session(...)` writes:\n    - `\"role\": user_info[\"role\"]`\n    - `\"perms\": user_info[\"permission\"]`\n\nAuthorization checks later trust cached session values:\n\n- `src/pyload/webui/app/helpers.py:134-151`\n  - `parse_permissions(...)` reads `session.get(\"role\")` / `session.get(\"perms\")`\n- `src/pyload/webui/app/helpers.py:225-230`\n  - `is_authenticated(...)` only verifies `authenticated` and `api.user_exists(user)` (existence), not fresh role/permission\n- `src/pyload/webui/app/helpers.py:267-275`\n  - `login_required(...)` uses `parse_permissions(s)` for allow/deny decisions\n- `src/pyload/webui/app/helpers.py:356-365`\n  - API session auth path also trusts `s[\"role\"]` and `s[\"perms\"]`\n\nRole/permission updates are written to DB but active sessions are not invalidated/refreshed:\n\n- `src/pyload/webui/app/blueprints/json_blueprint.py:389-434`\n  - `update_users(...)` calls `api.set_user_permission(...)` and returns\n- `src/pyload/core/api/__init__.py:1643-1645`\n  - `set_user_permission(...)` updates DB role/permission only\n\nDefault exposure window is long:\n\n- `src/pyload/core/config/default.cfg:47`\n  - `session_lifetime = 44640` minutes (~31 days)\n\nTherefore, privilege revocation is not enforced immediately for active sessions.\n\nNote on duplicates:\n- This appears distinct from CVE-2023-0227 (session validity after **user deletion**) because this report is about stale authorization after **role/permission changes** while the user still exists.\n\n### PoC\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nRepro: stale session privilege after role/permission changes.\n\nThis PoC is source-based and leaves no persistent state.\nIt validates that:\n1) Role/permission are cached into session at login.\n2) Authorization checks read role/permission from session, not fresh DB values.\n3) User updates write DB permission/role without invalidating active sessions.\n4) Default session lifetime is long, increasing stale-privilege exposure window.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport pathlib\nimport re\nfrom typing import Iterable\n\n\nROOT = pathlib.Path(__file__).resolve().parent / \"pyload\" / \"src\" / \"pyload\"\n\n\ndef read(rel: str) -\u003e str:\n    return (ROOT / rel).read_text(encoding=\"utf-8\")\n\n\ndef has_any(text: str, patterns: Iterable[str]) -\u003e bool:\n    return all(re.search(p, text, re.MULTILINE) for p in patterns)\n\n\ndef main() -\u003e None:\n    helpers = read(\"webui/app/helpers.py\")\n    json_blueprint = read(\"webui/app/blueprints/json_blueprint.py\")\n    api_init = read(\"core/api/__init__.py\")\n    default_cfg = (ROOT / \"core/config/default.cfg\").read_text(encoding=\"utf-8\")\n\n    checks = {\n        \"set_session_caches_role_perms\": has_any(\n            helpers,\n            [\n                r\u0027def\\\\s+set_session\\\\(\u0027,\n                r\u0027\"role\"\\\\s*:\\\\s*user_info\\\\[\"role\"\\\\]\u0027,\n                r\u0027\"perms\"\\\\s*:\\\\s*user_info\\\\[\"permission\"\\\\]\u0027,\n            ],\n        ),\n        \"is_authenticated_only_checks_user_exists\": has_any(\n            helpers,\n            [\n                r\u0027def\\\\s+is_authenticated\\\\(\u0027,\n                r\u0027api\\\\s*=\\\\s*flask\\\\.current_app\\\\.config\\\\[\"PYLOAD_API\"\\\\]\u0027,\n                r\u0027return\\\\s+authenticated\\\\s+and\\\\s+api\\\\.user_exists\\\\(user\\\\)\u0027,\n            ],\n        ),\n        \"parse_permissions_reads_session_cache\": has_any(\n            helpers,\n            [\n                r\u0027def\\\\s+parse_permissions\\\\(\u0027,\n                r\u0027session\\\\.get\\\\(\"role\"\\\\)\\\\s*==\\\\s*Role\\\\.ADMIN\u0027,\n                r\u0027session\\\\.get\\\\(\"perms\"\\\\)\u0027,\n            ],\n        ),\n        \"login_required_uses_parse_permissions_session\": has_any(\n            helpers,\n            [\n                r\u0027def\\\\s+login_required\\\\(\u0027,\n                r\u0027if\\\\s+is_authenticated\\\\(s\\\\):\u0027,\n                r\u0027perms\\\\s*=\\\\s*parse_permissions\\\\(s\\\\)\u0027,\n            ],\n        ),\n        \"api_session_auth_uses_cached_role_perms\": has_any(\n            helpers,\n            [\n                r\u0027if\\\\s+is_authenticated\\\\(s\\\\):\u0027,\n                r\u0027\"role\"\\\\s*:\\\\s*s\\\\[\"role\"\\\\]\u0027,\n                r\u0027\"permission\"\\\\s*:\\\\s*s\\\\[\"perms\"\\\\]\u0027,\n            ],\n        ),\n        \"update_users_changes_db_without_session_invalidation\": has_any(\n            json_blueprint,\n            [\n                r\u0027def\\\\s+update_users\\\\(\u0027,\n                r\u0027api\\\\.set_user_permission\\\\(name,\\\\s*data\\\\[\"permission\"\\\\],\\\\s*data\\\\[\"role\"\\\\]\\\\)\u0027,\n                r\u0027return\\\\s+jsonify\\\\(True\\\\)\u0027,\n            ],\n        ),\n        \"set_user_permission_only_updates_db\": has_any(\n            api_init,\n            [\n                r\u0027def\\\\s+set_user_permission\\\\(\u0027,\n                r\u0027self\\\\.pyload\\\\.db\\\\.set_permission\\\\(user,\\\\s*permission\\\\)\u0027,\n                r\u0027self\\\\.pyload\\\\.db\\\\.set_role\\\\(user,\\\\s*role\\\\)\u0027,\n            ],\n        ),\n        \"default_session_lifetime_long\": re.search(\n            r\u0027session_lifetime\\\\s*:\\\\s*\"Session lifetime \\\\(minutes\\\\)\"\\\\s*=\\\\s*44640\u0027,\n            default_cfg,\n            re.MULTILINE,\n        )\n        is not None,\n    }\n\n    for name, ok in checks.items():\n        print(f\"{name}={ok}\")\n\n    stale_privilege_repro_success = all(checks.values())\n    print(f\"stale_privilege_repro_success={stale_privilege_repro_success}\")\n\n    # Cleanup: this PoC creates/modifies no runtime/data files.\n    print(\"cleanup_done=True\")\n\n\nif __name__ == \"__main__\":\n    main()\n```\n\n```text\nset_session_caches_role_perms=True\nis_authenticated_only_checks_user_exists=True\nparse_permissions_reads_session_cache=True\nlogin_required_uses_parse_permissions_session=True\napi_session_auth_uses_cached_role_perms=True\nupdate_users_changes_db_without_session_invalidation=True\nset_user_permission_only_updates_db=True\ndefault_session_lifetime_long=True\nstale_privilege_repro_success=True\ncleanup_done=True\n```\n\n### Impact\n- Privilege revocation is not immediate for active sessions.\n- A user can continue using stale, previously granted privileges (including admin) after downgrade/restriction.\n- This can allow continued access to privileged WebUI/API actions until session expiry or manual logout/session reset.",
  "id": "PYSEC-2026-2990",
  "modified": "2026-07-13T16:05:52.685017Z",
  "published": "2026-07-13T15:02:47.816792Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/pyload/pyload/security/advisories/GHSA-66hx-chf7-3332"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-41133"
    },
    {
      "type": "WEB",
      "url": "https://github.com/pyload/pyload/commit/e95804fb0d06cbb07d2ba380fc494d9ff89b68c1"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/pyload/pyload"
    },
    {
      "type": "PACKAGE",
      "url": "https://pypi.org/project/pyload-ng"
    },
    {
      "type": "ADVISORY",
      "url": "https://github.com/advisories/GHSA-66hx-chf7-3332"
    }
  ],
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "pyLoad has Stale Session Privilege After Role/Permission Change (Privilege Revocation Bypass)"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…