GHSA-mw8f-w6p8-xrf4
GHSA-mw8f-w6p8-xrf4 · wger: cross-tenant account deletion / deactivation / activation by gym.manage_gym + gym=None
Cette page présente des faits issus de sources publiques. Consultez l’avis original avant toute mise à jour.
Cette page présente une partie des faits des sources. Consultez les avis liés pour la description complète et les versions concernées.
## 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
CVSS 8.5 · Élevée
- Publication
- Mise à jour
Pile logicielle: python
Produits et versions concernés
wger PyPI
- Versions du logiciel
- 1.1
- 1.1.1
- 1.2
- 1.2rc1
- 1.3
- 1.4
- 1.5
- 1.6
- 1.6.1
- 1.7
- 1.8
- 1.9
- 2.0
- 2.1
- Plages de versions concernées
- ECOSYSTEM: ≤ 2.5
Aucun correctif publié