CVE-2026-40353: wger: Stored XSS via Unescaped License Attribution Fields

Published Apr 16, 2026
·
Updated

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

2 affected components
pip/wger<=2.4
wger wger<2.5

Event History

Apr 16, 2026
Advisory Published
via GitHub·01:37 AM
Data Sourced
via GitHub·01:37 AM
DescriptionWeaknessAffected Software
Apr 17, 2026
CVE Published
via MITRE·09:16 PM
Data Sourced
via MITRE·09:16 PM
DescriptionWeakness
Data Sourced
via NVD·10:16 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

What is the severity of CVE-2026-40353?

CVE-2026-40353 is classified as a critical stored XSS vulnerability.

2

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.

3

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.

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.

5

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.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203