GHSA-6f54-qjvm-wwq3
MEDIUMGHSA-6f54-qjvm-wwq3 is a medium-severity (CVSS 5.4) vulnerability in wger. O3 Security confirms whether GHSA-6f54-qjvm-wwq3 is actually reachable in your code before you act, and blocks exploitation at runtime until you patch.
wger has Stored XSS via Unescaped License Attribution Fields
Blast Radius
wgerReal-time download stats are indexed for npm and PyPI packages. This vulnerability affects PyPI packages — download data is not available via public APIs for these ecosystems.
Description
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 "[email protected]" \
-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 Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 🐍PyPI | wger | all versions | No fix |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for wger. O3's reachability analysis confirms whether the vulnerable code path is actually invoked in your application, so you act on real exposure instead of every transitive match.
Remediation status
No patched version of wger has shipped for GHSA-6f54-qjvm-wwq3 yet. Where your build allows, override or pin the dependency away from the vulnerable range, and apply any maintainer-recommended mitigation.
Mitigate without a patch
If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.
How O3 protects you
O3 pinpoints whether GHSA-6f54-qjvm-wwq3 is reachable in your code and exactly where to fix it, then blocks exploitation in production at runtime until the patched version is deployed.
Tailored to GHSA-6f54-qjvm-wwq3. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.
Frequently Asked Questions
Is GHSA-6f54-qjvm-wwq3 in your dependencies?
O3 detects GHSA-6f54-qjvm-wwq3 across PyPI dependencies and uses function-level reachability to confirm whether the vulnerable code path is actually reachable — not just present. No false positives.