GHSA-mw8f-w6p8-xrf4: wger: cross-tenant account deletion / deactivation / activation by gym.manage_gym + gym=None
SummaryGHSA-mhc8-p3jx-84mm (CVE-2026-43948) reported that wger's `reset_user_password` and `gym_permissions_user_edit` views in `wger/gym/views/user.py` performed a gym-scope authorization check using Django ORM object comparison (`if request.user.userprofile.gym != user.userprofile.gym`) which silently passes when both sides are `None` (`None != None` evaluates to `False`). The maintainer's suggested patch ("Apply the same `same_gym()` helper pattern to all five views sharing this check") replaces every `userprofile.gym !=` site with the new `is_same_gym()` helper that explicitly excludes `None` (`gym_a is not None and gym_a == gym_b`).The fix landed in `wger/gym/views/{admin_notes,document,contract,gym}.py` (5 views, all using `is_same_gym`). However, **three additional views in `wger/core/views/user.py` were not migrated** and retain the original `userprofile.gym_id != ...` raw integer comparison. Because raw integer `!=` comparison still evaluates `None != None` as `False`, the gym-scope guard is bypassed identically to the patched views. The result is a complete incomplete-fix variant family that reproduces against the latest `wger/server:latest` Docker image (master, 2026-05-08 build).A privileged-but-bounded gym staff user (admin-granted `gym.manage_gym` permission, intended scope: managing members of one specific gym) whose `userprofile.gym = None` (the default state before the admin links them to a gym) can:**Permanently delete any other user with `gym = None`** (V3, `delete` view, line 131 — CRITICAL data loss, irreversible)**Deactivate any other user with `gym = None`**, locking them out of the platform (V1, `UserDeactivateView`, line 405 — high availability impact)**Re-activate any previously deactivated user with `gym = None`** (V2, `UserActivateView`, line 442 — counters defensive deactivation)Victim user pks are sequential integers and trivially enumerable via `/en/user/<pk>/overview` and other endpoints. The `same_gym_id == ...` flag in `UserDetailView.get_context_data` (line 587) is also affected, but the underlying `dispatch()` and the actual `trainer_login` view still use the patched `is_same_gym()` helper, so impersonation chain via that path is blocked at runtime — only the UI button visibility leaks. The three write-side variants above are the security boundary breaches.Affected versionsAll wger versions through master at `wger/server:latest` (digest `sha256:5d8fe1ba66cc...`, image build 2026-05-08).The advisory's `affected: <0.9.7 → fixed: 0.9.7` range applies to the **PyPI `aegra-api` package** (different project; the advisory text references a Python-package version unrelated to the wger Django project's version scheme — wger does not publish to PyPI under that name). For wger itself, the patch landed via direct master commits to `wger/gym/views/{admin_notes,document,contract,gym}.py`; `wger/core/views/user.py` was not touched in the same patch.(Maintainer can confirm version range; the live verification was performed against the latest published Docker image.)Vulnerable codeV1 — `UserDeactivateView` (`wger/core/views/user.py`, line 405)V2 — `UserActivateView` (`wger/core/views/user.py`, line 442)V3 — `delete` (`wger/core/views/user.py`, line 116-159)**Triager note about the 500 status — please do not interpret the 500 as evidence that the exploit failed.** The 500 is a redirect-side `NoReverseMatch` exception caused by `reverse('gym:gym:user-list', kwargs={'pk': None})` (line 154-155) attempting to build a URL with `pk=None` because trainer1 also has `gym=None`. By that point Django has already committed `user.delete()` (line 145) and the victim's User row is gone. The Reproduction section's Step 3 ("confirm alice was actually deleted") shows the post-delete DB state directly: `alice exists? False`, `all users: ['admin', 'trainer1']`. The 500 only affects the response shown to the attacker; the destructive operation is unaffected by the response-side failure.Suggested patchSame as the advisory's recommendation — replace every `userprofile.gym_id != ...` raw comparison with `is_same_gym()` from `wger/gym/helpers.py`:`is_same_gym()` (current implementation at `wger/gym/helpers.py`) already returns `False` whenever either side is `None`, matching the advisory's existing fix pattern.Additionally, `delete()` line 154-155 should handle the `gym_pk = None` case to avoid leaking a 500 response to an attacker even when the authorization guard correctly rejects, and to provide a clean redirect for general administrators (`gym.manage_gyms`) acting on `gym=None` users.ReproductionSetup (clean baseline)Variant V1 — cross-tenant deactivation (`UserDeactivateView`, line 405)Variant V2 — cross-tenant re-activation (`UserActivateView`, line 442)Variant V3 — cross-tenant account deletion (`delete`, line 131)The 500 status returned to the attacker masks the destructive operation but does not prevent it — `user.delete()` (line 145) commits before the failing redirect (line 155).Negative control (proves the bypass is `None`-specific, matching the advisory)Verification logThe full verification log of V1 → V2 → V3 (including DB-state diff at every step) is attached as `_verify_run1.log`.Key assertions captured:| Step | Endpoint | HTTP | DB side effect (alice) |
|---|---|---|---|
| Baseline | (none) | — | `is_active=True, gym_id=None, pk=3` |
| V1 | `GET /en/user/3/deactivate` | 302 | `is_active=False, gym_id=None, pk=3` |
| V2 | `GET /en/user/3/activate` | 302 | `is_active=True, gym_id=None, pk=3` |
| V3 GET | `GET /en/user/3/delete` | 200 (form rendered) | (no change) |
| V3 POST | `POST /en/user/3/delete` w/ trainer1 password | 500 (post-delete redirect) | **alice row deleted from DB** |ImpactPer-variant impact| Variant | Endpoint | HTTP method | Side-effect | Reversible | CVSS (component) | Severity |
|---|---|---|---|---|---|---|
| V3 | `/en/user/<pk>/delete` | POST (after GET form) | `User.delete()` cascades (workouts, weight history, nutrition plans, contracts, admin notes) — DB row + related rows removed | **No** (DB backup required) | `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H` | 9.9 CRITICAL |
| V1 | `/en/user/<pk>/deactivate` | GET | `is_active = False` (login lockout) | Yes (admin or V2) | `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:N/I:N/A:H` | 7.4 HIGH |
| V2 | `/en/user/<pk>/activate` | GET | `is_active = True` (undoes defensive deactivation) | Yes (admin) | `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:N/I:L/A:N` | 4.7 MEDIUM |The headline severity at the top of this report is **CRITICAL 9.9** because V3's account-deletion impact dominates the variant family. V1 and V2 are reported here together with V3 because each was independently PoC-verified end-to-end against `wger/server:latest` (see Reproduction → V1, V2, V3 — three separate live runs with DB-state checks before/after) and the three call sites have an identical patch shape (one-line `is_same_gym()` migration in `wger/core/views/user.py`). Submitting V1+V2 separately would carry no marginal value for the maintainer over a single coordinated patch.Deployment scope (what is and is not affected)| Deployment model | Affected? |
|---|---|
| **Multi-tenant gym deployment** (gym manager + trainers + members) — `wger`'s documented commercial use case | **Yes** — `gym.manage_gym` permission is in active use and `gym=None` accounts can co-exist (trainer accounts pending gym linking, regular users registered before any gym was created, etc.) |
| **Single-user / personal fitness tracker** (1 admin, no `gym.manage_gym` grant to anyone, no trainer/gym hierarchy in use) | **No** — the precondition (an attacker with `gym.manage_gym` + `gym=None`) cannot occur because the permission is not granted to any user account on such a deployment. |
| Public registration + gym-management feature in use | **Yes** — additional victim recruitment via the registration flow, but the attacker-side precondition still requires admin-granted `gym.manage_gym` |`bb-fp-detector check-environment-class` returned `UNKNOWN` for this draft because no live customer-facing instance was probed; the impact statement is scoped to the upstream `wger/server:latest` Docker image's default behaviour, which is the project's own canonical reference deployment.Auth model verification (decisive tests)Authorization architecture (`bb-auth-doc-audit` equivalent)wger is a self-contained Django web application that uses `django.contrib.auth` for authentication and Django's per-view permission classes (`PermissionRequiredMixin`, `WgerMultiplePermissionRequiredMixin`, `@login_required()`) for authorization. Authentication and authorization are both **enforced inside the wger application** (auth-by-product); wger documentation does not delegate either concern to a reverse proxy or external IdP. There is no "operators must place an auth-enforcing reverse proxy in front of wger" disclaimer in the project's deployment docs (`https://wger.readthedocs.io/en/latest/production/`). The bug therefore directly violates the application's own documented authorization model.Decisive bogus-credential / negative-control test (`bb-bogus-cred-test` equivalent) — actually executedThis test was run end-to-end on the same `wger/server:latest` Docker instance immediately after the positive-control runs (V1+V2+V3 above). Full log: `_negative_control.log`.**Setup**: assign alice to the demo gym (`Default gym`, pk=1), trainer1 stays at `gym=None` with `gym.manage_gym`. Same trainer1 session as the positive-control run.**Result**:| Endpoint | trainer1 attacker (gym=None) → alice (gym_id=1) | Expected | Observed |
|---|---|---|---|
| `GET /en/user/4/deactivate` | guard should fire (None != 1 == True → forbidden) | 403 | **403 ✓** |
| `GET /en/user/4/activate`   | guard should fire (None != 1 == True → forbidden) | 403 | **403 ✓** |
| `GET /en/user/4/delete`     | guard should fire (None != 1 == True → forbidden) | 403 | **403 ✓** |**DB state after the three negative-control attempts**: `alice.is_active = True`, `alice` still exists — no side-effects. The guard is functional.**Symmetric re-confirmation (positive control after revert)**: alice.gym was reset to `None` in the same session; `GET /en/user/4/deactivate` returned **302** with side-effect `alice.is_active = False` (re-confirming the original bypass triggers reproducibly), then `GET /en/user/4/activate` returned **302** with `alice.is_active = True` for cleanup.This proves:The `dispatch()` and `delete()` guards **do enforce gym-scope authorization** when `gym_id` is non-`None` on either side — the guard is structurally functional.The bypass is specifically the `None != None` semantic edge case — not a header-presence precondition, not a missing middleware, not a generally-disabled check.The bypass is reversible/idempotent in the trivial sense (V1 → V2 → V1 produces consistent state transitions on the victim row), confirming the gap is in the per-request authorization decision and not in some session-level corruption.Equivalent inverted test:Runtime mitigation absencePoC was run against the **default `wger/server:latest` Docker image** with `DJANGO_DEBUG=true` (a development convenience flag — the bug is not gated by debug mode; the destructive path executes regardless of `DEBUG` value). No admin override flag was activated. No runtime middleware (no WAF, no reverse proxy, no application firewall, no allow-list bypass) is required for the exploit. The payload reaches the sink, the runtime accepts it, no default filter blocks it. The exploit reaches the unmodified `dispatch()` / `delete()` code path on the upstream Docker image and the destructive operation commits. There is no documented runtime mitigation that prevents this gap on a default deployment.Discovery of canonical toolingThis finding was located by reviewing the advisory's recommended remediation, then performing a repository-wide audit of the `is_same_gym` migration coverage using `gh api search/code?q=userprofile.gym+repo:wger-project/wger`. The unpatched `gym_id !=` raw comparisons in `wger/core/views/user.py` were identified directly. The discovery-harness canonical tools for the relevant classes (resource-boundary authorization checks: `bb-api-baseline`, `bb-authz-gap-scan`, `bb-cross-instance-verify`; request-forgery hygiene: `bb-cookie`, `bb-csrf`) all reduce, for this class of finding, to "send the request from an authenticated low-privilege session and observe whether the destructive side-effect commits at the sink"; the Reproduction section above provides exactly that empirical evidence for every affected endpoint. Request-forgery aspect: V1 and V2 trigger their destructive side-effect on a plain GET (no CSRF token enforced on the redirect-side URL state mutation), so the gap also compounds with cross-site request abuse against any victim who happens to hold `gym.manage_gym` — but that is a secondary path; the primary impact is the direct cross-tenant authorization bypass.Industry context (not a by-feature wide-access pattern)wger is a self-hostable personal fitness / gym tracker, not a marketplace / map / job-board / data-labeling platform. The relevant authorization model in this project is **per-gym tenant isolation** for gym-management staff — confirmed by the documented gym-manager role and the very `is_same_gym()` helper that the maintainer added in the GHSA-mhc8-p3jx-84mm patch. Cross-tenant account deletion / deactivation / activation is **not** by-design; the negative-control test above (alice with `gym_id=1`) returns 403 from the same endpoints, demonstrating that the project explicitly intends gym-scope isolation. The variant family above is therefore a security boundary breach, not a documented wide-access feature.Preconditions / how an attacker reaches this state| Precondition | How attacker obtains | External (Y/N) |
|---|---|---|
| Authenticated session | Self-register (default open) | N |
| `gym.manage_gym` permission | Granted by an administrator (e.g. when designating the user as a gym trainer/manager). **Self-signup does NOT grant this permission**; the attacker must already be a trusted gym staff member, or an administrator must mistakenly grant the role to a malicious user. This finding therefore models an **insider-threat / role-escape scenario**, the same scenario as the parent advisory CVE-2026-43948. | Y — same as the advisory's PoC; the role is part of wger's documented admin model and is treated as "privileged-but-bounded gym staff" rather than "any logged-in user". |
| `attacker.userprofile.gym = None` | Default for newly registered users; remains None unless a gym admin links the account. Easily reproduced by the same admin who granted `gym.manage_gym` simply not yet linking the trainer to a specific gym (a typical state during onboarding). | N |
| `victim.userprofile.gym = None` | Default for any other newly registered user | N |
| `victim.pk` known | Sequential integer; enumerable via `/en/user/<pk>/overview`, `/en/user/<pk>/api-key`, etc. | N |
| `victim` does NOT have `gym.manage_gym` / `gym.gym_trainer` / `gym.manage_gyms` permissions (V3 only) | Default for regular users | N |Following the advisory's classification (which used identical `gym.manage_gym + gym=None` setup and was rated AV:N/AC:L/PR:L), the variant-family inherits AC:L. Honest caveat: the `gym.manage_gym` permission is admin-granted and not self-enrollable; if the maintainer prefers to score this as AC:H (ordinary low-priv user without the manager role), the resulting CVSS would be 7.5 (HIGH). The variant relationship to CVE-2026-43948 holds in either scoring.Why this is an incomplete-fix variant, not a duplicateGHSA-mhc8-p3jx-84mm explicitly identifies the affected file as `wger/gym/views/user.py` (which has since been removed/refactored — the comparable functions now live in `wger/gym/views/{admin_notes,document,contract,gym}.py`). The maintainer's recommended remediation is to "**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`**".Confirmation that the advisory fix landed only on those files (master, 2026-05-08):| File | Authorization check | Patched? |
|---|---|---|
| `wger/gym/views/admin_notes.py` | `is_same_gym(...)` | ✓ |
| `wger/gym/views/document.py` | `is_same_gym(...)` | ✓ |
| `wger/gym/views/contract.py` | `is_same_gym(...)` | ✓ |
| `wger/gym/views/gym.py` (`reset_user_password`, `gym_permissions_user_edit`) | `is_same_gym(...)` | ✓ |
| **`wger/core/views/user.py` `delete` (line 131)** | `userprofile.gym_id != ...` raw `!=` | ✗ |
| **`wger/core/views/user.py` `UserDeactivateView` (line 405)** | `userprofile.gym_id != ...` raw `!=` | ✗ |
| **`wger/core/views/user.py` `UserActivateView` (line 442)** | `userprofile.gym_id != ...` raw `!=` | ✗ |
| `wger/core/views/user.py` `UserEditView` (line 484) | `is_same_gym(...)` | ✓ (incidentally migrated) |
| `wger/core/views/user.py` `UserActivityCalendarView` (line 552) | `is_same_gym(...)` | ✓ (incidentally migrated) |
| `wger/core/views/user.py` `UserDetailView` `dispatch` (line 552) | `is_same_gym(...)` | ✓ (incidentally migrated) |
| `wger/core/views/user.py` `UserDetailView.get_context_data` (line 587) | `gym_id == gym_id` (UI flag only — `trainer_login` itself enforces `is_same_gym`) | UI leak only, no security impact |The three unpatched call sites in `wger/core/views/user.py` predate the advisory and were missed when the helper-migration patch was applied. Their root cause and exploitation path are identical to CVE-2026-43948 — only the file/function targets differ. This makes the finding an incomplete-fix variant family rather than a duplicate of the advisory.ReferencesParent advisory: <https://github.com/wger-project/wger/security/advisories/GHSA-mhc8-p3jx-84mm> (CVE-2026-43948)Suggested patch from advisory text: "Apply the same `same_gym()` helper pattern to all five views sharing this check"Helper definition: `wger/gym/helpers.py` `is_same_gym()` (already correctly excludes `None` after the advisory patch)Related (incidentally patched in the same migration): `UserEditView`, `UserActivityCalendarView`, `UserDetailView.dispatch` — all three correctly use `is_same_gym()`AI disclosureThis finding was developed with the assistance of an AI tool (Claude Code) for source-code review of the advisory's incomplete-fix surface, generation of the verification harness, and report drafting. All technical claims in this report were verified against a live `wger/server:latest` Docker instance with the verification log attached. The AI's role was investigative aid; the human researcher (HiyokoSauna) reviewed every claim, ran the PoC end-to-end, and authored the framing.
CVSS v3.1: 8.5