CVE-2026-40353: wger: Stored XSS via Unescaped License Attribution Fields
Stored XSS via Unescaped License Attribution Fields
Summary
The AbstractLicenseModel.attributionlink property in wger/utils/models.py constructs HTML strings by directly interpolating user-controlled fields (licenseauthor, licensetitle, licenseobjecturl, licenseauthorurl, licensederivativesourceurl) 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 licenseauthor 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
python @property def attributionlink(self): out = '' if self.licenseobjecturl: out += f'<a href="{self.licenseobjecturl}">{self.licensetitle}</a>' else: out += self.licensetitle # NO ESCAPING out += ' by ' if self.licenseauthorurl: out += f'<a href="{self.licenseauthorurl}">{self.licenseauthor}</a>' else: out += self.licenseauthor # NO ESCAPING out += f' is licensed under <a href="{self.license.url}">{self.license.shortname}</a>' if self.licensederivativesourceurl: out += ( f'/ A derivative work from <a href="{self.licensederivativesourceurl}">the ' f'original work</a>' ) return out
Unsafe Template Rendering File: wger/nutrition/templates/ingredient/view.html
- Line 171: {{ ingredient.attributionlink|safe }} - Line 226: {{ image.attributionlink|safe }}
Writable Entry Point File: wger/nutrition/views/ingredient.py:154-175
python class IngredientCreateView(WgerFormMixin, CreateView): model = Ingredient formclass = IngredientForm # includes licenseauthor field
URL: loginrequired(ingredient.IngredientCreateView.asview()) — any authenticated non-temporary user.
Form fields (from wger/nutrition/forms.py:295-313): includes licenseauthor (TextField, maxlength=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
1. attributionlink constructs raw HTML by string interpolation of user-controlled fields without calling django.utils.html.escape() or django.utils.html.formathtml() 2. The template renders the result with |safe, bypassing Django's auto-escaping 3. The licenseauthor field in IngredientForm has no input sanitization 4. The setauthor() 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
1. Register/login to a wger instance
2. Create a malicious ingredient via the web form at /en/nutrition/ingredient/add/: - Set Name to any valid name (e.g., "XSS Form Verified") - Set Energy to 125, Protein to 10, Carbohydrates to 10, Fat to 5 (energy must approximately match macros) - Set Author(s) (licenseauthor) to: <img src=x onerror="alert(document.cookie)"> - Submit the form — the form validates and saves successfully with no sanitization
3. 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:
html <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):
bash Upload exercise image with XSS in licenseauthor curl -X POST https://wger.example.com/api/v2/exerciseimage/ \ -H "Authorization: Token <token>" \ -F "exercise=1" \ -F "image=@photo.jpg" \ -F 'licenseauthor=<img src=x onerror="alert(document.cookie)">' \ -F "license=2"
Note: ExerciseImage's attributionlink 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 attributionlink 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 attributionlink property with properly escaped HTML using Django's formathtml():
python from django.utils.html import formathtml, escape
@property def attributionlink(self): parts = []
if self.licenseobjecturl: parts.append(formathtml('<a href="{}">{}</a>', self.licenseobjecturl, self.licensetitle)) else: parts.append(escape(self.licensetitle))
parts.append(' by ')
if self.licenseauthorurl: parts.append(formathtml('<a href="{}">{}</a>', self.licenseauthorurl, self.licenseauthor)) else: parts.append(escape(self.licenseauthor))
parts.append(formathtml( ' is licensed under <a href="{}">{}</a>', self.license.url, self.license.shortname ))
if self.licensederivativesourceurl: parts.append(formathtml( '/ A derivative work from <a href="{}">the original work</a>', self.licensederivativesourceurl ))
return marksafe(''.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
- Django Security: Cross Site Scripting (XSS) protection - Django formathtml() documentation - OWASP: Stored Cross-Site Scripting
Other sources
wger is a free, open-source workout and fitness manager. In versions 2.5 and below, the attributionlink property in AbstractLicenseModel constructs HTML by directly interpolating user-controlled license fields (such as licenseauthor) without escaping, and templates render the result using Django's |safe filter. An authenticated user can create an ingredient with a malicious licenseauthor value containing JavaScript, which executes in the browser of any visitor viewing the ingredient page, resulting in stored XSS. This issue has been fixed in version 2.5.
— MITRE
Affected Software
Event History
Frequently Asked Questions
What is the severity of CVE-2026-40353?
CVE-2026-40353 is classified as a critical stored XSS vulnerability.
How do I fix CVE-2026-40353?
To fix CVE-2026-40353, ensure that user input is properly escaped before being used in HTML output.
Which versions of wger are affected by CVE-2026-40353?
CVE-2026-40353 affects all versions of wger up to and including version 2.4.
What is the impact of CVE-2026-40353 on users?
CVE-2026-40353 allows attackers to execute arbitrary scripts in the context of the affected web application.
Is user data at risk with CVE-2026-40353?
Yes, user data can be at risk as attackers may exploit the stored XSS vulnerability to access sensitive information.