CVE-2026-35490
Description
changedetection.io is a free open source web page change detection tool. Prior to 0.54.8, the @login_optionally_required decorator is placed before (outer to) @blueprint.route() instead of after it. In Flask, @route() must be the outermost decorator because it registers the function it receives. When the order is reversed, @route() registers the original undecorated function, and the auth wrapper is never in the call chain. This silently disables authentication on these routes. This vulnerability is fixed in 0.54.8.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
changedetection.ioPyPI | < 0.54.8 | 0.54.8 |
Affected products
1- cpe:2.3:a:webtechnologies:changedetection:*:*:*:*:*:*:*:*Range: <0.54.8
Patches
131a760c2147eCVE-2026-35490 - Authentication Bypass via Decorator Ordering
4 files changed · +94 −9
changedetectionio/blueprint/backups/__init__.py+4 −4 modified@@ -98,8 +98,8 @@ def construct_blueprint(datastore: ChangeDetectionStore): backups_blueprint.register_blueprint(construct_restore_blueprint(datastore)) backup_threads = [] - @login_optionally_required @backups_blueprint.route("/request-backup", methods=['GET']) + @login_optionally_required def request_backup(): if any(thread.is_alive() for thread in backup_threads): flash(gettext("A backup is already running, check back in a few minutes"), "error") @@ -141,8 +141,8 @@ def find_backups(): return backup_info - @login_optionally_required @backups_blueprint.route("/download/<string:filename>", methods=['GET']) + @login_optionally_required def download_backup(filename): import re filename = filename.strip() @@ -165,9 +165,9 @@ def download_backup(filename): logger.debug(f"Backup download request for '{full_path}'") return send_from_directory(os.path.abspath(datastore.datastore_path), filename, as_attachment=True) - @login_optionally_required @backups_blueprint.route("/", methods=['GET']) @backups_blueprint.route("/create", methods=['GET']) + @login_optionally_required def create(): backups = find_backups() output = render_template("backup_create.html", @@ -176,8 +176,8 @@ def create(): ) return output - @login_optionally_required @backups_blueprint.route("/remove-backups", methods=['GET']) + @login_optionally_required def remove_backups(): backup_filepath = os.path.join(datastore.datastore_path, BACKUP_FILENAME_FORMAT.format("*"))
changedetectionio/blueprint/backups/restore.py+2 −2 modified@@ -174,8 +174,8 @@ def construct_restore_blueprint(datastore): restore_blueprint = Blueprint('restore', __name__, template_folder="templates") restore_threads = [] - @login_optionally_required @restore_blueprint.route("/restore", methods=['GET']) + @login_optionally_required def restore(): form = RestoreForm() return render_template("backup_restore.html", @@ -184,8 +184,8 @@ def restore(): max_upload_mb=_MAX_UPLOAD_BYTES // (1024 * 1024), max_decompressed_mb=_MAX_DECOMPRESSED_BYTES // (1024 * 1024)) - @login_optionally_required @restore_blueprint.route("/restore/start", methods=['POST']) + @login_optionally_required def backups_restore_start(): if any(t.is_alive() for t in restore_threads): flash(gettext("A restore is already running, check back in a few minutes"), "error")
changedetectionio/blueprint/browser_steps/__init__.py+3 −3 modified@@ -268,8 +268,8 @@ async def start_browsersteps_session(watch_uuid): return browsersteps_start_session - @login_optionally_required @browser_steps_blueprint.route("/browsersteps_start_session", methods=['GET']) + @login_optionally_required def browsersteps_start_session(): # A new session was requested, return sessionID import uuid @@ -304,8 +304,8 @@ def browsersteps_start_session(): logger.debug("Starting connection with playwright - done") return {'browsersteps_session_id': browsersteps_session_id} - @login_optionally_required @browser_steps_blueprint.route("/browsersteps_image", methods=['GET']) + @login_optionally_required def browser_steps_fetch_screenshot_image(): from flask import ( make_response, @@ -330,8 +330,8 @@ def browser_steps_fetch_screenshot_image(): return make_response('Unable to fetch image, is the URL correct? does the watch exist? does the step_type-n.jpeg exist?', 401) # A request for an action was received - @login_optionally_required @browser_steps_blueprint.route("/browsersteps_update", methods=['POST']) + @login_optionally_required def browsersteps_ui_update(): import base64
changedetectionio/tests/unit/test_auth_decorator_order.py+85 −0 added@@ -0,0 +1,85 @@ +""" +Static analysis test: verify @login_optionally_required is always applied +AFTER (inner to) @blueprint.route(), not before it. + +In Flask, @route() must be the outermost decorator because it registers +whatever function it receives. If @login_optionally_required is placed +above @route(), the raw unprotected function gets registered and auth is +silently bypassed (GHSA-jmrh-xmgh-x9j4). + +Correct order (route outermost, auth inner): + @blueprint.route('/path') + @login_optionally_required + def view(): ... + +Wrong order (auth never called): + @login_optionally_required ← registered by route, then discarded + @blueprint.route('/path') + def view(): ... +""" + +import ast +import pathlib +import pytest + +REPO_ROOT = pathlib.Path(__file__).parents[3] # …/changedetection.io/ +SOURCE_ROOT = REPO_ROOT / "changedetectionio" + + +def _is_route_decorator(node: ast.expr) -> bool: + """Return True if the decorator looks like @something.route(...).""" + return ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "route" + ) + + +def _is_auth_decorator(node: ast.expr) -> bool: + """Return True if the decorator is @login_optionally_required.""" + return isinstance(node, ast.Name) and node.id == "login_optionally_required" + + +def collect_violations() -> list[str]: + violations = [] + + for path in SOURCE_ROOT.rglob("*.py"): + try: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + except SyntaxError: + continue + + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + + decorators = node.decorator_list + auth_indices = [i for i, d in enumerate(decorators) if _is_auth_decorator(d)] + route_indices = [i for i, d in enumerate(decorators) if _is_route_decorator(d)] + + # Bad order: auth decorator appears at a lower index (higher up) than a route decorator + for auth_idx in auth_indices: + for route_idx in route_indices: + if auth_idx < route_idx: + rel = path.relative_to(REPO_ROOT) + violations.append( + f"{rel}:{node.lineno} — `{node.name}`: " + f"@login_optionally_required (line {decorators[auth_idx].lineno}) " + f"is above @route (line {decorators[route_idx].lineno}); " + f"auth wrapper will never be called" + ) + + return violations + + +def test_auth_decorator_order(): + violations = collect_violations() + if violations: + msg = ( + "\n\nFound routes where @login_optionally_required is placed ABOVE @blueprint.route().\n" + "This silently disables authentication — @route() registers the raw function\n" + "and the auth wrapper is never called.\n\n" + "Fix: move @blueprint.route() to be the outermost (topmost) decorator.\n\n" + + "\n".join(f" • {v}" for v in violations) + ) + pytest.fail(msg)
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/dgtlmoon/changedetection.io/security/advisories/GHSA-jmrh-xmgh-x9j4nvdExploitMitigationVendor AdvisoryWEB
- github.com/advisories/GHSA-jmrh-xmgh-x9j4ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-35490ghsaADVISORY
- github.com/dgtlmoon/changedetection.io/commit/31a760c2147e3e73a403baf6d7de34dc50429c85ghsaWEB
- github.com/dgtlmoon/changedetection.io/releases/tag/0.54.8ghsaWEB
News mentions
0No linked articles in our index yet.