{"answers":{"affected":"2 open source packages ship an affected version, including wger.","exploited":"There is no known exploitation of GHSA-mw8f-w6p8-xrf4 in the wild and no public exploit code.","fix":"No fix has been published for GHSA-mw8f-w6p8-xrf4 yet. Limit exposure of the affected software until one is available.","what":"GHSA-mw8f-w6p8-xrf4 is a missing authorization vulnerability in wger, published on 20 May 2026. Any signed-in user can exploit it over the network, with no action from the victim."},"fix_prompt":"Check this codebase for GHSA-mw8f-w6p8-xrf4 and fix it if it is affected.\n\nWhat it is: GHSA-mw8f-w6p8-xrf4 is a missing authorization vulnerability in wger, published on 20 May 2026. Any signed-in user can exploit it over the network, with no action from the victim.\nExploitation: There is no known exploitation of GHSA-mw8f-w6p8-xrf4 in the wild and no public exploit code.\n\nAffected packages and fixed versions:\n- wger (PyPI): affected all versions up to 2.5; no fix listed\n- wger (PyPI): affected all versions up to 2.5; no fix listed\n\nTask:\n1. Search the dependency manifests and lockfiles (for example package.json, package-lock.json, requirements.txt, poetry.lock, uv.lock, pom.xml, build.gradle, go.mod, Cargo.lock, Gemfile.lock) and any Dockerfiles for the affected packages, including transitive dependencies.\n2. If an affected version is in use, upgrade it to the fixed version for its release line with the project's package manager, so the lockfile is regenerated. Prefer the smallest upgrade that fixes it.\n3. If there is no fix, or the upgrade would break the build, do not guess: explain the risk and the safest workaround instead.\n4. Run the tests and summarise what changed and why.\n5. If nothing in the project is affected, say so and change nothing.\n\nReference: https://insights.sbomify.com/GHSA-mw8f-w6p8-xrf4/ (Markdown: https://insights.sbomify.com/GHSA-mw8f-w6p8-xrf4/index.md)","id":"GHSA-mw8f-w6p8-xrf4","markdown":"https://insights.sbomify.com/GHSA-mw8f-w6p8-xrf4/index.md","name":"GHSA-mw8f-w6p8-xrf4","record":{"aliases":[],"analysis":{"component_type":"web_application","fix_type":"patch","impacts":["data_modify","denial_of_service"],"network_reachable":"yes","non_default_config":false,"product_category":"wger fitness app","requires_auth":"low_privilege","summary":"A gym staff user with no assigned gym can delete or deactivate other unassigned users due to incomplete authorization checks in the wger fitness application.","user_interaction":"not_required","vuln_class":"missing_authorization"},"analysis_generated_by":"qwen3.8-flash-next","assigner":null,"cpe":[],"cvss":{"score":null,"severity":null,"source":null,"vector":null,"version":null},"cwes":[],"description":"## 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/\u003cpk\u003e/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: \u003c0.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","ecosystems":["PyPI"],"exploits":{"exploitdb":null,"first_seen":null,"github_poc":null,"max_poc_stars":null,"metasploit":null,"nuclei":null,"refs":[],"tier":null},"has_fix":false,"headline":"wger: cross-tenant account deletion / deactivation / activation by gym.manage_gym + gym=None","id":"GHSA-mw8f-w6p8-xrf4","indexable":true,"kev":null,"kind":"advisory","links":{"ecosystem":"PyPI","product":null,"products":[],"type":"missing_authorization","year":"2026"},"n_packages":2,"n_references":null,"packages":[{"ecosystem":"PyPI","fixed":[],"introduced":["0"],"last_affected":["2.5"],"package":"wger","purl":null,"source":"GHSA-mw8f-w6p8-xrf4"},{"ecosystem":"PyPI","fixed":[],"introduced":["0"],"last_affected":["2.5"],"package":"wger","purl":"pkg:pypi/wger","source":"GHSA-mw8f-w6p8-xrf4"}],"products":null,"published":"2026-05-20","related":[],"sources":[{"class":"attribution","licence":"CC BY 4.0","name":"GitHub Advisory Database","prefix":"GHSA"}],"ssvc":{"automatable":null,"exploitation":null,"technical_impact":null},"state":null,"updated":null,"vendors":null},"summary":"A gym staff user with no assigned gym can delete or deactivate other unassigned users due to incomplete authorization checks in the wger fitness application.","url":"https://insights.sbomify.com/GHSA-mw8f-w6p8-xrf4/"}