# Arbitrary File Write via tarfile Path Traversal

Language: Python
Severity: Critical
CWE: CWE-22

## Source
8

## Flow
8-13-18-19-20

## Sink
20

## Vulnerable Code
```python
import tarfile
import os
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route('/api/cloud/restore-backup', methods=['POST'])
def restore_cloud_backup():
    backup_file = request.files.get('backup_archive')
    tenant_id = request.form.get('tenant_id', 'default')
    restore_path = f'/var/cloud/tenants/{tenant_id}/data'
    
    if not backup_file:
        return jsonify({'error': 'No backup file provided'}), 400
    
    temp_archive = f'/tmp/restore_{tenant_id}.tar.gz'
    backup_file.save(temp_archive)
    
    os.makedirs(restore_path, exist_ok=True)
    
    with tarfile.open(temp_archive, 'r:gz') as tar:
        for member in tar.getmembers():
            tar.extract(member, path=restore_path)
    
    os.remove(temp_archive)
    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
```python
import tarfile
import os
from flask import Flask, request, jsonify

app = Flask(__name__)


def is_safe_path(member_path, base_path):
    """Validate that the resolved path of a tar member stays within the base directory."""
    abs_base = os.path.realpath(base_path)
    abs_member = os.path.realpath(os.path.join(base_path, member_path))
    return abs_member.startswith(abs_base + os.sep) or abs_member == abs_base


@app.route('/api/cloud/restore-backup', methods=['POST'])
def restore_cloud_backup():
    backup_file = request.files.get('backup_archive')
    tenant_id = request.form.get('tenant_id', 'default')

    if not tenant_id.isalnum() and not all(c.isalnum() or c in ('-', '_') for c in tenant_id):
        return jsonify({'error': 'Invalid tenant_id'}), 400

    restore_path = f'/var/cloud/tenants/{tenant_id}/data'

    if not backup_file:
        return jsonify({'error': 'No backup file provided'}), 400

    temp_archive = f'/tmp/restore_{tenant_id}.tar.gz'
    backup_file.save(temp_archive)

    os.makedirs(restore_path, exist_ok=True)

    try:
        with tarfile.open(temp_archive, 'r:gz') as tar:
            for member in tar.getmembers():
                if member.name.startswith('/') or member.name.startswith('..'):
                    raise ValueError(f"Illegal path in archive: {member.name}")

                if '..' in member.name.split('/') or '..' in member.name.split(os.sep):
                    raise ValueError(f"Path traversal detected in archive: {member.name}")

                if member.issym() or member.islnk():
                    link_target = member.linkname
                    if link_target.startswith('/') or '..' in link_target.split('/'):
                        raise ValueError(f"Unsafe link target in archive: {member.name} -> {member.linkname}")

                if not is_safe_path(member.name, restore_path):
                    raise ValueError(f"Path escapes restore directory: {member.name}")

                tar.extract(member, path=restore_path)
    except (ValueError, tarfile.TarError) as e:
        os.remove(temp_archive)
        return jsonify({'error': f'Invalid backup archive: {str(e)}'}), 400

    os.remove(temp_archive)
    return jsonify({'status': 'Backup restored successfully', 'path': restore_path}), 200
```
