dgtlmoon/changedetection.io · error

abort(400) # Bad Request if the filename doesn't match the

Error message

abort(400)  # Bad Request if the filename doesn't match the pattern

What it means

abort(400) raised in the backups blueprint when the requested backup filename does not match the expected backup filename regex. It is a deliberate input-validation guard: only filenames produced by changedetection.io's own backup mechanism (matching backup_filename_regex) are accepted for download.

Source

Thrown at changedetectionio/blueprint/backups/__init__.py:159

        return backup_info

    @backups_blueprint.route("/download/<string:filename>", methods=['GET'])
    @login_optionally_required
    def download_backup(filename):
        import re
        filename = filename.strip()
        backup_filename_regex = BACKUP_FILENAME_FORMAT.format(r"\d+")

        # Resolve 'latest' before any validation so checks run against the real filename.
        if filename == 'latest':
            backups = find_backups()
            if not backups:
                abort(404)
            filename = backups[0]['filename']

        if not re.match(r"^" + backup_filename_regex + "$", filename):
            abort(400)  # Bad Request if the filename doesn't match the pattern

        full_path = os.path.join(os.path.abspath(datastore.datastore_path), filename)
        if not full_path.startswith(os.path.abspath(datastore.datastore_path) + os.sep):
            abort(404)

        logger.debug(f"Backup download request for '{full_path}'")
        return send_from_directory(os.path.abspath(datastore.datastore_path), filename, as_attachment=True)

    @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",
                                 available_backups=backups,
                                 backup_running=any(thread.is_alive() for thread in backup_threads)
                                 )
        return output

View on GitHub (pinned to 5d9c7c6da7)

Solutions

  1. Use only filenames returned by the backup listing endpoint (find_backups())
  2. Pass the bare filename, not a path (no directories, no leading '/')
  3. Check backup_filename_regex in changedetectionio/blueprint/backups/__init__.py and conform your filename to it
  4. If your own backup files use a different naming scheme, rename them to match the regex or omit the filename param to get the newest backup

Example fix

# before
requests.get(f'{base}/backup/download', params={'filename': '/opt/data/2024-01-01-backup.zip'})

# after
backups = requests.get(f'{base}/backup/list').json()
newest = backups[0]['filename']
requests.get(f'{base}/backup/download', params={'filename': newest})
Defensive patterns

Strategy: validation

Validate before calling

import re
from changedetectionio.blueprint.backups import backup_filename_regex  # or copy the pattern
def valid_backup_filename(name: str) -> bool:
    return bool(re.fullmatch(backup_filename_regex, name)) and '/' not in name and '..' not in name

Try / catch

try:
    r = requests.get(url, params={'filename': name})
    r.raise_for_status()
except requests.HTTPError as e:
    if e.response.status_code == 400:
        raise ValueError(f'bad backup filename: {name}') from e
    raise

Prevention

When it happens

Trigger: GET /backup/download?filename=<name> where filename contains characters or a shape not matching backup_filename_regex (path traversal attempts, wrong extension, hand-crafted names, URL-encoded slashes or '..' segments).

Common situations: Scripts or users tampering with the filename query parameter; passing a full absolute path instead of a bare filename; version changes that altered the backup naming scheme so old filenames no longer match the regex.

Related errors


AI-assisted analysis of dgtlmoon/changedetection.io@5d9c7c6da7 (2026-08-27). Data as JSON: /api/errors/7d986ea6130cc868. Report an issue: GitHub.