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

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

- **Severity:** Not scored
- **Exploited in the wild:** Not known
- **Login needed:** Any signed-in user
- **Attackable:** Over the internet
- **Victim action needed:** Not needed
- **Fix available:** No
- **Published:** 20 May 2026

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

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

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

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


| Package | Ecosystem | Affected | Fixed in |
| --- | --- | --- | --- |
| `wger` | PyPI | All versions to 2.5 | None |
| `wger` | PyPI | All versions to 2.5 | None |

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

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

## Fix prompt for AI coding agents

```text
Check this codebase for GHSA-mw8f-w6p8-xrf4 and fix it if it is affected.

What it is: 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.
Exploitation: There is no known exploitation of GHSA-mw8f-w6p8-xrf4 in the wild and no public exploit code.

Affected packages and fixed versions:
- wger (PyPI): affected all versions up to 2.5; no fix listed
- wger (PyPI): affected all versions up to 2.5; no fix listed

Task:
1. Search the dependency manifests and lockfiles (for example package.json, package-lock.json, requirements.txt, poetry.lock, uv.lock, pom.xml, build.gradle, go.mod, Cargo.lock, Gemfile.lock) and any Dockerfiles for the affected packages, including transitive dependencies.
2. If an affected version is in use, upgrade it to the fixed version for its release line with the project's package manager, so the lockfile is regenerated. Prefer the smallest upgrade that fixes it.
3. If there is no fix, or the upgrade would break the build, do not guess: explain the risk and the safest workaround instead.
4. Run the tests and summarise what changed and why.
5. If nothing in the project is affected, say so and change nothing.

Reference: https://insights.sbomify.com/GHSA-mw8f-w6p8-xrf4/ (Markdown: https://insights.sbomify.com/GHSA-mw8f-w6p8-xrf4/index.md)
```

## Sources

- OSV: https://osv.dev/vulnerability/GHSA-mw8f-w6p8-xrf4

---
From sbomify Insights: https://insights.sbomify.com/GHSA-mw8f-w6p8-xrf4/
Summaries are generated and may contain mistakes; the official description above is the record.
