# Path Traversal via Unsanitized zipfile.extractall()

Language: Python
Severity: Critical
CWE: CWE-22

## Source
9

## Flow
9-11

## Sink
11

## Vulnerable Code
```python
import zipfile, os
from flask import Flask, request, jsonify
app = Flask(__name__)
FW_STAGING = '/var/iot/firmware/staging'
@app.route('/api/v1/device/firmware/push', methods=['POST'])
def push_fw_bundle():
    pkg = request.files.get('fw_pkg')
    if not pkg or not pkg.filename.endswith('.zip'):
        return jsonify({'err': 'invalid package'}), 400
    tmp_path = os.path.join('/tmp', pkg.filename)
    pkg.save(tmp_path)
    with zipfile.ZipFile(tmp_path, 'r') as zf:
        zf.extractall(FW_STAGING)
    return jsonify({'status': 'firmware staged', 'dest': FW_STAGING}), 200
```

## Explanation

The uploaded file `pkg` from the HTTP request is saved directly to disk and then extracted using `zf.extractall(FW_STAGING)` without validating individual ZIP member filenames. An attacker can craft a ZIP archive containing entries with path traversal sequences (e.g., `../../etc/cron.d/backdoor`), causing files to be written outside the intended `/var/iot/firmware/staging` directory — a classic 'Zip Slip' vulnerability.

## Remediation

The fix introduces a `_safe_extract` helper that resolves the real (canonical) path of every ZIP member and verifies it stays within the intended destination directory before any extraction occurs. If any entry would escape the staging directory (via `../` or absolute paths), a ValueError is raised and extraction is aborted. Additionally, the uploaded filename is sanitized with `os.path.basename()` to prevent path traversal when saving the temporary file, and the temp file is cleaned up in a `finally` block.

## Secure Code
```python
import zipfile, os
from flask import Flask, request, jsonify
app = Flask(__name__)
FW_STAGING = '/var/iot/firmware/staging'

def _safe_extract(zf, dest):
    """Extract ZIP members only if they resolve within the destination directory."""
    dest = os.path.realpath(dest)
    for member in zf.infolist():
        target_path = os.path.realpath(os.path.join(dest, member.filename))
        if not target_path.startswith(dest + os.sep) and target_path != dest:
            raise ValueError(
                f"Zip entry '{member.filename}' would escape the destination directory"
            )
    # All members validated; now extract
    for member in zf.infolist():
        zf.extract(member, dest)

@app.route('/api/v1/device/firmware/push', methods=['POST'])
def push_fw_bundle():
    pkg = request.files.get('fw_pkg')
    if not pkg or not pkg.filename.endswith('.zip'):
        return jsonify({'err': 'invalid package'}), 400
    # Sanitize the uploaded filename to prevent path traversal on save
    safe_name = os.path.basename(pkg.filename)
    if not safe_name:
        return jsonify({'err': 'invalid filename'}), 400
    tmp_path = os.path.join('/tmp', safe_name)
    pkg.save(tmp_path)
    try:
        with zipfile.ZipFile(tmp_path, 'r') as zf:
            _safe_extract(zf, FW_STAGING)
    except (ValueError, zipfile.BadZipFile) as exc:
        return jsonify({'err': str(exc)}), 400
    finally:
        os.remove(tmp_path)
    return jsonify({'status': 'firmware staged', 'dest': FW_STAGING}), 200
```
