# Insecure Deserialization via shelve.open() on Untrusted Data

Language: Python
Severity: Critical
CWE: CWE-502

## Source
7-8

## Flow
7-8-9-10

## Sink
10

## Vulnerable Code
```python
import shelve
import os
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route('/iot/device/restore', methods=['POST'])
def restore_device_config():
    device_id = request.form.get('device_id')
    backup_path = request.form.get('backup_file')
    full_path = f'/tmp/iot_backups/{backup_path}'
    with shelve.open(full_path) as db:
        config = db.get(device_id, {})
    return jsonify({'status': 'restored', 'device': device_id, 'config': config})
if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)
```

## Explanation

The application accepts user-controlled input for the backup file path and directly uses it with shelve.open(). Since shelve uses pickle for serialization, an attacker can craft a malicious shelve database containing pickled Python objects that execute arbitrary code upon deserialization when the file is opened.

## Remediation

The fix replaces shelve (which uses pickle internally) with JSON for safe deserialization, eliminating the insecure deserialization vulnerability. Additionally, strict input validation is applied to the backup filename using an allowlist regex and path traversal checks with realpath verification to prevent directory traversal attacks.

## Secure Code
```python
import json
import os
import re
from flask import Flask, request, jsonify

app = Flask(__name__)

BACKUP_DIR = '/tmp/iot_backups'


def is_safe_filename(filename):
    """Validate that the filename is safe and doesn't contain path traversal."""
    if not filename:
        return False
    # Only allow alphanumeric characters, hyphens, underscores, and dots
    if not re.match(r'^[a-zA-Z0-9_\-]+$', filename):
        return False
    # Reject path separators and traversal attempts
    if '..' in filename or '/' in filename or '\\' in filename:
        return False
    return True


@app.route('/iot/device/restore', methods=['POST'])
def restore_device_config():
    device_id = request.form.get('device_id')
    backup_name = request.form.get('backup_file')

    if not device_id or not backup_name:
        return jsonify({'status': 'error', 'message': 'Missing device_id or backup_file'}), 400

    # Validate the backup filename to prevent path traversal
    if not is_safe_filename(backup_name):
        return jsonify({'status': 'error', 'message': 'Invalid backup file name'}), 400

    # Construct path and verify it resolves within the allowed directory
    full_path = os.path.join(BACKUP_DIR, f'{backup_name}.json')
    real_path = os.path.realpath(full_path)
    if not real_path.startswith(os.path.realpath(BACKUP_DIR) + os.sep):
        return jsonify({'status': 'error', 'message': 'Invalid backup file path'}), 400

    if not os.path.exists(full_path):
        return jsonify({'status': 'error', 'message': 'Backup file not found'}), 404

    # Use JSON instead of shelve/pickle for safe deserialization
    try:
        with open(full_path, 'r') as f:
            backup_data = json.load(f)
    except (json.JSONDecodeError, IOError) as e:
        return jsonify({'status': 'error', 'message': 'Failed to read backup file'}), 500

    config = backup_data.get(device_id, {})
    return jsonify({'status': 'restored', 'device': device_id, 'config': config})


if __name__ == '__main__':
    os.makedirs(BACKUP_DIR, exist_ok=True)
    app.run(host='0.0.0.0', port=5000)
```
