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

Not scored Published

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.

Exploited in the wild
Not known
Login needed
Any signed-in user
Attackable
Over the internet
Victim action needed
Not needed
Fix
None listed

What is GHSA-mw8f-w6p8-xrf4?

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.

Official 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/<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)

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)

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)

@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:

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

Which versions are affected by GHSA-mw8f-w6p8-xrf4?

2 open source packages ship an affected version, including wger.

PackageAffectedFixed in
wger PyPIAll versions to 2.5None
wger PyPIAll versions to 2.5None

How do I fix GHSA-mw8f-w6p8-xrf4?

No fix has been published for GHSA-mw8f-w6p8-xrf4 yet. Limit exposure of the affected software until one is available.

Upgrade, and check permissions on every request on the server, not only in the interface. More about Missing authorization.

Is GHSA-mw8f-w6p8-xrf4 being exploited?

There is no known exploitation of GHSA-mw8f-w6p8-xrf4 in the wild and no public exploit code.

Sources

Is GHSA-mw8f-w6p8-xrf4 in your software?

Upload an SBOM and sbomify checks it against every vulnerability here, every day.

Check your SBOMs for free