CVE-2026-40474: Wger Wger Has Broken Access Control Vulnerability

HIGH
Advisory Identifier: GHSA-xppv-4jrx-qf8m
CVSS 7.6
CWE-284 CWE-862
Published
Advisory Summary

CVE-2026-40474 is a HIGH (CVSS 7.6) wger has broken access control vulnerability (CWE-284: Improper Access Control) affecting Wger libraries in the PyPI ecosystem. The issue involves wger has broken access control in global gym configuration update endpoint. Remediation is available in the latest vendor maintenance releases.

CVE-2026-40474 at a Glance

CVE Identifier CVE-2026-40474
GHSA Identifier GHSA-xppv-4jrx-qf8m
Severity HIGH
CVSS v3.1 Base Score 7.6
CWE Classification CWE-284: Improper Access Control
Affected Product Wger
Package Ecosystem PyPI
Published Date
Last Updated

Security Impact

CVSS: 7.6

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:L).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required Low (PR:L)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality Low (C:L)
Integrity High (I:H)
Availability Low (A:L)

How to Fix CVE-2026-40474

To remediate this vulnerability, upgrade all affected Wger dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

wger fixed: Latest secure release
pip install wger>=Latest secure release
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

pip show wger

How to Check if You Are Affected

To determine whether your Python environment is affected by CVE-2026-40474, inspect your installed packages using pip:

pip show wger

Analyze the terminal output to locate any version of Wger in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than the patched release), your application is affected.

Note: The pip show wger command checks currently installed packages in your active Python environment.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-284: Improper Access Control, where the product does not restrict or incorrectly restricts access to a resource from an unauthorized actor. In Wger, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

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

CVE-2026-40474 Affected Wger Versions

1 component(s)
Product / Package Ecosystem Affected Versions Fixed Version
wger
PyPI
≤ 2.1

References

Frequently Asked Questions

What is CVE-2026-40474?
CVE-2026-40474 is a security vulnerability in Wger involving wger has Broken Access Control in Global Gym Configuration Update Endpoint under CWE-284: Improper Access Control.
Which Wger versions are affected?
Affected versions include releases prior to current release patches across affected packages: wger.
What version fixes CVE-2026-40474?
Upgrading to current release patches (or the latest patched release for your respective branch) resolves the vulnerability.
Is wger affected?
Yes, wger is affected in releases prior to Latest secure release. Upgrade your dependency to Latest secure release or later.
What is the CVSS score of CVE-2026-40474?
CVE-2026-40474 has a CVSS v3.1 base score of 7.6 (HIGH) with vector string CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:L.
What CWE is associated with CVE-2026-40474?
CVE-2026-40474 is classified under CWE-284: Improper Access Control (The product does not restrict or incorrectly restricts access to a resource from an unauthorized actor.).
How can I check PyPI dependencies for CVE-2026-40474?
Run the dependency tree command: 'pip show wger'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-xppv-4jrx-qf8m",
  "modified" : "2026-04-24T20:51:15Z",
  "published" : "2026-04-16T01:35:16Z",
  "aliases" : [ "CVE-2026-40474" ],
  "summary" : "wger has Broken Access Control in Global Gym Configuration Update Endpoint",
  "details" : "## Summary\n\nwger 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`) .\n\nThe 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()`.\n\nThis is a vertical privilege escalation from a regular user to privileged global configuration control.\nThe 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.\n\n## Affected endpoint\n\nThe config URLs map as follows.\n\nFile: `wger/config/urls.py`\n\n```python\npatterns_gym_config = [\n    path('edit', gym_config.GymConfigUpdateView.as_view(), name='edit'),\n]\n\nurlpatterns = [\n    path(\n        'gym-config/',\n        include((patterns_gym_config, 'gym_config'), namespace='gym_config'),\n    ),\n]\n```\n\nThis resolves to:\n\n`/config/gym-config/edit`\n\n## Root cause\n\n### The view declares a permission but does not enforce it\n\nFile: `wger/config/views/gym_config.py`\n\n```python\nclass GymConfigUpdateView(WgerFormMixin, UpdateView):\n    model = GymConfig\n    fields = ('default_gym',)\n    permission_required = 'config.change_gymconfig'\n    success_url = reverse_lazy('gym:gym:list')\n    title = gettext_lazy('Edit')\n\n    def get_object(self):\n        return GymConfig.objects.get(pk=1)\n```\n\nThe permission string exists, but `WgerFormMixin` does not check `permission_required`.\n\n### The project’s permission mixin exists but is not used\n\nFile: `wger/utils/generic_views.py`\n\n```python\nclass WgerPermissionMixin:\n    permission_required = False\n    login_required = False\n\n    def dispatch(self, request, *args, **kwargs):\n        if self.login_required or self.permission_required:\n            if not request.user.is_authenticated:\n                return HttpResponseRedirect(\n                    reverse_lazy('core:user:login') + f'?next={request.path}'\n                )\n\n            if self.permission_required:\n                has_permission = False\n                if isinstance(self.permission_required, tuple):\n                    for permission in self.permission_required:\n                        if request.user.has_perm(permission):\n                            has_permission = True\n                elif request.user.has_perm(self.permission_required):\n                    has_permission = True\n\n                if not has_permission:\n                    return HttpResponseForbidden('You are not allowed to access this object')\n\n        return super(WgerPermissionMixin, self).dispatch(request, *args, **kwargs)\n```\n\n`GymConfigUpdateView` does not inherit this mixin, so none of the login/permission logic runs.\n\n### The mixin that *is* used performs only ownership checks, and `GymConfig` has no owner\n\nFile: `wger/utils/generic_views.py`\n\n```python\nclass WgerFormMixin(ModelFormMixin):\n    def dispatch(self, request, *args, **kwargs):\n        self.kwargs = kwargs\n        self.request = request\n\n        if self.owner_object:\n            owner_object = self.owner_object['class'].objects.get(pk=kwargs[self.owner_object['pk']])\n        else:\n            try:\n                owner_object = self.get_object().get_owner_object()\n            except AttributeError:\n                owner_object = False\n\n        if owner_object and owner_object.user != self.request.user:\n            return HttpResponseForbidden('You are not allowed to access this object')\n\n        return super(WgerFormMixin, self).dispatch(request, *args, **kwargs)\n```\n\nFile: `wger/config/models/gym_config.py`\n\n```python\nclass GymConfig(models.Model):\n    default_gym = models.ForeignKey(\n        Gym,\n        verbose_name=_('Default gym'),\n        # ...\n        null=True,\n        blank=True,\n        on_delete=models.CASCADE,\n    )\n    # No get_owner_object() method\n```\n\nBecause `GymConfig` does not implement `get_owner_object()`, `WgerFormMixin` catches `AttributeError` and sets `owner_object = False`, skipping any access restriction.\n\n## Security impact\n\nThis is not a cosmetic setting: `GymConfig.save()` performs installation-wide side effects.\n\nFile: `wger/config/models/gym_config.py`\n\n```python\ndef save(self, *args, **kwargs):\n    if self.default_gym:\n        UserProfile.objects.filter(gym=None).update(gym=self.default_gym)\n\n        for profile in UserProfile.objects.filter(gym=self.default_gym):\n            user = profile.user\n            if not is_any_gym_admin(user):\n                try:\n                    user.gymuserconfig\n                except GymUserConfig.DoesNotExist:\n                    config = GymUserConfig()\n                    config.gym = self.default_gym\n                    config.user = user\n                    config.save()\n\n    return super(GymConfig, self).save(*args, **kwargs)\n```\n\nOn 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.\n\n## Proof of concept (local verification)\n\nEnvironment: local docker compose stack, accessed via `http://127.0.0.1:8088/en/`.\n\n### Observed behavior\n\nAn unauthenticated user can reach the endpoint via GET; POST requires authentication and redirects to login.\nAn 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.\n\n### DB evidence (before/after)\n\nBefore submission:\n\n```bash\ndefault_gym_id= None\nprofiles_gym_null= 1\n```\n\nAfter a low-privileged user submitted the form setting `default_gym` to gym id `1`:\n\n```bash\ndefault_gym_id= 1\nprofiles_gym_null= 0\n```\n\n## Recommended fix\n\nEnsure permission enforcement runs before the form dispatch.\n\nUsing the project mixin (order matters):\n\n```python\nclass GymConfigUpdateView(WgerPermissionMixin, WgerFormMixin, UpdateView):\n    permission_required = 'config.change_gymconfig'\n    login_required = True\n```\n\nAlternatively, use Django’s `PermissionRequiredMixin` (and `LoginRequiredMixin`) directly.\n\n## Conclusion \n\nThe 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.\n\n<img width=\"1912\" height=\"578\" alt=\"Screenshot 2026-02-27 230752\" src=\"https://github.com/user-attachments/assets/c627b404-6d9c-4477-88bd-f867d0fa09d2\" />",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:L"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "PyPI",
      "name" : "wger"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "last_affected" : "2.1"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/wger-project/wger/security/advisories/GHSA-xppv-4jrx-qf8m"
  }, {
    "type" : "ADVISORY",
    "url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-40474"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/wger-project/wger/commit/47ee5af93b3ced24b9f94b0a8b9296b50bc9523f"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/wger-project/wger"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/wger-project/wger/releases/tag/2.5"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-284", "CWE-862" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-04-16T01:35:16Z",
    "nvd_published_at" : "2026-04-17T22:16:33Z"
  }
}