Files
changedetection.io/changedetectionio/tests/test_diff_download_patch_xss.py
T
dgtlmoon f42da28dab
Build and push containers / metadata (push) Canceled after 0s
Build and push containers / build-push-containers (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Canceled after 0s
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Canceled after 0s
ChangeDetection.io App Test / lint-code (push) Canceled after 0s
ChangeDetection.io App Test / lint-translations (push) Canceled after 0s
ChangeDetection.io App Test / lint-template-i18n (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-10 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-11 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-12 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-13 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-14 (push) Canceled after 0s
GHSA-23mp-8222-96fr - reflected XSS in /diff/<uuid>/download-patch via from_version - Using built-in plain text response instead. Reported-by: Mayssare Amakhtari (@cy3erm)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (#4324)
2026-08-24 16:24:22 +02:00

87 lines
3.8 KiB
Python

#!/usr/bin/env python3
"""
Reflected XSS regression tests for /diff/<uuid>/download-patch (GHSA-23mp-8222-96fr).
get_history_snapshot() does a plain dict lookup on the history, so an unknown timestamp
raised KeyError whose str() carries the raw from_version/to_version query parameter. The
error path interpolated that into make_response(), which defaults to text/html - so the
parameter was reflected unescaped and executed in the victim's browser.
Two independent guarantees are asserted here, because either alone would have prevented
this bug and both are worth keeping:
1. Unknown timestamps are rejected up front (404), so the exception never fires.
2. Whatever the error path returns is text/plain and never echoes the input.
This is the third instance of the same pattern, after CVE-2026-27645 (/rss/watch/) and
CVE-2026-29038 (/rss/tag/).
"""
import os
from flask import url_for
from .util import wait_for_all_checks
XSS_PAYLOADS = [
'<svg/onload=alert(document.domain)>',
'"><script>alert(1)</script>',
"'><img src=x onerror=alert(1)>",
]
def _setup_watch_with_history(client, datastore_path):
"""A watch needs >=2 snapshots before download-patch will get as far as the lookup."""
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
f.write("first content")
test_url = url_for('test_endpoint', _external=True)
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)
client.post(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
f.write("second content, now different")
client.post(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
return uuid
def test_download_patch_does_not_reflect_unknown_timestamp(client, live_server, measure_memory_usage, datastore_path):
uuid = _setup_watch_with_history(client, datastore_path)
for payload in XSS_PAYLOADS:
# from_version and to_version are separate code paths; both must be validated
for param in ('from_version', 'to_version'):
res = client.get(url_for("ui.ui_diff.download_patch", uuid=uuid, **{param: payload}))
assert payload.encode() not in res.data, \
f"{param}={payload!r} was reflected into the response body"
# An unknown timestamp is a client error, not a 500 from an unhandled KeyError
assert res.status_code == 404, \
f"{param}={payload!r} should be rejected up front, got {res.status_code}"
# Even if a future refactor lets the exception path run again, the body must
# not be parseable as HTML
if res.status_code >= 400:
assert 'text/html' not in res.headers.get('Content-Type', ''), \
f"error response for {param}={payload!r} should not be text/html"
def test_download_patch_still_works_for_valid_timestamps(client, live_server, measure_memory_usage, datastore_path):
"""The validation must not break the legitimate path."""
uuid = _setup_watch_with_history(client, datastore_path)
watch = client.application.config.get('DATASTORE').data['watching'][uuid]
dates = list(watch.history.keys())
assert len(dates) >= 2
res = client.get(url_for("ui.ui_diff.download_patch", uuid=uuid,
from_version=dates[-2], to_version=dates[-1]))
assert res.status_code == 200
assert 'text/plain' in res.headers.get('Content-Type', '')
assert b'second content' in res.data, "Patch should contain the changed text"
# And with no params at all it should default to the last two snapshots
res = client.get(url_for("ui.ui_diff.download_patch", uuid=uuid))
assert res.status_code == 200
assert b'second content' in res.data