# Path Traversal via pathlib.Path.resolve() Symlink Bypass

Language: Python
Severity: High
CWE: CWE-22

## Source
10

## Flow
10-11-12-15

## Sink
15

## Vulnerable Code
```python
from pathlib import Path
from flask import Flask, send_file, request

app = Flask(__name__)
MODEL_STORAGE = "/var/ml/trained_models"

@app.route('/api/v2/model/export')
def export_trained_model():
    model_name = request.args.get('model_id', 'default.h5')
    model_path = Path(MODEL_STORAGE) / model_name
    resolved = model_path.resolve()
    if not str(resolved).startswith(MODEL_STORAGE):
        return {"error": "Invalid model path"}, 403
    return send_file(resolved, as_attachment=True)
```

## Explanation

The vulnerability occurs because user-controlled input from request.args.get('model_id') flows directly into Path construction and file operations. Although resolve() is used for validation, an attacker can create a symlink within the MODEL_STORAGE directory pointing to sensitive files outside it. The validation checks if the resolved path starts with MODEL_STORAGE, but this can be bypassed if a symlink is placed inside MODEL_STORAGE that points elsewhere, allowing arbitrary file read via send_file().

## Remediation

The fix applies defense-in-depth: it validates the model_id against a strict whitelist regex that only allows safe filename characters and known model extensions, explicitly rejects symlinks before resolution to prevent symlink-based bypasses, and resolves the MODEL_STORAGE base path itself before comparison to ensure consistent prefix checking with a trailing separator.

## Secure Code
```python
from pathlib import Path
import os
import re
from flask import Flask, send_file, request

app = Flask(__name__)
MODEL_STORAGE = "/var/ml/trained_models"

# Whitelist pattern for model IDs: alphanumeric, hyphens, underscores, dots, no path separators
MODEL_ID_PATTERN = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9._-]*\.(?:h5|pt|pkl|onnx|safetensors)$')

@app.route('/api/v2/model/export')
def export_trained_model():
    model_name = request.args.get('model_id', 'default.h5')
    
    # Validate model_id against whitelist pattern (no path separators, no dots sequences)
    if not MODEL_ID_PATTERN.match(model_name):
        return {"error": "Invalid model ID format"}, 400
    
    # Reject any path separator characters explicitly
    if os.sep in model_name or '/' in model_name or '\\' in model_name:
        return {"error": "Invalid model ID"}, 400
    
    # Reject '..' sequences
    if '..' in model_name:
        return {"error": "Invalid model ID"}, 400
    
    model_path = Path(MODEL_STORAGE) / model_name
    
    # Check that the file is not a symlink (prevent symlink-based bypass)
    if model_path.is_symlink():
        return {"error": "Invalid model path: symlinks not allowed"}, 403
    
    # Resolve and verify the path is within MODEL_STORAGE
    resolved = model_path.resolve()
    storage_resolved = Path(MODEL_STORAGE).resolve()
    
    if not str(resolved).startswith(str(storage_resolved) + os.sep) and resolved != storage_resolved:
        return {"error": "Invalid model path"}, 403
    
    # Verify file exists and is a regular file
    if not resolved.is_file():
        return {"error": "Model not found"}, 404
    
    return send_file(resolved, as_attachment=True)
```
