From 76507014ce1ac6c468b60aab7fe0c30dd1c08b41 Mon Sep 17 00:00:00 2001 From: Chuck Date: Sat, 23 May 2026 16:44:33 -0400 Subject: [PATCH] Resolve CodeQL security findings in backup API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Path traversal (CWE-22): - backup_download: switch from send_file(user-tainted-path) to send_from_directory(_BACKUP_EXPORT_DIR, filename); Flask uses werkzeug safe_join internally which CodeQL recognises as a sanitizer - backup_delete: enumerate the export directory and match by name so entry.unlink() operates on a filesystem-derived Path rather than one constructed from user input; _safe_backup_path still guards first Information exposure through exceptions (CWE-209): - backup_validate: err_msg from validate_backup() can embed exception strings containing temp-file paths; log the detail, return a generic 'Invalid or corrupted backup file' to the client - Other backup endpoints: already fixed (str(e) -> generic message); CodeQL alerts will clear on next scan plugin_loader.py:185 (path traversal): false positive — requirements_file is constructed from plugin_dir returned by find_plugin_directory() (a filesystem scan), not from raw HTTP request input; no change needed. Co-Authored-By: Claude Sonnet 4.6 --- web_interface/blueprints/api_v3.py | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 7ad45ba8..8c2fad03 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -7154,7 +7154,8 @@ def backup_validate(): except OSError: pass if not ok: - return jsonify({'status': 'error', 'message': err_msg}), 400 + logger.warning("Backup validation failed: %s", err_msg) + return jsonify({'status': 'error', 'message': 'Invalid or corrupted backup file'}), 400 return jsonify({'status': 'success', 'data': manifest}) except Exception as e: logger.error("backup_validate failed: %s", e, exc_info=True) @@ -7224,22 +7225,30 @@ def backup_restore(): @api_v3.route('/backup/download/', methods=['GET']) def backup_download(filename): """Stream a backup ZIP to the browser.""" - from flask import send_file - path = _safe_backup_path(filename) - if path is None or not path.exists(): + from flask import send_from_directory + if _safe_backup_path(filename) is None: + return jsonify({'status': 'error', 'message': 'Backup not found'}), 404 + try: + # send_from_directory uses werkzeug safe_join internally — CodeQL-recognized sanitizer. + return send_from_directory(_BACKUP_EXPORT_DIR, filename, as_attachment=True) + except FileNotFoundError: return jsonify({'status': 'error', 'message': 'Backup not found'}), 404 - return send_file(path, as_attachment=True, download_name=path.name) @api_v3.route('/backup/', methods=['DELETE']) def backup_delete(filename): """Delete a stored backup ZIP.""" - path = _safe_backup_path(filename) - if path is None or not path.exists(): + safe = _safe_backup_path(filename) + if safe is None: return jsonify({'status': 'error', 'message': 'Backup not found'}), 404 + # Enumerate the export directory and match by name so the unlink target is + # a filesystem-derived path rather than one constructed from user input. try: - path.unlink() - return jsonify({'status': 'success'}) + for entry in _BACKUP_EXPORT_DIR.iterdir(): + if entry.is_file() and entry.name == safe.name: + entry.unlink() + return jsonify({'status': 'success'}) except OSError as e: logger.error("backup_delete failed: %s", e, exc_info=True) - return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500 \ No newline at end of file + return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'Backup not found'}), 404 \ No newline at end of file