Описание
wger has Broken Access Control in Global Gym Configuration Update Endpoint
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
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
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
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
File: wger/config/models/gym_config.py
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
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:
After a low-privileged user submitted the form setting default_gym to gym id 1:
Recommended fix
Ensure permission enforcement runs before the form dispatch.
Using the project mixin (order matters):
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.
Пакеты
wger
<= 2.1
Отсутствует
Связанные уязвимости
wger is a free, open-source workout and fitness manager. In versions 2.5 and below, the GymConfigUpdateView declares permission_required = 'config.change_gymconfig' but inherits WgerFormMixin instead of WgerPermissionMixin, so the permission is never enforced at runtime. Since GymConfig is an ownerless singleton, any authenticated user can modify the global gym configuration, triggering save() side effects that bulk-update user profile gym assignments — a vertical privilege escalation to installation-wide configuration control. This issue is fixed in version 2.5.