CVE-2026-40474: wger has Broken Access Control in the Global Gym Configuration Update Endpoint
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" />
Other sources
wger is a free, open-source workout and fitness manager. In versions 2.5 and below, the GymConfigUpdateView declares permissionrequired = 'config.changegymconfig' but inherits WgerFormMixin instead of WgerPermissionMixin, so the permission is never enforced at runtime. Since GymConfig is an ownerless singleton, any authenticated user can modify the global gym configuration, triggering save() side effects that bulk-update user profile gym assignments — a vertical privilege escalation to installation-wide configuration control. This issue is fixed in version 2.5.
— MITRE
Affected Software
Remediation
Event History
Frequently Asked Questions
What is the severity of CVE-2026-40474?
CVE-2026-40474 has a medium severity level due to a permission enforcement flaw that may allow unauthorized configuration changes.
How do I fix CVE-2026-40474?
To fix CVE-2026-40474, update to a version of wger that includes a proper implementation of permission checks.
What software is affected by CVE-2026-40474?
CVE-2026-40474 affects wger versions up to and including 2.1.
What type of vulnerability is CVE-2026-40474?
CVE-2026-40474 is a permission exposure vulnerability related to configuration editing.
Can CVE-2026-40474 lead to data compromise?
Yes, CVE-2026-40474 can lead to unauthorized changes to configuration data, potentially compromising the integrity of the application.