GHSA-6F54-QJVM-WWQ3
Vulnerability from github – Published: 2026-04-16 01:37 – Updated: 2026-04-16 01:37Stored XSS via Unescaped License Attribution Fields
Summary
The 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.
Severity
High (CVSS 3.1: ~7.6)
- Low-privilege attacker (any authenticated non-temporary user)
- Stored XSS — persists in database
- Triggers on a public page (no authentication needed to view)
- Can steal session cookies, perform actions as other users, redirect to phishing
CWE
CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
Affected Components
Vulnerable Property
File: wger/utils/models.py:88-110
@property
def attribution_link(self):
out = ''
if self.license_object_url:
out += f'<a href="{self.license_object_url}">{self.license_title}</a>'
else:
out += self.license_title # NO ESCAPING
out += ' by '
if self.license_author_url:
out += f'<a href="{self.license_author_url}">{self.license_author}</a>'
else:
out += self.license_author # NO ESCAPING
out += f' is licensed under <a href="{self.license.url}">{self.license.short_name}</a>'
if self.license_derivative_source_url:
out += (
f'/ A derivative work from <a href="{self.license_derivative_source_url}">the '
f'original work</a>'
)
return out
Unsafe Template Rendering
File: wger/nutrition/templates/ingredient/view.html
- Line 171:
{{ ingredient.attribution_link|safe }} - Line 226:
{{ image.attribution_link|safe }}
Writable Entry Point
File: wger/nutrition/views/ingredient.py:154-175
class IngredientCreateView(WgerFormMixin, CreateView):
model = Ingredient
form_class = IngredientForm # includes license_author field
URL: login_required(ingredient.IngredientCreateView.as_view()) — any authenticated non-temporary user.
Form fields (from wger/nutrition/forms.py:295-313): includes license_author (TextField, max_length=3500) — no sanitization.
Models Affected
6 models inherit from AbstractLicenseModel:
- Exercise, ExerciseImage, ExerciseVideo, Translation (exercises module)
- Ingredient, Image (nutrition module)
Only the Ingredient and nutrition Image models' attribution links are currently rendered with |safe in templates.
Root Cause
attribution_linkconstructs raw HTML by string interpolation of user-controlled fields without callingdjango.utils.html.escape()ordjango.utils.html.format_html()- The template renders the result with
|safe, bypassing Django's auto-escaping - The
license_authorfield inIngredientFormhas no input sanitization - The
set_author()method only sets a default value if the field is empty — it does not sanitize user-provided values
Reproduction Steps (Verified)
Prerequisites
- A wger instance with user registration enabled (default)
- An authenticated user account (non-temporary)
Steps
-
Register/login to a wger instance
-
Create a malicious ingredient via the web form at
/en/nutrition/ingredient/add/: - Set
Nameto any valid name (e.g., "XSS Form Verified") - Set
Energyto125,Proteinto10,Carbohydratesto10,Fatto5(energy must approximately match macros) - Set
Author(s)(license_author) to:<img src=x onerror="alert(document.cookie)"> -
Submit the form — the form validates and saves successfully with no sanitization
-
View the ingredient page (public URL, no auth needed):
- Navigate to the newly created ingredient's detail page
- The XSS payload executes in the browser
Verified PoC Output
The rendered HTML in the ingredient detail page (line 171 of ingredient/view.html) contains:
<small>
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>
</small>
The <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.
Alternative API Path (ExerciseImage)
For users who are "trustworthy" (account >3 weeks old + verified email):
# Upload exercise image with XSS in license_author
curl -X POST https://wger.example.com/api/v2/exerciseimage/ \
-H "Authorization: Token <token>" \
-F "exercise=1" \
-F "image=@photo.jpg" \
-F 'license_author=<img src=x onerror="alert(document.cookie)">' \
-F "license=2"
Note: 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).
Impact
- Session hijacking: Steal admin session cookies to gain full control
- Account takeover: Modify other users' passwords or email addresses
- Data theft: Access other users' workout plans, nutrition data, and personal measurements
- Worm-like propagation: Malicious ingredient could inject XSS that creates more malicious ingredients
- Phishing: Redirect users to fake login pages
Suggested Fix
Replace the attribution_link property with properly escaped HTML using Django's format_html():
from django.utils.html import format_html, escape
@property
def attribution_link(self):
parts = []
if self.license_object_url:
parts.append(format_html('<a href="{}">{}</a>', self.license_object_url, self.license_title))
else:
parts.append(escape(self.license_title))
parts.append(' by ')
if self.license_author_url:
parts.append(format_html('<a href="{}">{}</a>', self.license_author_url, self.license_author))
else:
parts.append(escape(self.license_author))
parts.append(format_html(
' is licensed under <a href="{}">{}</a>',
self.license.url, self.license.short_name
))
if self.license_derivative_source_url:
parts.append(format_html(
'/ A derivative work from <a href="{}">the original work</a>',
self.license_derivative_source_url
))
return mark_safe(''.join(str(p) for p in parts))
Alternatively, remove the |safe filter from the templates and escape in the property, though this would break the anchor tags.
References
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "wger"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"last_affected": "2.4"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-40353"
],
"database_specific": {
"cwe_ids": [
"CWE-79"
],
"github_reviewed": true,
"github_reviewed_at": "2026-04-16T01:37:21Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"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\u0027s `|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 \u2014 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 (\u0027Cross-site Scripting\u0027)\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 = \u0027\u0027\n if self.license_object_url:\n out += f\u0027\u003ca href=\"{self.license_object_url}\"\u003e{self.license_title}\u003c/a\u003e\u0027\n else:\n out += self.license_title # NO ESCAPING\n out += \u0027 by \u0027\n if self.license_author_url:\n out += f\u0027\u003ca href=\"{self.license_author_url}\"\u003e{self.license_author}\u003c/a\u003e\u0027\n else:\n out += self.license_author # NO ESCAPING\n out += f\u0027 is licensed under \u003ca href=\"{self.license.url}\"\u003e{self.license.short_name}\u003c/a\u003e\u0027\n if self.license_derivative_source_url:\n out += (\n f\u0027/ A derivative work from \u003ca href=\"{self.license_derivative_source_url}\"\u003ethe \u0027\n f\u0027original work\u003c/a\u003e\u0027\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())` \u2014 any authenticated non-temporary user.\n\n**Form fields (from `wger/nutrition/forms.py:295-313`):** includes `license_author` (TextField, max_length=3500) \u2014 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\u0027 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\u0027s 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 \u2014 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 \u003cimg src=x onerror=\"alert(document.cookie)\"\u003e\n ```\n - Submit the form \u2014 **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\u0027s 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\u003csmall\u003e\n by \u003cimg src=x onerror=alert(1)\u003e is licensed under \u003ca href=\"https://creativecommons.org/licenses/by-sa/3.0/deed.en\"\u003eCC-BY-SA 3\u003c/a\u003e\n\u003c/small\u003e\n```\n\nThe `\u003cimg\u003e` 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 \u003e3 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 \u003ctoken\u003e\" \\\n -F \"exercise=1\" \\\n -F \"image=@photo.jpg\" \\\n -F \u0027license_author=\u003cimg src=x onerror=\"alert(document.cookie)\"\u003e\u0027 \\\n -F \"license=2\"\n```\n\nNote: ExerciseImage\u0027s `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\u0027 passwords or email addresses\n- **Data theft**: Access other users\u0027 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\u0027s `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(\u0027\u003ca href=\"{}\"\u003e{}\u003c/a\u003e\u0027, self.license_object_url, self.license_title))\n else:\n parts.append(escape(self.license_title))\n\n parts.append(\u0027 by \u0027)\n\n if self.license_author_url:\n parts.append(format_html(\u0027\u003ca href=\"{}\"\u003e{}\u003c/a\u003e\u0027, self.license_author_url, self.license_author))\n else:\n parts.append(escape(self.license_author))\n\n parts.append(format_html(\n \u0027 is licensed under \u003ca href=\"{}\"\u003e{}\u003c/a\u003e\u0027,\n self.license.url, self.license.short_name\n ))\n\n if self.license_derivative_source_url:\n parts.append(format_html(\n \u0027/ A derivative work from \u003ca href=\"{}\"\u003ethe original work\u003c/a\u003e\u0027,\n self.license_derivative_source_url\n ))\n\n return mark_safe(\u0027\u0027.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)",
"id": "GHSA-6f54-qjvm-wwq3",
"modified": "2026-04-16T01:37:21Z",
"published": "2026-04-16T01:37:21Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/wger-project/wger/security/advisories/GHSA-6f54-qjvm-wwq3"
},
{
"type": "PACKAGE",
"url": "https://github.com/wger-project/wger"
}
],
"schema_version": "1.4.0",
"severity": [
{
"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",
"type": "CVSS_V4"
}
],
"summary": "wger has Stored XSS via Unescaped License Attribution Fields"
}
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.