GHSA-6f54-qjvm-wwq3: wger has Stored XSS via Unescaped License Attribution Fields
Stored XSS via Unescaped License Attribution FieldsSummaryThe `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 databaseTriggers on a public page (no authentication needed to view)Can steal session cookies, perform actions as other users, redirect to phishingCWECWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')Affected ComponentsVulnerable Property**File:** `wger/utils/models.py:88-110`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`**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 Affected6 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_link` constructs raw HTML by string interpolation of user-controlled fields without calling `django.utils.html.escape()` or `django.utils.html.format_html()`The template renders the result with `|safe`, bypassing Django's auto-escapingThe `license_author` field in `IngredientForm` has no input sanitizationThe `set_author()` method only sets a default value if the field is empty — it does not sanitize user-provided valuesReproduction Steps (Verified)PrerequisitesA 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 `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)` (license_author) to: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 pageThe XSS payload executes in the browserVerified PoC OutputThe rendered HTML in the ingredient detail page (line 171 of `ingredient/view.html`) contains: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):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 pagesSuggested FixReplace the `attribution_link` property with properly escaped HTML using Django's `format_html()`: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](https://docs.djangoproject.com/en/5.0/topics/security/#cross-site-scripting-xss-protection)[Django `format_html()` documentation](https://docs.djangoproject.com/en/5.0/ref/utils/#django.utils.html.format_html)[OWASP: Stored Cross-Site Scripting](https://owasp.org/www-community/attacks/xss/#stored-xss-attacks)
CVSS v4.0: 5.1
CVSS v3.1: 5.4