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/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
/