GHSA-XPPV-4JRX-QF8M
Vulnerability from github – Published: 2026-04-16 01:35 – Updated: 2026-04-16 01:35Summary
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.
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "wger"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"last_affected": "2.1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-40474"
],
"database_specific": {
"cwe_ids": [
"CWE-284",
"CWE-862"
],
"github_reviewed": true,
"github_reviewed_at": "2026-04-16T01:35:16Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "## Summary\n\nwger exposes a global configuration edit endpoint at `/config/gym-config/edit` implemented by `GymConfigUpdateView`. The view declares `permission_required = \u0027config.change_gymconfig\u0027` but does not enforce it because it inherits `WgerFormMixin` (ownership-only checks) instead of the project\u2019s 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 = \u0027config.change_gymconfig\u0027, 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(\u0027edit\u0027, gym_config.GymConfigUpdateView.as_view(), name=\u0027edit\u0027),\n]\n\nurlpatterns = [\n path(\n \u0027gym-config/\u0027,\n include((patterns_gym_config, \u0027gym_config\u0027), namespace=\u0027gym_config\u0027),\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 = (\u0027default_gym\u0027,)\n permission_required = \u0027config.change_gymconfig\u0027\n success_url = reverse_lazy(\u0027gym:gym:list\u0027)\n title = gettext_lazy(\u0027Edit\u0027)\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\u2019s 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(\u0027core:user:login\u0027) + f\u0027?next={request.path}\u0027\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(\u0027You are not allowed to access this object\u0027)\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[\u0027class\u0027].objects.get(pk=kwargs[self.owner_object[\u0027pk\u0027]])\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(\u0027You are not allowed to access this object\u0027)\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=_(\u0027Default gym\u0027),\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\u2019 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(\u0027gym:gym:list\u0027)` (e.g. `/en/gym/list`), which is permission-protected; therefore the browser may display a \u201cForbidden\u201d 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 = \u0027config.change_gymconfig\u0027\n login_required = True\n```\n\nAlternatively, use Django\u2019s `PermissionRequiredMixin` (and `LoginRequiredMixin`) directly.\n\n## Conclusion \n\nThe view explicitly declares permission_required = \u0027config.change_gymconfig\u0027, 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\u003cimg width=\"1912\" height=\"578\" alt=\"Screenshot 2026-02-27 230752\" src=\"https://github.com/user-attachments/assets/c627b404-6d9c-4477-88bd-f867d0fa09d2\" /\u003e",
"id": "GHSA-xppv-4jrx-qf8m",
"modified": "2026-04-16T01:35:16Z",
"published": "2026-04-16T01:35:16Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/wger-project/wger/security/advisories/GHSA-xppv-4jrx-qf8m"
},
{
"type": "PACKAGE",
"url": "https://github.com/wger-project/wger"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:L",
"type": "CVSS_V3"
}
],
"summary": "wger has Broken Access Control in Global Gym Configuration Update Endpoint"
}
Sightings
| Author | Source | Type | Date |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.