GitHub Security Advisory: GHSA-mhc8-p3jx-84mm
Package: wger (PIP)
Severity: CRITICAL (CVSS 9.9)
CWE: CWE-863: Incorrect Authorization
Vulnerable versions: <= 2.5
Patched in: 2.6

Summary: wger: cross-tenant password reset and plaintext disclosure via gym=None bypass

Details:
### Summary

The `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:

```python
# VULNERABLE - wger/gym/views/user.py
if request.user.userprofile.gym != user.userprofile.gym:
    return HttpResponseForbidden()
```

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:

```python
[CREDENTIAL_REDACTED]_generator()
user.set_password(password)
user.save()
return render(request, 'user/trainer_login.html', {'password': password, ...})
```

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**:

```diff
--- a/wger/gym/views/user.py
+++ b/wger/gym/views/user.py
-    if request.user.userprofile.gym != user.userprofile.gym:
-        return HttpResponseForbidden()
+    trainer_gym_id = request.user.userprofile.gym_id   # raw FK int
+    member_gym_id  = user.userprofile.gym_id
+
+    if trainer_gym_id is None or trainer_gym_id != member_gym_id:
+        return HttpResponseForbidden()
```

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`.

### PoC

Tested 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`:

```
POST /en/user/login HTTP/1.1
Host: target
Content-Type: application/x-www-form-urlencoded

username=trainer1&password=[REDACTED]&csrfmiddlewaretoken=[R