Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
🐍 PyPI

GHSA-xppv-4jrx-qf8m

HIGH

GHSA-xppv-4jrx-qf8m is a high-severity (CVSS 7.6) CWE-284 vulnerability in wger. O3 Security confirms whether GHSA-xppv-4jrx-qf8m is actually reachable in your code before you act, and blocks exploitation at runtime until you patch.

wger has Broken Access Control in Global Gym Configuration Update Endpoint

Also known asCVE-2026-40474PYSEC-2026-3424
Published
Apr 16, 2026
Updated
Jul 13, 2026
Affected
1 pkg
Patched
None yet
Exploits
None indexed

Blast Radius

1 pkg affected
🐍wger

Real-time download stats are indexed for npm and PyPI packages. This vulnerability affects PyPI packages — download data is not available via public APIs for these ecosystems.

Description

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

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

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

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

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

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

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:

default_gym_id= None
profiles_gym_null= 1

After a low-privileged user submitted the form setting default_gym to gym id 1:

default_gym_id= 1
profiles_gym_null= 0

Recommended fix

Ensure permission enforcement runs before the form dispatch.

Using the project mixin (order matters):

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.

<img width="1912" height="578" alt="Screenshot 2026-02-27 230752" src="https://github.com/user-attachments/assets/c627b404-6d9c-4477-88bd-f867d0fa09d2" />

Affected Packages

1 total
EcosystemPackageVulnerable rangeFix
🐍PyPIwgerall versionsNo fix

Detection & mitigation playbook

Open-source dependency
  1. Detect

    Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for wger. O3's reachability analysis confirms whether the vulnerable code path is actually invoked in your application, so you act on real exposure instead of every transitive match.

  2. Remediation status

    No patched version of wger has shipped for GHSA-xppv-4jrx-qf8m yet. Where your build allows, override or pin the dependency away from the vulnerable range, and apply any maintainer-recommended mitigation.

  3. Mitigate without a patch

    If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.

  4. How O3 protects you

    O3 pinpoints whether GHSA-xppv-4jrx-qf8m is reachable in your code and exactly where to fix it, then blocks exploitation in production at runtime until the patched version is deployed.

Tailored to GHSA-xppv-4jrx-qf8m. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.

Frequently Asked Questions

## 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 config
O3 Security · Impact-Aware SCA

Is GHSA-xppv-4jrx-qf8m in your dependencies?

O3 detects GHSA-xppv-4jrx-qf8m across PyPI dependencies and uses function-level reachability to confirm whether the vulnerable code path is actually reachable — not just present. No false positives.