+ "details": "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.\n\nThis 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.\n\n## Package\n\n- **Ecosystem:** pip\n- **Package:** changedetection.io\n- **Affected versions:** <= 0.54.1\n- **Patched versions:** _(none yet)_\n\n\n## Severity\n**Moderate - CVSS 6.1**\n`CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N`\n\n\n## Details\n**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)\n\nThe `tag_uuid` parameter from the URL path is interpolated into the response body using an f-string with no escaping:\n\n```python\ntag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid)\nif not tag:\n return f\"Tag with UUID {tag_uuid} not found\", 404 # ← No escaping, Content-Type: text/html\n\n```\n\nFlask'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.\n\n### Relationship to CVE-2026-27645\n\nCVE-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:\n\n- **`/rss/watch/` on v0.54.1** — Returns generic 404 page, XSS no longer triggers ✅\n- **`/rss/tag/` on v0.54.1** — XSS payload still fires, vulnerability confirmed ❌\n\n## Attack Vector\n\nThe 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:\n\n1. Attacker visits the target's homepage (if unauthenticated) and extracts the RSS token from the `<link>` tag\n2. Crafts a malicious URL:\n \n ```\n http://target:5000/rss/tag/<img src=x onerror=alert(document.cookie)>?token=EXTRACTED_TOKEN\n \n ```\n \n3. Sends the link to a victim who has an active session on the changedetection.io instance\n4. When the victim clicks the link, the server responds with:\n \n ```\n Tag with UUID <img src=x onerror=alert(document.cookie)> not found\n \n ```\n \n5. The browser renders the `<img>` tag, the `onerror` fires, and JavaScript executes in the victim's session context\n\n## Proof of Concept\n\n### Request\n\n```http\nGET /rss/tag/%3Cimg%20src%3Dx%20onerror%3Dalert(document.domain)%3E?token=60b83b06df98b24c66367bc3d233105b HTTP/1.1\nHost: localhost:5000\n\n```\n\n### Response\n\n```http\nHTTP/1.1 404 NOT FOUND\nContent-Type: text/html; charset=utf-8\n\nTag with UUID <img src=x onerror=alert(document.domain)> not found\n\n```\n\nThe XSS payload is reflected unescaped in an HTML response. The browser executes `alert(document.domain)` and displays \"localhost\", confirming JavaScript execution.\n\n**Tested on:** changedetection.io v0.54.1 (Docker, localhost, Feb 25, 2026)\n\n\nhttps://github.com/user-attachments/assets/6db07f6a-6df8-48a7-a597-9f39dfa1bb29\n\n\n## Impact\n\n- **Session cookie theft** via `document.cookie` exfiltration\n- **Account takeover** if session cookies lack the `HttpOnly` flag\n- **Phishing** via crafted links that appear to originate from a trusted changedetection.io instance\n- **Low exploitation barrier** - the RSS token is obtainable without authentication from the homepage `<link>` tag\n- **Widespread exposure** - prior scanning of internet-facing instances (during CVE-2026-27645 research) identified 500+ publicly accessible deployments\n\n## Suggested Fix\n\nEscape the `tag_uuid` parameter before reflecting it in the response, or set the `Content-Type` to `text/plain`:\n\n### Option A: HTML Escape (Recommended)\n\n```python\nfrom markupsafe import escape\n\nif not tag:\n return f\"Tag with UUID {escape(tag_uuid)} not found\", 404\n\n```\n\n### Option B: Set Content-Type to text/plain\n\n```python\nfrom flask import make_response\n\nif not tag:\n resp = make_response(f\"Tag with UUID {tag_uuid} not found\", 404)\n resp.headers['Content-Type'] = 'text/plain; charset=utf-8'\n return resp\n\n```\n## Credits\n\n- **Roberto Nunes** ([@Akokonunes](https://github.com/Akokonunes)) - Reporter\n- **neo-ai-engineer** ([@neo-ai-engineer](https://github.com/neo-ai-engineer)) - Reporter\n\n## References\n- Related advisory: [GHSA-mw8m-398g-h89w](https://github.com/dgtlmoon/changedetection.io/security/advisories/GHSA-mw8m-398g-h89w) (CVE-2026-27645)\n- Vulnerable source: [tag.py @ 1d72716](https://raw.githubusercontent.com/dgtlmoon/changedetection.io/1d72716c6988a4f6796bb85a5d42872800cd7a70/changedetectionio/blueprint/rss/tag.py)",
0 commit comments