PT-2026-60035 · Pypi · Wger
Published
2026-07-13
·
Updated
2026-07-13
CVSS v3.1
7.6
High
| Vector | AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:L |
Summary
wger exposes a global configuration edit endpoint at
/config/gym-config/edit implemented by GymConfigUpdateView. The view declares permission required = 'config.change gymconfig' 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 get owner object(), 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 permission required = 'config.change gymconfig', 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.pypython
patterns gym config = [
path('edit', gym config.GymConfigUpdateView.as view(), name='edit'),
]
urlpatterns = [
path(
'gym-config/',
include((patterns gym config, 'gym config'), namespace='gym config'),
),
]This resolves to:
/config/gym-config/editRoot cause
The view declares a permission but does not enforce it
File:
wger/config/views/gym config.pypython
class GymConfigUpdateView(WgerFormMixin, UpdateView):
model = GymConfig
fields = ('default gym',)
permission required = 'config.change gymconfig'
success url = reverse lazy('gym:gym:list')
title = gettext lazy('Edit')
def get object(self):
return GymConfig.objects.get(pk=1)The permission string exists, but
WgerFormMixin does not check permission required.The project’s permission mixin exists but is not used
File:
wger/utils/generic views.pypython
class WgerPermissionMixin:
permission required = False
login required = False
def dispatch(self, request, *args, **kwargs):
if self.login required or self.permission required:
if not request.user.is authenticated:
return HttpResponseRedirect(
reverse lazy('core:user:login') + f'?next={request.path}'
)
if self.permission required:
has permission = False
if isinstance(self.permission required, tuple):
for permission in self.permission required:
if request.user.has perm(permission):
has permission = True
elif request.user.has perm(self.permission required):
has permission = True
if not has permission:
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/generic views.pypython
class WgerFormMixin(ModelFormMixin):
def dispatch(self, request, *args, **kwargs):
self.kwargs = kwargs
self.request = request
if self.owner object:
owner object = self.owner object['class'].objects.get(pk=kwargs[self.owner object['pk']])
else:
try:
owner object = self.get object().get owner object()
except AttributeError:
owner object = False
if owner object and owner object.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/gym config.pypython
class GymConfig(models.Model):
default gym = models.ForeignKey(
Gym,
verbose name= ('Default gym'),
# ...
null=True,
blank=True,
on delete=models.CASCADE,
)
# No get owner object() methodBecause
GymConfig does not implement get owner object(), WgerFormMixin catches AttributeError and sets owner object = False, skipping any access restriction.Security impact
This is not a cosmetic setting:
GymConfig.save() performs installation-wide side effects.File:
wger/config/models/gym config.pypython
def save(self, *args, **kwargs):
if self.default gym:
UserProfile.objects.filter(gym=None).update(gym=self.default gym)
for profile in UserProfile.objects.filter(gym=self.default gym):
user = profile.user
if not is any gym admin(user):
try:
user.gymuserconfig
except GymUserConfig.DoesNotExist:
config = GymUserConfig()
config.gym = self.default gym
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
success url = reverse lazy('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
default gym id= None
profiles gym null= 1After a low-privileged user submitted the form setting
default gym to gym id 1:bash
default gym id= 1
profiles gym null= 0Recommended fix
Ensure permission enforcement runs before the form dispatch.
Using the project mixin (order matters):
python
class GymConfigUpdateView(WgerPermissionMixin, WgerFormMixin, UpdateView):
permission required = 'config.change gymconfig'
login required = TrueAlternatively, use Django’s
PermissionRequiredMixin (and LoginRequiredMixin) directly.Conclusion
The view explicitly declares permission required = 'config.change gymconfig', 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.
Fix
Found an issue in the description? Have something to add? Feel free to write us 👾
Related Identifiers
Affected Products
Wger