dgtlmoon/changedetection.io · error

abort(404)

Error message

abort(404)

What it means

abort(404) in the backup download endpoint guarding against path traversal: after joining the requested filename with the datastore path, the resulting full path must still start with the datastore directory plus a separator. If not, the resolved path escaped the datastore directory and the request is rejected as 404.

Source

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

    @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

    @backups_blueprint.route("/remove-backups", methods=['POST'])
    @login_optionally_required
    def remove_backups():

View on GitHub (pinned to 5d9c7c6da7)

Solutions

  1. Pass only a plain filename previously listed by the backup listing endpoint
  2. URL-decode and reject any filename containing '/', '\\' or '..' before sending the request
  3. Verify datastore.datastore_path is the same directory your backups live in

Example fix

# before
filename = '../../etc/passwd'
requests.get(f'{base}/backup/download', params={'filename': filename})

# after
from pathlib import PurePosixPath
assert '..' not in PurePosixPath(filename).parts and '/' not in filename
requests.get(f'{base}/backup/download', params={'filename': filename})
Defensive patterns

Strategy: validation

Validate before calling

from pathlib import PurePosixPath
def safe_backup_name(name: str) -> bool:
    p = PurePosixPath(name)
    return p.name == name and '..' not in p.parts and not name.startswith('/')

Try / catch

try:
    r = requests.get(url, params={'filename': name})
    if r.status_code == 404:
        raise PermissionError('filename escaped datastore path')
    r.raise_for_status()
except requests.RequestException as e:
    log.warning('backup download rejected: %s', e)

Prevention

When it happens

Trigger: filename values like '../secret.zip', '..%2f..%2fetc%2cpasswd', absolute paths, or symlinks that resolve outside datastore.datastore_path produce a full_path that fails the startswith(datastore_path + os.sep) check.

Common situations: Path traversal attempts (often automated scanners); passing an absolute path as filename; running the app on a case-sensitive filesystem where the datastore path casing differs between config and request.

Understand the failure class

Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.

Related errors


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