From bdf54ff33f65090ddb069db89f882b697d700507 Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Tue, 19 May 2026 11:06:47 +0200 Subject: [PATCH] API Security - Watch GET history snapshot - Should return `text/plain` mimetype so it cant be accidently executed in the browser --- changedetectionio/api/Watch.py | 14 +++- changedetectionio/tests/test_api_security.py | 70 +++++++++++++++++++- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/changedetectionio/api/Watch.py b/changedetectionio/api/Watch.py index 9cb4d721..f6fe24c5 100644 --- a/changedetectionio/api/Watch.py +++ b/changedetectionio/api/Watch.py @@ -278,8 +278,20 @@ class WatchSingleHistory(Resource): if request.args.get('html'): content = watch.get_fetched_html(timestamp) if content: + # XSS mitigation (GHSA-cgj8-g98g-4p9x): this is an API endpoint, not a + # browser-rendered view. The bytes ARE HTML (that's what the caller asked + # for) but a programmatic client doesn't need text/html — and serving + # text/html lets attacker-planted " + "" + "" + ) + ts = '1700000000' + watch = live_server.app.config['DATASTORE'].data['watching'][watch_uuid] + watch.save_history_blob(contents=malicious_html, timestamp=ts, snapshot_id=ts) + watch.save_last_fetched_html(timestamp=ts, contents=malicious_html) + + # The actual XSS-relevant assertion: how is the snapshot served? + res = client.get( + url_for("watchsinglehistory", uuid=watch_uuid, timestamp=ts) + '?html=true', + headers={'x-api-key': api_key}, + ) + assert res.status_code == 200, f"unexpected status {res.status_code}: {res.data!r}" + + ctype = res.headers.get('Content-Type', '') + assert 'text/html' not in ctype, \ + f"snapshot must not be served as text/html (got {ctype!r}) — see GHSA-cgj8-g98g-4p9x" + # Explicit utf-8 closes the UTF-7 sniffing bypass — without a charset, some + # browsers will auto-detect UTF-7 from byte patterns and a crafted snapshot + # can still execute via `+ADw-script+AD4-...` + assert 'charset=utf-8' in ctype.lower(), \ + f"Content-Type must pin charset=utf-8 to defeat UTF-7 sniffing XSS (got {ctype!r})" + + nosniff = res.headers.get('X-Content-Type-Options', '') + assert nosniff.lower() == 'nosniff', \ + f"X-Content-Type-Options: nosniff required to defeat MIME-sniffing (got {nosniff!r})" + + # API contract: the raw bytes must still be the original HTML — programmatic + # consumers depend on getting the stored snapshot back. + assert b'