From 89a41c0ddaecfaba440dd62b0cfc1e122af58bf2 Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Thu, 20 Aug 2026 10:58:39 +0200 Subject: [PATCH] GHSA-3v57-xv5m-v7m7 - Multiple front-end endpoints lacking CSRF protection --- .../blueprint/backups/__init__.py | 2 +- .../backups/templates/backup_create.html | 9 +++- changedetectionio/blueprint/tags/__init__.py | 6 +-- .../tags/templates/groups-overview.html | 7 ++- changedetectionio/blueprint/ui/__init__.py | 10 ++-- .../blueprint/ui/templates/edit.html | 3 ++ .../watchlist/templates/watch-overview.html | 10 +++- changedetectionio/static/js/modal.js | 52 ++++++++++++++----- .../static/styles/scss/parts/_button.scss | 4 ++ changedetectionio/static/styles/styles.css | 2 +- .../test_custom_browser_url.py | 4 +- .../fetchers/test_custom_js_before_content.py | 2 +- .../tests/plugins/test_processor.py | 4 +- .../tests/proxy_list/test_noproxy.py | 2 +- .../tests/restock/test_restock.py | 12 ++--- .../tests/smtp/test_notification_smtp.py | 30 +++++------ .../tests/test_add_replace_remove_filter.py | 18 +++---- changedetectionio/tests/test_auth.py | 2 +- .../test_automatic_follow_ldjson_price.py | 8 +-- changedetectionio/tests/test_backend.py | 38 +++++++------- changedetectionio/tests/test_backup.py | 8 +-- .../tests/test_basic_socketio.py | 4 +- .../tests/test_block_while_text_present.py | 14 ++--- changedetectionio/tests/test_clone.py | 4 +- .../tests/test_commit_persistence.py | 2 +- changedetectionio/tests/test_conditions.py | 20 +++---- changedetectionio/tests/test_css_selector.py | 10 ++-- .../tests/test_element_removal.py | 8 +-- changedetectionio/tests/test_encoding.py | 8 +-- changedetectionio/tests/test_errorhandling.py | 4 +- changedetectionio/tests/test_extract_csv.py | 2 +- changedetectionio/tests/test_extract_regex.py | 32 ++++++------ .../tests/test_filter_exist_changes.py | 2 +- .../tests/test_filter_failure_notification.py | 12 ++--- changedetectionio/tests/test_group.py | 26 +++++----- .../tests/test_history_consistency.py | 12 ++--- changedetectionio/tests/test_ignore.py | 4 +- changedetectionio/tests/test_ignore_text.py | 16 +++--- .../tests/test_ignorehyperlinks.py | 6 +-- .../tests/test_ignorestatuscode.py | 8 +-- .../tests/test_ignorewhitespace.py | 6 +-- .../tests/test_jsonpath_jq_selector.py | 24 ++++----- changedetectionio/tests/test_live_preview.py | 4 +- changedetectionio/tests/test_llm_preview.py | 2 +- .../tests/test_nonrenderable_pages.py | 8 +-- changedetectionio/tests/test_notification.py | 42 +++++++-------- changedetectionio/tests/test_obfuscations.py | 2 +- changedetectionio/tests/test_pdf.py | 4 +- .../tests/test_preview_endpoints.py | 4 +- changedetectionio/tests/test_queue_handler.py | 2 +- changedetectionio/tests/test_request.py | 22 ++++---- .../tests/test_restock_itemprop.py | 42 +++++++-------- changedetectionio/tests/test_rss.py | 14 ++--- changedetectionio/tests/test_rss_group.py | 12 ++--- .../tests/test_rss_reader_mode.py | 6 +-- .../tests/test_rss_single_watch.py | 8 +-- changedetectionio/tests/test_rss_tag_token.py | 2 +- changedetectionio/tests/test_scheduler.py | 6 +-- changedetectionio/tests/test_security.py | 6 +-- .../test_settings_tag_force_reprocess.py | 10 ++-- changedetectionio/tests/test_share_watch.py | 2 +- changedetectionio/tests/test_source.py | 8 +-- changedetectionio/tests/test_trigger.py | 12 ++--- changedetectionio/tests/test_trigger_regex.py | 6 +-- .../tests/test_trigger_regex_with_filter.py | 8 +-- changedetectionio/tests/test_ui.py | 10 ++-- changedetectionio/tests/test_unique_lines.py | 14 ++--- .../tests/test_watch_edited_flag.py | 6 +-- .../tests/test_watch_fields_storage.py | 2 +- .../tests/test_watchlist_unviewed_state.py | 4 +- .../tests/test_xpath_selector.py | 32 ++++++------ .../tests/visualselector/test_fetch_data.py | 8 +-- 72 files changed, 407 insertions(+), 358 deletions(-) diff --git a/changedetectionio/blueprint/backups/__init__.py b/changedetectionio/blueprint/backups/__init__.py index a3ac3b90..0d162694 100644 --- a/changedetectionio/blueprint/backups/__init__.py +++ b/changedetectionio/blueprint/backups/__init__.py @@ -176,7 +176,7 @@ def construct_blueprint(datastore: ChangeDetectionStore): ) return output - @backups_blueprint.route("/remove-backups", methods=['GET']) + @backups_blueprint.route("/remove-backups", methods=['POST']) @login_optionally_required def remove_backups(): diff --git a/changedetectionio/blueprint/backups/templates/backup_create.html b/changedetectionio/blueprint/backups/templates/backup_create.html index 21678fd2..8bbd6f64 100644 --- a/changedetectionio/blueprint/backups/templates/backup_create.html +++ b/changedetectionio/blueprint/backups/templates/backup_create.html @@ -38,8 +38,13 @@ {{ _('Create backup') }} {% if available_backups %} - {{ _('Remove backups') }} + {# POST + CSRF token: this permanently deletes every backup archive, so it must + not be reachable from a bare GET (an on any page the operator + visits would have triggered it). A real form keeps it working without JS. #} +
+ + +
{% endif %} diff --git a/changedetectionio/blueprint/tags/__init__.py b/changedetectionio/blueprint/tags/__init__.py index d20603e9..c32023cb 100644 --- a/changedetectionio/blueprint/tags/__init__.py +++ b/changedetectionio/blueprint/tags/__init__.py @@ -67,7 +67,7 @@ def construct_blueprint(datastore: ChangeDetectionStore): tag.commit() return redirect(url_for('tags.tags_overview_page')) - @tags_blueprint.route("/delete/", methods=['GET']) + @tags_blueprint.route("/delete/", methods=['POST']) @login_optionally_required def delete(uuid): # Delete the tag from settings immediately @@ -94,7 +94,7 @@ def construct_blueprint(datastore: ChangeDetectionStore): flash(gettext("Tag deleted, removing from watches in background")) return redirect(url_for('tags.tags_overview_page')) - @tags_blueprint.route("/unlink/", methods=['GET']) + @tags_blueprint.route("/unlink/", methods=['POST']) @login_optionally_required def unlink(uuid): # Unlink tag from all watches in background thread to avoid blocking @@ -117,7 +117,7 @@ def construct_blueprint(datastore: ChangeDetectionStore): flash(gettext("Unlinking tag from watches in background")) return redirect(url_for('tags.tags_overview_page')) - @tags_blueprint.route("/delete_all", methods=['GET']) + @tags_blueprint.route("/delete_all", methods=['POST']) @login_optionally_required def delete_all(): diff --git a/changedetectionio/blueprint/tags/templates/groups-overview.html b/changedetectionio/blueprint/tags/templates/groups-overview.html index 769fdb4d..f82e2c9c 100644 --- a/changedetectionio/blueprint/tags/templates/groups-overview.html +++ b/changedetectionio/blueprint/tags/templates/groups-overview.html @@ -72,9 +72,13 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} { {{ tag.title }} {{ _('Edit') }} - {{ _('Recheck') }} +
+ + +
{{ _('Delete') }} ", methods=['GET']) + @ui_blueprint.route("/clear_history/", methods=['POST']) @login_optionally_required def clear_watch_history(uuid): try: @@ -227,7 +227,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_pool, return output # Clear all statuses, so we do not see the 'unviewed' class - @ui_blueprint.route("/form/mark-all-viewed", methods=['GET']) + @ui_blueprint.route("/form/mark-all-viewed", methods=['POST']) @login_optionally_required def mark_all_viewed(): # Save the current newest history as the most recently viewed. Operate on @@ -267,7 +267,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_pool, return redirect(url_for('watchlist.index', **wl_filters.filter_query_args(request.args))) - @ui_blueprint.route("/delete", methods=['GET']) + @ui_blueprint.route("/delete", methods=['POST']) @login_optionally_required def form_delete(): uuid = request.args.get('uuid') @@ -284,7 +284,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_pool, return redirect(url_for('watchlist.index')) - @ui_blueprint.route("/clone", methods=['GET']) + @ui_blueprint.route("/clone", methods=['POST']) @login_optionally_required def form_clone(): uuid = request.args.get('uuid') @@ -301,7 +301,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_pool, return redirect(url_for("ui.ui_edit.edit_page", uuid=new_uuid)) - @ui_blueprint.route("/checknow", methods=['GET']) + @ui_blueprint.route("/checknow", methods=['POST']) @login_optionally_required def form_watch_checknow(): # Forced recheck will skip the 'skip if content is the same' rule (, 'reprocess_existing_data': True}))) diff --git a/changedetectionio/blueprint/ui/templates/edit.html b/changedetectionio/blueprint/ui/templates/edit.html index aead58ad..0c2ae1eb 100644 --- a/changedetectionio/blueprint/ui/templates/edit.html +++ b/changedetectionio/blueprint/ui/templates/edit.html @@ -540,6 +540,7 @@ Math: {{ 1 + 1 }}") }} {{ render_button(form.save_button) }} {{ _('Delete') }} {% if watch.history_n %}{{ _('Clear History') }}{% endif %} {{ _('Clone & Edit') }} {{ _('RSS Feed for this watch') }} diff --git a/changedetectionio/blueprint/watchlist/templates/watch-overview.html b/changedetectionio/blueprint/watchlist/templates/watch-overview.html index 0c747694..204fe94c 100644 --- a/changedetectionio/blueprint/watchlist/templates/watch-overview.html +++ b/changedetectionio/blueprint/watchlist/templates/watch-overview.html @@ -272,8 +272,14 @@ window.watchOverviewI18n = { It used to be {% if unread_count %}-gated, which meant it could not exist in the DOM of another open tab — so marking viewed (or a new change arriving) in one tab could never show/hide it in the other. Presence in the DOM is what lets realtime toggle it. #} - {{ _('Mark all viewed') }} - {{ _('Recheck all') }} {% if active_tag_uuid %}{{ _("in '%(title)s'", title=active_tag.title) }}{% endif %} + {# Submit button rather than a link: these mutate state, so they must not be + reachable by GET (an would fire them). formaction/formmethod + retarget the enclosing #watch-list-form, whose csrf_token comes along - and + avoids a nested
, which is invalid HTML and silently dropped. #} + + RSS Feed diff --git a/changedetectionio/static/js/modal.js b/changedetectionio/static/js/modal.js index 272cd9e7..a13ed507 100644 --- a/changedetectionio/static/js/modal.js +++ b/changedetectionio/static/js/modal.js @@ -172,6 +172,44 @@ const ModalDialog = { // Make available globally window.ModalDialog = ModalDialog; +/** + * Submit `url` as a POST carrying the CSRF token, via a throwaway body-level form. + * + * Body-level (rather than wrapping the link) because these links often live inside another + * - the watch list and the edit page both do - and nested forms are invalid HTML that + * browsers silently drop. The token comes from the global `csrftoken` set in base.html. + * + * Used for state-changing actions that must not be reachable by GET, otherwise an + * on any page the operator visits performs the action for them. + */ +function postWithCsrf(url) { + const form = document.createElement('form'); + form.method = 'POST'; + form.action = url; + form.style.display = 'none'; + if (typeof csrftoken !== 'undefined' && csrftoken) { + const tok = document.createElement('input'); + tok.type = 'hidden'; + tok.name = 'csrf_token'; + tok.value = csrftoken; + form.appendChild(tok); + } + document.body.appendChild(form); + form.submit(); +} +window.postWithCsrf = postWithCsrf; + +/** + * Anchors marked data-method="POST" that do NOT also ask for confirmation. + * (The confirm variant is handled in the data-requires-confirm handler below.) + */ +$(document).ready(function() { + $(document).on('click', 'a[data-method="POST"]:not([data-requires-confirm])', function(e) { + e.preventDefault(); + postWithCsrf($(this).attr('href')); + }); +}); + /** * Auto-attach modal confirmations to links with data-requires-confirm attribute * Usage in HTML: @@ -203,19 +241,7 @@ $(document).ready(function() { // on a bare GET, since CSRF relies on GET firing. const method = ($element.attr('data-method') || 'GET').toUpperCase(); if (method === 'POST') { - const form = document.createElement('form'); - form.method = 'POST'; - form.action = url; - form.style.display = 'none'; - if (typeof csrftoken !== 'undefined' && csrftoken) { - const tok = document.createElement('input'); - tok.type = 'hidden'; - tok.name = 'csrf_token'; - tok.value = csrftoken; - form.appendChild(tok); - } - document.body.appendChild(form); - form.submit(); + postWithCsrf(url); return; } diff --git a/changedetectionio/static/styles/scss/parts/_button.scss b/changedetectionio/static/styles/scss/parts/_button.scss index e091f208..6257af2a 100644 --- a/changedetectionio/static/styles/scss/parts/_button.scss +++ b/changedetectionio/static/styles/scss/parts/_button.scss @@ -13,6 +13,10 @@ border: 1px solid var(--color-border-table-cell); background: var(--color-background); color: var(--color-text-input-description); + //