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 @@
{% endif %} - {{ _('Create backup') }} + {% if available_backups %} {# POST + CSRF token: this permanently deletes every backup archive, so it must not be reachable from a bare GET (an