GHSA-mhc8-p3jx-84mm wger: cross-tenant password reset and plaintext disclosure via gym=None bypass
SummaryThe `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.Details**File**: `wger/gym/views/user.py`The authorization guard in `reset_user_password` (and the parallel check in `gym_permissions_user_edit`) uses Django ORM object comparison:When 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:The generated password is rendered verbatim in the response body.**Affected endpoints**:`GET /en/gym/user/<user_pk>/reset-user-password` -> `wger.gym.views.user.reset_user_password``GET /en/gym/user/<user_pk>/edit` -> `wger.gym.views.user.gym_permissions_user_edit`**Suggested patch**:The `_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`.PoCTested 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`).**Step 1** - Authenticate as trainer with `manage_gym` permission and `gym=None`:**Step 2** - Trigger cross-tenant password reset:**Step 3** - Authenticate as victim (alice) using leaked password:**RBAC Disproof Protocol** (three-scenario test):Scenario A (admin, same-gym) -> HTTP 200 (expected - documented feature)Scenario B (trainer1 gym=None -> alice gym=None) -> **HTTP 200 with plaintext password in body** (expected HTTP 403)Scenario C (trainer1 gym=1 -> alice gym=2) -> HTTP 403 (expected - guard works when gyms differ, confirms bypass is `None`-specific)Reproducibility: 2/2 runs after clean-baseline database reset.ImpactAn attacker with `gym.manage_gym` permission and `gym=None` can:Reset the password of any other `gym=None` user on the wger instance.Receive the new plaintext password in the HTTP response body.Log in as the victim immediately.Permanently lock the victim out (original password invalidated).**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.**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.This 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.
CVSS v3.1 score: 9.9