p PatchFirst

GHSA-mw8f-w6p8-xrf4

GHSA-mw8f-w6p8-xrf4 · wger: cross-tenant account deletion / deactivation / activation by gym.manage_gym + gym=None

Esta página presenta datos de fuentes públicas de vulnerabilidades. Consulta el aviso original antes de actualizar.

Esta página muestra una selección de datos de las fuentes. Consulte los avisos enlazados para ver la descripción completa y las versiones afectadas.

## 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 · Alta

Publicación
Actualización

Pila tecnológica: python

Productos y versiones afectados

  • wger PyPI

    Versiones
    • 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
    Intervalos de versiones afectados
    • ECOSYSTEM: ≤ 2.5

    Sin corrección publicada

Fuentes originales

Comprueba tu servidor →