Skip to content

Commit fde86b4

Browse files
1 parent 87195fc commit fde86b4

File tree

2 files changed

+115
-0
lines changed

2 files changed

+115
-0
lines changed
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-6f54-qjvm-wwq3",
4+
"modified": "2026-04-16T01:37:21Z",
5+
"published": "2026-04-16T01:37:21Z",
6+
"aliases": [
7+
"CVE-2026-40353"
8+
],
9+
"summary": "wger has Stored XSS via Unescaped License Attribution Fields",
10+
"details": "# Stored XSS via Unescaped License Attribution Fields\n\n## Summary\n\nThe `AbstractLicenseModel.attribution_link` property in `wger/utils/models.py` constructs HTML strings by directly interpolating user-controlled fields (`license_author`, `license_title`, `license_object_url`, `license_author_url`, `license_derivative_source_url`) without any escaping. The resulting HTML is rendered in the ingredient view template using Django's `|safe` filter, which disables auto-escaping. An authenticated user can create an ingredient with a malicious `license_author` value containing JavaScript, which executes when any user (including unauthenticated visitors) views the ingredient page.\n\n## Severity\n\n**High** (CVSS 3.1: ~7.6)\n\n- Low-privilege attacker (any authenticated non-temporary user)\n- Stored XSS — persists in database\n- Triggers on a public page (no authentication needed to view)\n- Can steal session cookies, perform actions as other users, redirect to phishing\n\n## CWE\n\nCWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')\n\n## Affected Components\n\n### Vulnerable Property\n**File:** `wger/utils/models.py:88-110`\n\n```python\n@property\ndef attribution_link(self):\n out = ''\n if self.license_object_url:\n out += f'<a href=\"{self.license_object_url}\">{self.license_title}</a>'\n else:\n out += self.license_title # NO ESCAPING\n out += ' by '\n if self.license_author_url:\n out += f'<a href=\"{self.license_author_url}\">{self.license_author}</a>'\n else:\n out += self.license_author # NO ESCAPING\n out += f' is licensed under <a href=\"{self.license.url}\">{self.license.short_name}</a>'\n if self.license_derivative_source_url:\n out += (\n f'/ A derivative work from <a href=\"{self.license_derivative_source_url}\">the '\n f'original work</a>'\n )\n return out\n```\n\n### Unsafe Template Rendering\n**File:** `wger/nutrition/templates/ingredient/view.html`\n\n- **Line 171:** `{{ ingredient.attribution_link|safe }}`\n- **Line 226:** `{{ image.attribution_link|safe }}`\n\n### Writable Entry Point\n**File:** `wger/nutrition/views/ingredient.py:154-175`\n\n```python\nclass IngredientCreateView(WgerFormMixin, CreateView):\n model = Ingredient\n form_class = IngredientForm # includes license_author field\n```\n\n**URL:** `login_required(ingredient.IngredientCreateView.as_view())` — any authenticated non-temporary user.\n\n**Form fields (from `wger/nutrition/forms.py:295-313`):** includes `license_author` (TextField, max_length=3500) — no sanitization.\n\n### Models Affected\n\n6 models inherit from `AbstractLicenseModel`:\n- `Exercise`, `ExerciseImage`, `ExerciseVideo`, `Translation` (exercises module)\n- `Ingredient`, `Image` (nutrition module)\n\nOnly the **Ingredient** and nutrition **Image** models' attribution links are currently rendered with `|safe` in templates.\n\n## Root Cause\n\n1. `attribution_link` constructs raw HTML by string interpolation of user-controlled fields without calling `django.utils.html.escape()` or `django.utils.html.format_html()`\n2. The template renders the result with `|safe`, bypassing Django's auto-escaping\n3. The `license_author` field in `IngredientForm` has no input sanitization\n4. The `set_author()` method only sets a default value if the field is empty — it does not sanitize user-provided values\n\n## Reproduction Steps (Verified)\n\n### Prerequisites\n- A wger instance with user registration enabled (default)\n- An authenticated user account (non-temporary)\n\n### Steps\n\n1. **Register/login** to a wger instance\n\n2. **Create a malicious ingredient** via the web form at `/en/nutrition/ingredient/add/`:\n - Set `Name` to any valid name (e.g., \"XSS Form Verified\")\n - Set `Energy` to `125`, `Protein` to `10`, `Carbohydrates` to `10`, `Fat` to `5` (energy must approximately match macros)\n - Set `Author(s)` (license_author) to:\n ```\n <img src=x onerror=\"alert(document.cookie)\">\n ```\n - Submit the form — **the form validates and saves successfully with no sanitization**\n\n3. **View the ingredient page** (public URL, no auth needed):\n - Navigate to the newly created ingredient's detail page\n - The XSS payload executes in the browser\n\n### Verified PoC Output\n\nThe rendered HTML in the ingredient detail page (line 171 of `ingredient/view.html`) contains:\n\n```html\n<small>\n by <img src=x onerror=alert(1)> is licensed under <a href=\"https://creativecommons.org/licenses/by-sa/3.0/deed.en\">CC-BY-SA 3</a>\n</small>\n```\n\nThe `<img>` tag with `onerror` handler is injected directly into the page DOM and executes JavaScript when the browser attempts to load the non-existent image.\n\n### Alternative API Path (ExerciseImage)\n\nFor users who are \"trustworthy\" (account >3 weeks old + verified email):\n\n```bash\n# Upload exercise image with XSS in license_author\ncurl -X POST https://wger.example.com/api/v2/exerciseimage/ \\\n -H \"Authorization: Token <token>\" \\\n -F \"exercise=1\" \\\n -F \"image=@photo.jpg\" \\\n -F 'license_author=<img src=x onerror=\"alert(document.cookie)\">' \\\n -F \"license=2\"\n```\n\nNote: ExerciseImage's `attribution_link` is not currently rendered with `|safe` in exercise templates, but the data is stored with XSS payloads and would execute if any template renders it with `|safe` in the future. The API serializer also returns the unescaped `attribution_link` data, which could cause XSS in API consumers (mobile apps, SPAs).\n\n## Impact\n\n- **Session hijacking**: Steal admin session cookies to gain full control\n- **Account takeover**: Modify other users' passwords or email addresses\n- **Data theft**: Access other users' workout plans, nutrition data, and personal measurements\n- **Worm-like propagation**: Malicious ingredient could inject XSS that creates more malicious ingredients\n- **Phishing**: Redirect users to fake login pages\n\n## Suggested Fix\n\nReplace the `attribution_link` property with properly escaped HTML using Django's `format_html()`:\n\n```python\nfrom django.utils.html import format_html, escape\n\n@property\ndef attribution_link(self):\n parts = []\n\n if self.license_object_url:\n parts.append(format_html('<a href=\"{}\">{}</a>', self.license_object_url, self.license_title))\n else:\n parts.append(escape(self.license_title))\n\n parts.append(' by ')\n\n if self.license_author_url:\n parts.append(format_html('<a href=\"{}\">{}</a>', self.license_author_url, self.license_author))\n else:\n parts.append(escape(self.license_author))\n\n parts.append(format_html(\n ' is licensed under <a href=\"{}\">{}</a>',\n self.license.url, self.license.short_name\n ))\n\n if self.license_derivative_source_url:\n parts.append(format_html(\n '/ A derivative work from <a href=\"{}\">the original work</a>',\n self.license_derivative_source_url\n ))\n\n return mark_safe(''.join(str(p) for p in parts))\n```\n\nAlternatively, remove the `|safe` filter from the templates and escape in the property, though this would break the anchor tags.\n\n## References\n\n- [Django Security: Cross Site Scripting (XSS) protection](https://docs.djangoproject.com/en/5.0/topics/security/#cross-site-scripting-xss-protection)\n- [Django `format_html()` documentation](https://docs.djangoproject.com/en/5.0/ref/utils/#django.utils.html.format_html)\n- [OWASP: Stored Cross-Site Scripting](https://owasp.org/www-community/attacks/xss/#stored-xss-attacks)",
11+
"severity": [
12+
{
13+
"type": "CVSS_V4",
14+
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:P/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N"
15+
}
16+
],
17+
"affected": [
18+
{
19+
"package": {
20+
"ecosystem": "PyPI",
21+
"name": "wger"
22+
},
23+
"ranges": [
24+
{
25+
"type": "ECOSYSTEM",
26+
"events": [
27+
{
28+
"introduced": "0"
29+
},
30+
{
31+
"last_affected": "2.4"
32+
}
33+
]
34+
}
35+
]
36+
}
37+
],
38+
"references": [
39+
{
40+
"type": "WEB",
41+
"url": "https://github.com/wger-project/wger/security/advisories/GHSA-6f54-qjvm-wwq3"
42+
},
43+
{
44+
"type": "PACKAGE",
45+
"url": "https://github.com/wger-project/wger"
46+
}
47+
],
48+
"database_specific": {
49+
"cwe_ids": [
50+
"CWE-79"
51+
],
52+
"severity": "MODERATE",
53+
"github_reviewed": true,
54+
"github_reviewed_at": "2026-04-16T01:37:21Z",
55+
"nvd_published_at": null
56+
}
57+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-xppv-4jrx-qf8m",
4+
"modified": "2026-04-16T01:35:16Z",
5+
"published": "2026-04-16T01:35:16Z",
6+
"aliases": [
7+
"CVE-2026-40474"
8+
],
9+
"summary": "wger has Broken Access Control in Global Gym Configuration Update Endpoint",
10+
"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\" />",
11+
"severity": [
12+
{
13+
"type": "CVSS_V3",
14+
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:L"
15+
}
16+
],
17+
"affected": [
18+
{
19+
"package": {
20+
"ecosystem": "PyPI",
21+
"name": "wger"
22+
},
23+
"ranges": [
24+
{
25+
"type": "ECOSYSTEM",
26+
"events": [
27+
{
28+
"introduced": "0"
29+
},
30+
{
31+
"last_affected": "2.1"
32+
}
33+
]
34+
}
35+
]
36+
}
37+
],
38+
"references": [
39+
{
40+
"type": "WEB",
41+
"url": "https://github.com/wger-project/wger/security/advisories/GHSA-xppv-4jrx-qf8m"
42+
},
43+
{
44+
"type": "PACKAGE",
45+
"url": "https://github.com/wger-project/wger"
46+
}
47+
],
48+
"database_specific": {
49+
"cwe_ids": [
50+
"CWE-284",
51+
"CWE-862"
52+
],
53+
"severity": "HIGH",
54+
"github_reviewed": true,
55+
"github_reviewed_at": "2026-04-16T01:35:16Z",
56+
"nvd_published_at": null
57+
}
58+
}

0 commit comments

Comments
 (0)