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

Source: https://junglewise.ai/threats/cve-2026-40353-wger-has-stored-xss-via-unescaped-license-attribution-fields  
Publisher: Junglewise Threat Intelligence, https://junglewise.ai/threats  
Published: 2026-04-16. Updated: 2026-05-11  
License: CC BY 4.0 with a link back

CVE-2026-40353 · Severity: medium · CVSS 5.4 · Published 2026-04-16

# 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`

```python
@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`

```python
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

1. `attribution_link` constructs raw HTML by string interpolation of user-controlled fields without calling `django.utils.html.escape()` or `django.utils.html.format_html()`
2. The template renders the result with `|safe`, bypassing Django's auto-escaping
3. The `license_author` field in `IngredientForm` has no input sanitization
4. 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

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)` (license_author) 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 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()`:

```python
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

- [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)

Technologies: [wger (PyPI)](https://junglewise.ai/threats/technologies/wger). Vendors: [PyPI](https://junglewise.ai/threats/vendors/pypi).

## Quick answers

**Is CVE-2026-40353 exploited in the wild?** No. Junglewise Threat Intelligence has no report of CVE-2026-40353 being exploited in the wild.

**How severe is CVE-2026-40353?** Medium severity, with a CVSS score of 5.4, published 16 April 2026.

**Which products and versions are affected?** wger (PyPI) <= 2.4, ECOSYSTEM:introduced=0,last_affected=2.4.

**Is there a fix for CVE-2026-40353?** No fixed version is recorded yet. Check the vendor's advisory in the references below. If you run it, Junglewise drafts a mitigation that fits your setup and applies it once you approve, with senior security engineers behind it.

**Am I affected by CVE-2026-40353?** Check whether you run wger (PyPI) at an affected version. Junglewise does that check for you around the clock: connect your code, cloud and domains, and it matches new vulnerabilities like this one against what you actually run and tells you when one reaches you.

## Affected products

- wger (PyPI) <= 2.4, ECOSYSTEM:introduced=0,last_affected=2.4

## Identifiers

- CVE-2026-40353
- GHSA-6f54-qjvm-wwq3
- PYSEC-2026-3419

## References

- [https://api.github.com/users/0xkakash1](https://api.github.com/users/0xkakash1)
- [https://github.com/0xkakash1](https://github.com/0xkakash1)
- [https://api.github.com/users/0xkakash1/gists%7B/gist_id%7D](https://api.github.com/users/0xkakash1/gists%7B/gist_id%7D)
- [https://api.github.com/users/0xkakash1/repos](https://api.github.com/users/0xkakash1/repos)
- [https://avatars.githubusercontent.com/u/63727500?v=4](https://avatars.githubusercontent.com/u/63727500?v=4)
- [https://api.github.com/users/0xkakash1/events%7B/privacy%7D](https://api.github.com/users/0xkakash1/events%7B/privacy%7D)

## Related threats

- [wger authorization bypass in workout log entry creation](https://junglewise.ai/threats/cve-2026-46438-wger-cross-user-data-corruption-via-missing-ownership-check-on)
- [wger Trainer privilege escalation in user deactivation](https://junglewise.ai/threats/cve-2026-46434-wger-trainer-privilege-escalation-improper-privilege-management)
- [wger API credentials remain valid after logout/password change](https://junglewise.ai/threats/cve-2026-46437-wger-api-credentials-remain-valid-after-logout-password-change)
- [wger trainer_login CSRF bypass via GET request](https://junglewise.ai/threats/cve-2026-45161-wger-trainer-login-accepts-get-csrf-bypass-enables-forced-session)
- [wger gym-scope guard bypass allows cross-tenant admin notes leak](https://junglewise.ai/threats/cve-2026-43976-wger-cross-tenant-admin-notes-contracts-leak-via-gym-none-bypass)
