From 3a71777499bb8128576fcdecd9bc381d285fdfe5 Mon Sep 17 00:00:00 2001
From: dgtlmoon
Date: Fri, 4 Sep 2026 12:38:20 +0200
Subject: [PATCH] UI - Fixing lots of actions that should be a `POST` style
action which led to a 404
Also solves some of GHSA-56fq-63vj-9992 Add-watch-UI should be POST/CSRF protected
---
.../blueprint/add_watch_ui/__init__.py | 10 +-
.../add_watch_ui/static/add-watch.js | 11 +-
.../blueprint/backups/__init__.py | 2 +-
.../backups/templates/backup_create.html | 6 +-
.../blueprint/browser_steps/__init__.py | 2 +-
.../blueprint/check_proxies/__init__.py | 2 +-
changedetectionio/blueprint/settings/llm.py | 2 +-
.../settings/templates/settings_llm_tab.html | 5 +-
changedetectionio/blueprint/ui/__init__.py | 2 +-
changedetectionio/flask_app.py | 4 +-
changedetectionio/static/js/browser-steps.js | 2 +-
changedetectionio/static/js/recheck-proxy.js | 2 +-
.../styles/scss/parts/_hamburger_menu.scss | 10 +-
.../static/styles/scss/parts/_language.scss | 10 +
.../static/styles/scss/parts/_menu.scss | 7 +
changedetectionio/static/styles/styles.css | 2 +-
changedetectionio/templates/base.html | 14 +-
changedetectionio/templates/menu.html | 5 +-
.../tests/proxy_socks5/test_socks5_proxy.py | 4 +-
.../tests/test_access_control.py | 2 +-
.../tests/test_add_watch_ui_browser.py | 29 ++-
changedetectionio/tests/test_backup.py | 4 +-
changedetectionio/tests/test_i18n.py | 42 +--
.../tests/test_language_selector_anonymous.py | 6 +-
.../tests/test_llm_api_key_security.py | 6 +-
changedetectionio/tests/test_rss_tag_token.py | 2 +-
changedetectionio/tests/test_security.py | 10 +-
.../tests/unit/test_fetch_url_gate.py | 242 ++++++++++++++++++
.../tests/visualselector/test_fetch_data.py | 4 +-
changedetectionio/translations/messages.pot | 2 +-
30 files changed, 381 insertions(+), 70 deletions(-)
create mode 100644 changedetectionio/tests/unit/test_fetch_url_gate.py
diff --git a/changedetectionio/blueprint/add_watch_ui/__init__.py b/changedetectionio/blueprint/add_watch_ui/__init__.py
index 7d502ee41..b82532b89 100644
--- a/changedetectionio/blueprint/add_watch_ui/__init__.py
+++ b/changedetectionio/blueprint/add_watch_ui/__init__.py
@@ -42,7 +42,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
system_default_browser=browser_config.system_default_description(datastore),
)
- @add_watch_ui_blueprint.route("/snapshot", methods=['GET'])
+ @add_watch_ui_blueprint.route("/snapshot", methods=['POST'])
@login_optionally_required
def add_watch_ui_snapshot():
"""One-shot live fetch of an arbitrary URL for the Add Watch visual selector.
@@ -52,6 +52,10 @@ def construct_blueprint(datastore: ChangeDetectionStore):
connect, "Goto site", grab the screenshot + xpath element data, then tear
the browser down again. Element selection then happens client-side on the
returned data, exactly like the watch Edit page's visual selector.
+
+ POST-only and CSRF protected on purpose: this drives a real browser fetch and
+ writes a temporary watch dir, so as a GET it could be triggered cross-origin
+ (or by any tag/link that issues a GET) without the operator's consent.
"""
import base64
from changedetectionio.blueprint.browser_steps import (
@@ -71,7 +75,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
# backslash/parser-differential rejection of GHSA-rph4-96w6-q594 (GHSA-56fq-63vj-9992).
# Note this fetch never reaches difference_detection_processor.call_browser(), so it gets
# no gating from there - it has to validate for itself.
- url = (request.args.get('url') or '').strip()
+ url = (request.form.get('url') or '').strip()
ok, reason = is_fetch_url_allowed(url)
if not ok:
logger.warning(f"Add-watch snapshot: refused '{url}' - {reason}")
@@ -82,7 +86,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
# Either way it has to be able to render a preview - the plain HTTP client
# produces no screenshot and no element data, so previewing with it is pointless
# (and it used to be the silent default here, see the system-default bug).
- fetcher_name = (request.args.get('fetch_backend') or '').strip() or browser_config.default_visual_browser(datastore)
+ fetcher_name = (request.form.get('fetch_backend') or '').strip() or browser_config.default_visual_browser(datastore)
if not fetcher_name or not browser_config.is_visual_capable(fetcher_name, datastore):
logger.warning(f"Add-watch snapshot: refused browser '{fetcher_name}' for '{url}'")
return make_response('No interactive browser available that can render a live preview '
diff --git a/changedetectionio/blueprint/add_watch_ui/static/add-watch.js b/changedetectionio/blueprint/add_watch_ui/static/add-watch.js
index 59c250374..be586d5ce 100644
--- a/changedetectionio/blueprint/add_watch_ui/static/add-watch.js
+++ b/changedetectionio/blueprint/add_watch_ui/static/add-watch.js
@@ -59,9 +59,18 @@ $(document).ready(() => {
$.ajax({
url: add_watch_snapshot_url,
+ // POST, never GET - this makes the server-side browser fetch a URL of our
+ // choosing, so it must not be triggerable cross-origin. csrf.js adds the
+ // X-CSRFToken header to every non-GET ajax call; the CSRF field on the form
+ // is sent too so it works even if that handler hasn't run yet.
+ method: 'POST',
// Preview with the browser picked in the list - that same browser is what
// gets saved on the watch, so what you see here is what it will check with.
- data: {url: url, fetch_backend: $('input[name="fetch_backend"]:checked').val() || ''},
+ data: {
+ url: url,
+ fetch_backend: $('input[name="fetch_backend"]:checked').val() || '',
+ csrf_token: $('#new-watch-form input[name="csrf_token"]').val() || '',
+ },
dataType: 'json',
}).done((data) => {
showState('ready');
diff --git a/changedetectionio/blueprint/backups/__init__.py b/changedetectionio/blueprint/backups/__init__.py
index d3c97a61a..b6b4c751c 100644
--- a/changedetectionio/blueprint/backups/__init__.py
+++ b/changedetectionio/blueprint/backups/__init__.py
@@ -98,7 +98,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
backups_blueprint.register_blueprint(construct_restore_blueprint(datastore))
backup_threads = []
- @backups_blueprint.route("/request-backup", methods=['GET'])
+ @backups_blueprint.route("/request-backup", methods=['POST'])
@login_optionally_required
def request_backup():
if any(thread.is_alive() for thread in backup_threads):
diff --git a/changedetectionio/blueprint/backups/templates/backup_create.html b/changedetectionio/blueprint/backups/templates/backup_create.html
index 8bbd6f644..0718c5b73 100644
--- a/changedetectionio/blueprint/backups/templates/backup_create.html
+++ b/changedetectionio/blueprint/backups/templates/backup_create.html
@@ -35,8 +35,10 @@
{{ _('Language support is in beta, please help us improve by opening a PR on GitHub with any updates.') }}
diff --git a/changedetectionio/templates/menu.html b/changedetectionio/templates/menu.html
index e199cc6f1..859081c5a 100644
--- a/changedetectionio/templates/menu.html
+++ b/changedetectionio/templates/menu.html
@@ -18,7 +18,10 @@
{%- if current_user.is_authenticated -%}
{%- endif -%}
diff --git a/changedetectionio/tests/proxy_socks5/test_socks5_proxy.py b/changedetectionio/tests/proxy_socks5/test_socks5_proxy.py
index 99b6d54d1..69da88b77 100644
--- a/changedetectionio/tests/proxy_socks5/test_socks5_proxy.py
+++ b/changedetectionio/tests/proxy_socks5/test_socks5_proxy.py
@@ -84,10 +84,12 @@ def test_socks5(client, live_server, measure_memory_usage, datastore_path):
# PROXY CHECKER WIDGET CHECK - this needs more checking
uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
- res = client.get(
+ # POST only - it kicks off real fetches through every configured proxy
+ res = client.post(
url_for("check_proxies.start_check", uuid=uuid),
follow_redirects=True
)
+ assert res.status_code == 200
# It's probably already finished super fast :(
#assert b"RUNNING" in res.data
diff --git a/changedetectionio/tests/test_access_control.py b/changedetectionio/tests/test_access_control.py
index 771a9df8b..2ab8c76e0 100644
--- a/changedetectionio/tests/test_access_control.py
+++ b/changedetectionio/tests/test_access_control.py
@@ -125,7 +125,7 @@ def test_check_access_control(app, client, live_server, measure_memory_usage, da
follow_redirects=True
)
- res = c.get(url_for("logout"),
+ res = c.post(url_for("logout"),
follow_redirects=True)
assert b"Login" in res.data
diff --git a/changedetectionio/tests/test_add_watch_ui_browser.py b/changedetectionio/tests/test_add_watch_ui_browser.py
index 56c9f0ff0..d49d99483 100644
--- a/changedetectionio/tests/test_add_watch_ui_browser.py
+++ b/changedetectionio/tests/test_add_watch_ui_browser.py
@@ -79,26 +79,41 @@ def test_snapshot_refuses_browser_that_cannot_preview(client, live_server, measu
from changedetectionio.blueprint.add_watch_ui import browser_config
monkeypatch.setattr(browser_config, 'is_visual_capable', lambda name, datastore: False)
+ snapshot_url = url_for('add_watch_ui.add_watch_ui_snapshot')
+
# Nothing capable, and no explicit browser asked for -> nothing to preview with
- res = client.get(url_for('add_watch_ui.add_watch_ui_snapshot', url='https://example.com'))
+ res = client.post(snapshot_url, data={'url': 'https://example.com'})
assert res.status_code == 400
assert b'No interactive browser' in res.data
# Explicitly asking for a browser that can't preview is refused just the same
- res = client.get(url_for('add_watch_ui.add_watch_ui_snapshot', url='https://example.com',
- fetch_backend='html_requests'))
+ res = client.post(snapshot_url, data={'url': 'https://example.com',
+ 'fetch_backend': 'html_requests'})
assert res.status_code == 400
# A made-up name never resolves to a capable fetcher either (real capability lookup here)
monkeypatch.undo()
- res = client.get(url_for('add_watch_ui.add_watch_ui_snapshot', url='https://example.com',
- fetch_backend='../../etc/passwd'))
+ res = client.post(snapshot_url, data={'url': 'https://example.com',
+ 'fetch_backend': '../../etc/passwd'})
assert res.status_code == 400
- res = client.get(url_for('add_watch_ui.add_watch_ui_snapshot', url='https://example.com',
- fetch_backend='os'))
+ res = client.post(snapshot_url, data={'url': 'https://example.com',
+ 'fetch_backend': 'os'})
assert res.status_code == 400
+def test_snapshot_is_post_only(client, live_server, measure_memory_usage, datastore_path):
+ """A GET must not reach the endpoint at all.
+
+ /snapshot drives a real server-side browser fetch and hands the rendered result back in
+ the response (GHSA-56fq-63vj-9992). As a GET that is reachable by anything that can make
+ the operator's browser issue a request - an /