Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
🐍
🐍 PyPI
Not in CISA KEV
HIGH severity

GHSA-xppv-4jrx-qf8m wger

HIGHFix: wger-project/wger@47ee5af

GHSA-xppv-4jrx-qf8m is a high-severity (CVSS 7.6) CWE-284 vulnerability in wger. No vendor fix is recorded yet; mitigation options are listed below.

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
See advisory
Exploits
None indexed
Exploitation data as of Sep 18, 2026 · OSV.dev, NVD, FIRST.org (EPSS)

Exploitation Status

Proof-of-concept exploit code exists

  • CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.

Exploitation and automatability from CISA’s SSVC triage for GHSA-xppv-4jrx-qf8m.

EPSS Exploitation Probability

via FIRST.org ↗
0.3%probability of exploitation in next 30 days
Lower Risk0.00%
Lower risk than most CVEs27th percentile — riskier than 27% of all scored CVEsHighest risk

EPSS (Exploit Prediction Scoring System) is a daily probability model maintained by FIRST.org. It estimates the likelihood a CVE will be exploited in production environments within the next 30 days, derived from real-world threat intelligence signals.

How urgent is this, really

GHSA-xppv-4jrx-qf8m plotted by exploitation likelihood (EPSS) against impact (CVSS). The shaded corner — EPSS 50%+ and CVSS 7.0+ — is where this CVE doesn't sit, though severity or exploitability alone can still warrant action.

Where this sits among everything scored

Of 377,166 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Real counts from FIRST.org, not a sample — log-scaled since the landscape is heavily right-skewed.

Real-World Exposure

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, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.

  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 Security's impact-aware SCA analyses which vulnerable code paths your application actually calls, so a match like GHSA-xppv-4jrx-qf8m can be triaged on real exposure rather than presence alone.

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 Security finds GHSA-xppv-4jrx-qf8m across PyPI dependencies, including transitive ones, and its impact-aware SCA ranks findings by whether your code actually calls the vulnerable path.

GHSA-xppv-4jrx-qf8m: wger PrivEsc (High 7.6) | O3 Security