CVE-2026-40474: wger has Broken Access Control in the Global Gym Configuration Update Endpoint

Published Apr 16, 2026
·
Updated

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

2 affected components
pip/wger<=2.1
wger wger<2.5

Event History

Apr 16, 2026
Advisory Published
via GitHub·01:35 AM
Data Sourced
via GitHub·01:35 AM
DescriptionSeverityWeaknessAffected Software
Apr 17, 2026
CVE Published
via MITRE·09:39 PM
Data Sourced
via MITRE·09:39 PM
DescriptionSeverityWeakness
Data Sourced
via NVD·10:16 PM
RemedyDescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

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.

2

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.

3

What software is affected by CVE-2026-40474?

CVE-2026-40474 affects wger versions up to and including 2.1.

4

What type of vulnerability is CVE-2026-40474?

CVE-2026-40474 is a permission exposure vulnerability related to configuration editing.

5

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.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203