mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-09-02 19:47:03 +00:00
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
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (#4324)
87 lines
3.8 KiB
Python
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
|