Know every vulnerabilitybefore it knows you.
DevGuard continuously monitors your dependencies and alerts you when CVEs like this one affect your stack — with real-time threat intelligence built for developers.
GHSA-6f54-qjvm-wwq3
No affected components available
Stored 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
- Set
-
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
The vulnerability can be exploited over the network without needing physical access. It is easy for an attacker to exploit this vulnerability. An attacker needs basic access or low-level privileges. The attacker needs the user to perform some action, like clicking a link. The vulnerability can affect other systems as well, not just the initial system. There is a low impact on the confidentiality of the information. There is a low impact on the integrity of the data.
Exploitation attempts have been detected. Elevated vigilance and prompt remediation are advised.
The exploit probability is very low. The vulnerability is unlikely to be exploited in the next 30 days.
We did not find any exploit available. Neither in GitHub repositories nor in the Exploit-Database.
Browse More
Continuously monitor your dependencies and get alerted when vulnerabilities like this one affect your stack.
Checkout DevGuard