PT-2026-60035 · Pypi · Wger

Published

2026-07-13

·

Updated

2026-07-13

CVSS v3.1

7.6

High

VectorAV: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.py
python
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/edit

Root cause

The view declares a permission but does not enforce it

File: wger/config/views/gym config.py
python
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.py
python
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.py
python
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.py
python
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() method
Because 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.py
python
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= 1
After a low-privileged user submitted the form setting default gym to gym id 1:
bash
default gym id= 1
profiles gym null= 0

Recommended 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 = True
Alternatively, 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.
Screenshot 2026-02-27 230752

Fix

Found an issue in the description? Have something to add? Feel free to write us 👾

Related Identifiers

PYSEC-2026-3424

Affected Products

Wger