{"title":"Zip Slip via tarfile.extractall() on Untrusted Archives","language":"Python","severity":"Critical","cwe":"CWE-22","source_lines":[10],"flow_lines":[10,13,14,16],"sink_lines":[16],"vulnerable_code":"import tarfile\nimport os\nfrom flask import Flask, request, jsonify\n\napp = Flask(__name__)\n\n@app.route('/api/cloud/deploy-model', methods=['POST'])\ndef deploy_ml_model():\n    model_archive = request.files['model_package']\n    deployment_id = request.form.get('deployment_id', 'default')\n    extract_path = f'/opt/ml_models/{deployment_id}'\n    os.makedirs(extract_path, exist_ok=True)\n    archive_path = f'/tmp/{model_archive.filename}'\n    model_archive.save(archive_path)\n    with tarfile.open(archive_path, 'r:gz') as tar:\n        tar.extractall(path=extract_path)\n    return jsonify({'status': 'deployed', 'model_path': extract_path})","explanation":"The application accepts an untrusted tar.gz archive via file upload and extracts it using tarfile.extractall() without validating archive member paths. A malicious archive can contain entries with path traversal sequences (e.g., '../../etc/cron.d/malicious') that escape the intended extraction directory, allowing arbitrary file writes anywhere on the filesystem where the application has permissions.","remediation":"The fix validates every tar archive member before extraction by resolving its real path and ensuring it stays within the intended extraction directory. It also checks symbolic and hard links to prevent link-based escapes, and sanitizes the deployment_id and filename inputs to prevent path traversal through those parameters as well.","secure_code":"import tarfile\nimport os\nfrom flask import Flask, request, jsonify\n\napp = Flask(__name__)\n\ndef is_safe_tar_member(member, extract_path):\n    \"\"\"Validate that a tar member extracts within the intended directory.\"\"\"\n    member_path = os.path.realpath(os.path.join(extract_path, member.name))\n    extract_path_real = os.path.realpath(extract_path)\n    if not member_path.startswith(extract_path_real + os.sep) and member_path != extract_path_real:\n        return False\n    if member.issym() or member.islnk():\n        link_target = os.path.realpath(os.path.join(extract_path, member.linkname))\n        if not link_target.startswith(extract_path_real + os.sep):\n            return False\n    return True\n\n@app.route('/api/cloud/deploy-model', methods=['POST'])\ndef deploy_ml_model():\n    model_archive = request.files['model_package']\n    deployment_id = request.form.get('deployment_id', 'default')\n    safe_deployment_id = os.path.basename(deployment_id)\n    if not safe_deployment_id or safe_deployment_id in ('.', '..'):\n        return jsonify({'error': 'Invalid deployment_id'}), 400\n    extract_path = f'/opt/ml_models/{safe_deployment_id}'\n    os.makedirs(extract_path, exist_ok=True)\n    archive_path = f'/tmp/{os.path.basename(model_archive.filename)}'\n    model_archive.save(archive_path)\n    try:\n        with tarfile.open(archive_path, 'r:gz') as tar:\n            for member in tar.getmembers():\n                if not is_safe_tar_member(member, extract_path):\n                    os.remove(archive_path)\n                    return jsonify({'error': f'Unsafe path detected in archive: {member.name}'}), 400\n            tar.extractall(path=extract_path)\n    finally:\n        if os.path.exists(archive_path):\n            os.remove(archive_path)\n    return jsonify({'status': 'deployed', 'model_path': extract_path})"}