VYPR
Critical severity9.8NVD Advisory· Published Apr 7, 2026· Updated Apr 14, 2026

CVE-2026-35490

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.

PackageAffected versionsPatched versions
changedetection.ioPyPI
< 0.54.80.54.8

Affected products

1

Patches

1
31a760c2147e

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

News mentions

0

No linked articles in our index yet.