{"title":"Zip Bomb via Recursive Archive Expansion","language":"Python","severity":"High","cwe":"CWE-409","source_lines":[10],"flow_lines":[10,11,12,13],"sink_lines":[13],"vulnerable_code":"import zipfile\nimport os\nfrom flask import Flask, request, jsonify\n\napp = Flask(__name__)\n\n@app.route('/api/cloud/extract-backup', methods=['POST'])\ndef restore_cloud_backup():\n    backup_file = request.files['backup']\n    restore_path = f\"/tmp/restore_{request.form.get('tenant_id')}\"\n    os.makedirs(restore_path, exist_ok=True)\n    backup_file.save(f\"{restore_path}/backup.zip\")\n    with zipfile.ZipFile(f\"{restore_path}/backup.zip\", 'r') as archive:\n        archive.extractall(restore_path)\n    return jsonify({\"status\": \"restored\", \"path\": restore_path})","explanation":"The application accepts an uploaded ZIP file from an untrusted source without validating its compressed/uncompressed size ratio or recursion depth. The extractall() method decompresses the entire archive without limits, allowing a zip bomb (highly compressed malicious archive) to exhaust disk space and system resources.","remediation":"The fix adds comprehensive validation before extraction: it checks the total uncompressed size, individual file sizes, compression ratio (to detect zip bombs), file count limits, and rejects nested archives. Additionally, it includes path traversal protection during extraction and sanitizes the tenant_id input to prevent directory traversal attacks.","secure_code":"import zipfile\nimport os\nfrom flask import Flask, request, jsonify\n\napp = Flask(__name__)\n\nMAX_TOTAL_EXTRACTED_SIZE = 500 * 1024 * 1024  # 500 MB max total extracted size\nMAX_SINGLE_FILE_SIZE = 100 * 1024 * 1024  # 100 MB max per file\nMAX_FILE_COUNT = 10000  # Maximum number of files in archive\nMAX_COMPRESSION_RATIO = 100  # Maximum allowed compression ratio\nMAX_NESTING_DEPTH = 0  # Do not allow nested archives\n\nNESTED_ARCHIVE_EXTENSIONS = ('.zip', '.tar', '.gz', '.bz2', '.xz', '.7z', '.rar')\n\n\ndef is_nested_archive(filename):\n    \"\"\"Check if a file appears to be a nested archive.\"\"\"\n    return filename.lower().endswith(NESTED_ARCHIVE_EXTENSIONS)\n\n\ndef validate_zip_archive(zip_path):\n    \"\"\"Validate a ZIP archive for zip bomb characteristics before extraction.\"\"\"\n    compressed_size = os.path.getsize(zip_path)\n\n    with zipfile.ZipFile(zip_path, 'r') as archive:\n        members = archive.infolist()\n\n        # Check file count\n        if len(members) > MAX_FILE_COUNT:\n            raise ValueError(f\"Archive contains too many files: {len(members)} (max: {MAX_FILE_COUNT})\")\n\n        total_uncompressed = 0\n        for member in members:\n            # Check for nested archives\n            if is_nested_archive(member.filename):\n                raise ValueError(f\"Nested archive detected: {member.filename}. Nested archives are not allowed.\")\n\n            # Check individual file size\n            if member.file_size > MAX_SINGLE_FILE_SIZE:\n                raise ValueError(f\"File '{member.filename}' exceeds max size: {member.file_size} bytes (max: {MAX_SINGLE_FILE_SIZE})\")\n\n            total_uncompressed += member.file_size\n\n        # Check total uncompressed size\n        if total_uncompressed > MAX_TOTAL_EXTRACTED_SIZE:\n            raise ValueError(f\"Total extracted size too large: {total_uncompressed} bytes (max: {MAX_TOTAL_EXTRACTED_SIZE})\")\n\n        # Check compression ratio\n        if compressed_size > 0:\n            ratio = total_uncompressed / compressed_size\n            if ratio > MAX_COMPRESSION_RATIO:\n                raise ValueError(f\"Suspicious compression ratio: {ratio:.1f}x (max: {MAX_COMPRESSION_RATIO}x)\")\n\n    return True\n\n\ndef safe_extract(zip_path, extract_path):\n    \"\"\"Safely extract a ZIP archive with path traversal protection.\"\"\"\n    with zipfile.ZipFile(zip_path, 'r') as archive:\n        for member in archive.infolist():\n            # Prevent path traversal (zip slip)\n            member_path = os.path.realpath(os.path.join(extract_path, member.filename))\n            if not member_path.startswith(os.path.realpath(extract_path) + os.sep) and member_path != os.path.realpath(extract_path):\n                raise ValueError(f\"Path traversal detected in archive member: {member.filename}\")\n\n            archive.extract(member, extract_path)\n\n\n@app.route('/api/cloud/extract-backup', methods=['POST'])\ndef restore_cloud_backup():\n    if 'backup' not in request.files:\n        return jsonify({\"error\": \"No backup file provided\"}), 400\n\n    tenant_id = request.form.get('tenant_id')\n    if not tenant_id or not tenant_id.isalnum():\n        return jsonify({\"error\": \"Invalid tenant_id\"}), 400\n\n    backup_file = request.files['backup']\n    restore_path = f\"/tmp/restore_{tenant_id}\"\n    os.makedirs(restore_path, exist_ok=True)\n    zip_file_path = f\"{restore_path}/backup.zip\"\n    backup_file.save(zip_file_path)\n\n    try:\n        # Validate the archive is a proper ZIP file\n        if not zipfile.is_zipfile(zip_file_path):\n            os.remove(zip_file_path)\n            return jsonify({\"error\": \"Invalid ZIP file\"}), 400\n\n        # Validate archive for zip bomb characteristics\n        validate_zip_archive(zip_file_path)\n\n        # Safely extract with path traversal protection\n        safe_extract(zip_file_path, restore_path)\n\n    except ValueError as e:\n        # Clean up on validation failure\n        os.remove(zip_file_path)\n        return jsonify({\"error\": str(e)}), 400\n    except zipfile.BadZipFile:\n        os.remove(zip_file_path)\n        return jsonify({\"error\": \"Corrupted ZIP file\"}), 400\n    finally:\n        # Remove the uploaded zip after extraction attempt\n        if os.path.exists(zip_file_path):\n            os.remove(zip_file_path)\n\n    return jsonify({\"status\": \"restored\", \"path\": restore_path})"}