{"title":"Arbitrary File Write via tarfile Path Traversal","language":"Python","severity":"Critical","cwe":"CWE-22","source_lines":[8],"flow_lines":[8,13,18,19,20],"sink_lines":[20],"vulnerable_code":"import tarfile\nimport os\nfrom flask import Flask, request, jsonify\n\napp = Flask(__name__)\n\n@app.route('/api/cloud/restore-backup', methods=['POST'])\ndef restore_cloud_backup():\n    backup_file = request.files.get('backup_archive')\n    tenant_id = request.form.get('tenant_id', 'default')\n    restore_path = f'/var/cloud/tenants/{tenant_id}/data'\n    \n    if not backup_file:\n        return jsonify({'error': 'No backup file provided'}), 400\n    \n    temp_archive = f'/tmp/restore_{tenant_id}.tar.gz'\n    backup_file.save(temp_archive)\n    \n    os.makedirs(restore_path, exist_ok=True)\n    \n    with tarfile.open(temp_archive, 'r:gz') as tar:\n        for member in tar.getmembers():\n            tar.extract(member, path=restore_path)\n    \n    os.remove(temp_archive)\n    return jsonify({'status': 'Backup restored successfully', 'path': restore_path}), 200","explanation":"The code extracts tar archive members without validating their paths, allowing an attacker to craft malicious archive entries with path traversal sequences (e.g., '../../../etc/cron.d/malicious'). When tar.extract() is called, these malicious paths can write files outside the intended restore_path directory, enabling arbitrary file write anywhere on the filesystem.","remediation":"The fix validates each tar member's path before extraction by rejecting absolute paths, path traversal sequences ('..'), and unsafe symbolic links. It also verifies that the resolved extraction path stays within the intended restore directory using os.path.realpath(). Additionally, the tenant_id parameter is sanitized to prevent path traversal via that input vector.","secure_code":"import tarfile\nimport os\nfrom flask import Flask, request, jsonify\n\napp = Flask(__name__)\n\n\ndef is_safe_path(member_path, base_path):\n    \"\"\"Validate that the resolved path of a tar member stays within the base directory.\"\"\"\n    abs_base = os.path.realpath(base_path)\n    abs_member = os.path.realpath(os.path.join(base_path, member_path))\n    return abs_member.startswith(abs_base + os.sep) or abs_member == abs_base\n\n\n@app.route('/api/cloud/restore-backup', methods=['POST'])\ndef restore_cloud_backup():\n    backup_file = request.files.get('backup_archive')\n    tenant_id = request.form.get('tenant_id', 'default')\n\n    if not tenant_id.isalnum() and not all(c.isalnum() or c in ('-', '_') for c in tenant_id):\n        return jsonify({'error': 'Invalid tenant_id'}), 400\n\n    restore_path = f'/var/cloud/tenants/{tenant_id}/data'\n\n    if not backup_file:\n        return jsonify({'error': 'No backup file provided'}), 400\n\n    temp_archive = f'/tmp/restore_{tenant_id}.tar.gz'\n    backup_file.save(temp_archive)\n\n    os.makedirs(restore_path, exist_ok=True)\n\n    try:\n        with tarfile.open(temp_archive, 'r:gz') as tar:\n            for member in tar.getmembers():\n                if member.name.startswith('/') or member.name.startswith('..'):\n                    raise ValueError(f\"Illegal path in archive: {member.name}\")\n\n                if '..' in member.name.split('/') or '..' in member.name.split(os.sep):\n                    raise ValueError(f\"Path traversal detected in archive: {member.name}\")\n\n                if member.issym() or member.islnk():\n                    link_target = member.linkname\n                    if link_target.startswith('/') or '..' in link_target.split('/'):\n                        raise ValueError(f\"Unsafe link target in archive: {member.name} -> {member.linkname}\")\n\n                if not is_safe_path(member.name, restore_path):\n                    raise ValueError(f\"Path escapes restore directory: {member.name}\")\n\n                tar.extract(member, path=restore_path)\n    except (ValueError, tarfile.TarError) as e:\n        os.remove(temp_archive)\n        return jsonify({'error': f'Invalid backup archive: {str(e)}'}), 400\n\n    os.remove(temp_archive)\n    return jsonify({'status': 'Backup restored successfully', 'path': restore_path}), 200"}