GitHub Advisoryadvisory · PyPI
GHSA-mw8f-w6p8-xrf4 wger: cross-tenant account deletion / deactivation / activation by gym.manage_gym + gym=None
HIGH
Official page ↗
- Ecosystem
- PyPI
- Packages
- wger
- Severity
- High
- CVSS vector
- CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H
- Weaknesses
- CWE-862, CWE-863
- Reviewed
- 2026-05-20
Affected packages
• PyPI wger: <= 2.5 (no fix yet)
Description
Summary
GHSA-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:
1. Permanently delete any other user with `gym = None` (V3, `delete` view, line 131 — CRITICAL data loss, irreversible)
2. Deactivate any other user with `gym = None`, locking them out of the platform (V1, `UserDeactivateView`, line 405 — high availability impact)
3. 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//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 versions
• All 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 code
V1 — `UserDeactivateView` (`wger/core/views/user.py`, line 405)
```python
class UserDeactivateView(...):
permission_required = ('gym.manage_gym', 'gym.manage_gyms', 'gym.gym_trainer')
def dispatch(self, request, *args, **kwargs):
edit_user = get_object_or_404(User, pk=self.kwargs['pk'])
if not request.user.is_authenticated:
return HttpResponseForbidden()
if (
request.user.has_perm('gym.manage_gym') or request.user.has_perm('gym.gym_trainer')
) and edit_user.userprofile.gym_id != request.user.userprofile.gym_id: # ← BUG: None != None == False
return HttpResponseForbidden()
return super(UserDeactivateView, self).dispatch(request, *args, **kwargs)
def get_redirect_url(self, pk):
edit_user = get_object_or_404(User, pk=pk)
edit_user.is_active = False # ← side effect on plain GET
edit_user.save()
...
```
V2 — `UserActivateView` (`wger/core/views/user.py`, line 442)
```python
class UserActivateView(...):
permission_required = ('gym.manage_gym', 'gym.manage_gyms', 'gym.gym_trainer')
def dispatch(self, request, *args, **kwargs):
edit_user = get_object_or_404(User, pk=self.kwargs['pk'])
...
if (
request.user.has_perm('gym.manage_gym') or request.user.has_perm('gym.gym_trainer')
) and edit_user.userprofile.gym_id != request.user.userprofile.gym_id: # ← BUG: same pattern
return HttpResponseForbidden()
return super(UserActivateView, self).dispatch(request, *args, kwargs)
def get_redirect_url(self, pk):
edit_user = get_object_or_404(User, pk=pk)
edit_user.is_active = True # ← side effect on plain GET
edit_user.save()
...
```
V3 — `delete` (`wger/core/views/user.py`, line 116-159)
```python
@login_required()
def delete(request, user_pk=None):
...
if user_pk:
user = get_object_or_404(User, pk=user_pk)
if not request.user.has_perm('gym.manage_gyms') and (
not request.user.has_perm('gym.manage_gym')
or request.user.userprofile.gym_id != user.userprofile.gym_id # ← BUG (line 131)
or user.has_perm('gym.manage_gym')
or user.has_perm('gym.gym_trainer')
or user.has_perm('gym.manage_gyms')
):
return HttpResponseForbidden()
...
if request.method == 'POST':
form = PasswordConfirmationForm(data=request.POST, user=request.user)
if form.is_valid():
user.delete() # ← victim account permanently deleted (line 145)
...
gym_pk = request.user.userprofile.gym_id # = None for trainer1
return HttpResponseRedirect(reverse('gym:gym:user-list', kwargs={'pk': gym_pk}))
# ↑ raises NoReverseMatch (gym_pk=None) → 500 to attacker
# but user.delete() already executed — victim is gone
```
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 patch
Same as the advisory's recommendation — replace every `userprofile.gym_id != ...` raw comparison with `is_same_gym()` from `wger/gym/helpers.py`:
```diff
--- a/wger/core/views/user.py
+++ b/wger/core/views/user.py
@login_required()
def delete(request, user_pk=None):
...
• if not request.user.has_perm('gym.manage_gyms') and (
• not request.user.has_perm('gym.manage_gym')
• or request.user.userprofile.gym_id != user.userprofile.gym_id
• or user.has_perm('gym.manage_gym')
• or user.has_perm('gym.gym_trainer')
• or user.has_perm('gym.manage_gyms')
• ):
+ if not request.user.has_perm('gym.manage_gyms') and (
+ not request.user.has_perm('gym.manage_gym')
+ or not is_same_gym(request.user, user)
+ or user.has_perm('gym.manage_gym')
+ or user.has_perm('gym.gym_trainer')
+ or user.has_perm('gym.manage_gyms')
+ ):
return HttpResponseForbidden()
class UserDeactivateView(...):
def dispatch(self, request, *args, **kwargs):
edit_user = get_object_or_404(User, pk=self.kwargs['pk'])
...
• if (
• request.user.has_perm('gym.mana…