CWE-73: External Control of File Name or Path - Python
Overview
External control of file names or paths happens when user-supplied input is used to build a filesystem path without validation. Python's open(), os.path and pathlib will build and open whatever path they are handed; none of them enforces a base directory, so containment is the application's job.
Primary Defence: Use Path.resolve(strict=True) with relative_to() validation for existing files, so canonicalized paths with symlinks resolved stay within the intended base directory. Allowlists for known file sets, UUID-based indirect reference maps for sensitive files, and Flask's send_from_directory() for serving reduce path traversal, absolute path injection, and symlink risks. For uploads, store under a server-generated name and reject any client-supplied name containing / or \ outright - do not rely on Path(filename).name to strip components, because it parses by the rules of the host platform and leaves a backslash path intact on POSIX. werkzeug.utils.secure_filename() and Django's get_valid_filename() are display hygiene for a name you keep as metadata, not the containment boundary.
Common Vulnerable Patterns
Direct Use of User Input in open()
# VULNERABLE - No validation of user-supplied filename
from flask import request
@app.route('/download')
def download_file():
filename = request.args.get('file')
with open(filename, 'rb') as f:
return f.read()
# Attack example:
# GET /download?file=../../../../etc/passwd
# Result: Reads /etc/passwd from the server
Why this is vulnerable: The parameter is the entire path, so there is no intended directory to escape from and no traversal sequence is needed - ?file=/etc/passwd is enough. Flask's request.args returns the decoded value and applies no validation; it is a parsing step, not a check. The handler returns the bytes to the caller, which turns the route into a general read primitive scoped only by the process user.
Validating Before the Final Decode
# VULNERABLE - the check runs on a value that is decoded again afterwards
from urllib.parse import unquote
@app.route('/read')
def read_file():
# Werkzeug has already decoded the query value once
filename = request.args['filename']
if '..' in filename:
abort(400, 'Invalid filename')
# A second decode restores the sequence the check rejected
filename = unquote(filename)
with open(f'/app/data/{filename}') as f:
return f.read()
# Attack example:
# GET /read?filename=%252e%252e%252f%252e%252e%252fetc%252fpasswd
# request.args yields "%2e%2e%2f%2e%2e%2fetc%2fpasswd" - no literal ".."
# unquote yields "../../etc/passwd"
Why this is vulnerable: Werkzeug decodes query values, so a singly-encoded ..%2F..%2F arrives as ../../ and this check would catch it. The denylist fails when something decodes a second time after validation, and it never sees the payloads that carry no .. at all - an absolute path, or a symbolic link inside the data directory. Validate the path the filesystem will use, with Path.resolve() and an is_relative_to() containment check.
Using os.path.join with Absolute Paths
# VULNERABLE - os.path.join allows absolute paths
import os
@app.route('/file')
def get_file():
filename = request.args['name']
# os.path.join ignores first arg if second is absolute
full_path = os.path.join('/app/data/', filename)
with open(full_path) as f:
return f.read()
# Attack example:
# GET /file?name=/etc/passwd
# Result: os.path.join('/app/data/', '/etc/passwd') = '/etc/passwd'
Why this is vulnerable: os.path.join() is documented to discard everything before an absolute segment, so it is behaving correctly - the error is expecting a join to act as a boundary. pathlib does the same: Path('/app/data') / '/etc/passwd' is PosixPath('/etc/passwd').
The source matters for whether an absolute path can reach the join. A query parameter, a form field or a JSON body value arrives verbatim, so a leading / survives. A route variable does not: Werkzeug's path: converter is [^/].*?, which cannot match a leading slash, and Map merges consecutive slashes in the literal parts of a rule, so GET /file//etc/passwd against /file/<path:filename> returns a 308 to /file/etc/passwd rather than reaching the view. Traversal through a route variable is a separate question and does reach it - path: matches ../../etc/passwd and hands it over intact.
Sanitizing the Filename but Nothing Else
# VULNERABLE - traversal is contained, but nothing else about the write is
from werkzeug.utils import secure_filename
@app.route('/upload', methods=['POST'])
def upload_file():
file = request.files['file']
# Strips directory components, and that is the only control applied
filename = secure_filename(file.filename)
file.save(os.path.join('/uploads', filename))
return 'Uploaded'
# secure_filename('../../etc/passwd') returns 'etc_passwd', so traversal is
# contained. What is not:
# - Two users uploading 'report.pdf' overwrite each other silently
# - No extension or content-type allowlist, so '.py' and '.html' are accepted
# - No authorization check on who may write into /uploads
# - secure_filename('../') returns '', and file.save() then targets the
# directory itself and raises IsADirectoryError
Why this is vulnerable: This is the shape most likely to be closed as fixed while the interesting problems remain, because the traversal rule stops firing and everything left over belongs to a different CWE. file.save() opens the destination with mode 'wb', which truncates, so a name matching an existing file replaces it silently - and secure_filename() maps many distinct inputs onto the same output, which makes those collisions easy to arrange rather than accidental.
String Concatenation for Paths
# VULNERABLE - String concat allows injection
@app.route('/delete')
def delete_file():
filename = request.args['file']
filepath = '/app/temp/' + filename
os.remove(filepath)
return 'Deleted'
# Attack example:
# GET /delete?file=../../app.py
# Result: Deletes /app/app.py
Why this is vulnerable: String concatenation performs no path handling at all, so the traversal survives into os.remove() intact. Deletion routes deserve their own scrutiny: nothing is returned to the attacker, so the usual detection signal - unexpected data leaving the host - never appears, and the outcome cannot be undone by fixing the code afterwards.
Secure Patterns
Allowlist with pathlib
from pathlib import Path
class SecureFileService:
def __init__(self, base_dir="/app/data"):
self.base_dir = Path(base_dir).resolve(strict=True)
self.allowed_files = {"report.pdf", "summary.txt", "data.csv"}
def read_file(self, filename: str) -> bytes:
if filename not in self.allowed_files:
raise PermissionError("File not allowed")
candidate = (self.base_dir / filename)
# Resolve symlinks and require existence
real = candidate.resolve(strict=True)
# Prevent symlink escapes (Python 3.9+)
if not real.is_relative_to(self.base_dir):
raise PermissionError("Invalid file location")
if not real.is_file():
raise FileNotFoundError("Not a file")
return real.read_bytes()
Why this works:
- Only exact allowlisted basenames are permitted (no user-controlled paths).
- The file is resolved to its real path and verified to remain under the trusted base directory.
- Only regular files are read.
Path Resolution with Ancestor Validation
from pathlib import Path
class SecurePathValidator:
def __init__(self, base_directory: str):
self.base_dir = Path(base_directory).resolve(strict=True)
def validate_existing_path(self, user_path: str) -> Path:
if not user_path:
raise ValueError("Missing path")
candidate = (self.base_dir / user_path)
# Require existence so symlink resolution is meaningful
real = candidate.resolve(strict=True)
# Ensure the real target is within the base directory
real.relative_to(self.base_dir)
if not real.is_file():
raise FileNotFoundError("Not a file")
return real
# Usage:
validator = SecurePathValidator('/app/data')
safe_path = validator.validate_existing_path(user_input)
content = safe_path.read_text()
Why this works:
- The base directory is resolved to a real, canonical path before any validation.
- User input is resolved to a real filesystem path, collapsing
./..components and resolving symlinks (for existing paths). relative_to()enforces that the resolved target is a descendant of the trusted base directory, and it runs on the resolved filesystem path rather than on the raw user input.
UUID-Based Indirect References
# SECURE - Maps tokens to actual file paths
import uuid
from pathlib import Path
from typing import Dict, Optional
class SecureFileRegistry:
def __init__(self, base_directory: str):
self.base_dir = Path(base_directory).resolve(strict=True)
self.registry: Dict[uuid.UUID, Path] = {}
def register_file(self, internal_path: str) -> uuid.UUID:
"""Register a file and return access token."""
file_path = (self.base_dir / internal_path).resolve(strict=True)
# Validate file is within base directory
try:
file_path.relative_to(self.base_dir)
except ValueError:
raise ValueError(f'Invalid file path: {internal_path}')
if not file_path.is_file():
raise FileNotFoundError(f'File not found: {internal_path}')
# Generate unique token
token = uuid.uuid4()
self.registry[token] = file_path
return token
def get_file(self, token: uuid.UUID) -> bytes:
file_path = self.registry.get(token)
if not file_path:
raise FileNotFoundError("Invalid file token")
real = file_path.resolve(strict=True)
if not real.is_relative_to(self.base_dir):
raise PermissionError("Invalid file location")
if not real.is_file():
raise FileNotFoundError("Not a file")
return real.read_bytes()
# Usage:
registry = SecureFileRegistry('/app/data')
token = registry.register_file('reports/2024/q1.pdf')
# Return token to user, they can only access via this token
content = registry.get_file(token)
Why this works:
- Users interact only with opaque tokens, not filesystem paths.
- Tokens map to server-validated, canonical file paths under a trusted base directory.
- Path traversal is prevented by enforcing containment during registration.
- Because access uses server-controlled mappings, user input cannot influence path resolution at read time.
- Filesystem permissions and token scoping still apply: an unguessable token reduces risk but is not access control.
Filename Sanitization
# Filename sanitization is an input cleanup step, not a security boundary.
import re
from pathlib import Path
from typing import Set
class SecureFilenameHandler:
ALLOWED_EXTENSIONS: Set[str] = {'.pdf', '.txt', '.csv', '.xlsx'}
@staticmethod
def sanitize_filename(filename: str) -> str:
"""
Sanitize filename to prevent path traversal.
Raises:
ValueError: If filename is invalid or has forbidden extension
"""
if not filename or not filename.strip():
raise ValueError('Filename cannot be empty')
# Reject path syntax outright rather than stripping it. Path().name
# parses by the rules of the running platform, so on POSIX it treats
# a backslash as an ordinary character and returns the whole string.
if '/' in filename or '\\' in filename:
raise ValueError('Filename must not contain path separators')
if filename in ('.', '..'):
raise ValueError('Filename must not be a directory reference')
if Path(filename).name != filename:
raise ValueError('Filename must not contain path components')
clean_name = filename
# Remove dangerous characters
clean_name = re.sub(r'[^a-zA-Z0-9._-]', '_', clean_name)
# Validate extension
extension = Path(clean_name).suffix.lower()
if extension not in SecureFilenameHandler.ALLOWED_EXTENSIONS:
raise ValueError(f'File type not allowed: {extension}')
return clean_name
# Usage:
safe_filename = SecureFilenameHandler.sanitize_filename(user_input)
file_path = Path('/uploads') / safe_filename
Why this works:
- Rejecting
/and\explicitly is what makes this portable.Path(filename).namealone is not: on POSIX,PurePosixPath('..\\..\\etc\\passwd').nameis the entire string, because backslash is not a separator there. A name that looks harmless to a Linux service can still be a path to a Windows client, an SMB share, or a downstream archive extractor. - The explicit
./..rejection is needed on its own:Path('..').nameis'..', so the comparison below does not catch it. Without that line the extension allowlist happens to reject..as having no valid suffix, which is an accident rather than a control. The.namecomparison is a backstop for what is left, and it is platform-scoped too: on Windows it rejects the drive-relativeC:report.pdf, while on POSIX that name passes the comparison unchanged and is defused by the character allowlist instead, returning asC_report.pdf. - Character allowlisting reduces problematic characters for filesystem use and logging.
- Extension allowlisting enforces a restricted set of accepted file types.
- This provides filename hygiene only; real-path containment and safe file handling must still be enforced when accessing the filesystem. For uploads, prefer not to reuse the client's name at all: store under a server-generated identifier and keep the original only as display metadata, which is what
werkzeug.utils.secure_filenameis for when you do need a human-readable form.
Framework-Specific Guidance
Django - Secure File Handling
# SECURE - Django file upload and serving
from django.core.files.storage import FileSystemStorage
from django.core.exceptions import SuspiciousFileOperation
from django.utils.text import get_valid_filename
from pathlib import PurePosixPath
class SecureFileStorage(FileSystemStorage):
def generate_filename(self, filename):
# Django expects POSIX-style paths for storage names (even on Windows)
p = PurePosixPath(filename)
if p.is_absolute() or ".." in p.parts:
raise SuspiciousFileOperation("Invalid upload path")
# Clean only the final component; keep upload_to subdirs
safe_name = get_valid_filename(p.name)
return str(p.with_name(safe_name))
# In models.py:
from django.db import models
class Document(models.Model):
file = models.FileField(
upload_to='documents/%Y/%m/',
storage=SecureFileStorage()
)
uploaded_at = models.DateTimeField(auto_now_add=True)
# Secure file serving view:
from django.http import FileResponse, Http404
from django.views import View
from pathlib import Path
class SecureFileDownloadView(View):
def get(self, request, file_id):
try:
doc = Document.objects.get(id=file_id)
except Document.DoesNotExist:
raise Http404("File not found")
# TODO: enforce authorization (owner/role/tenant checks) here.
fh = doc.file.open("rb")
return FileResponse(
fh,
as_attachment=True,
filename=Path(doc.file.name).name, # storage name, not local path
)
# settings.py:
MEDIA_ROOT = '/var/app/media/'
MEDIA_URL = '/media/'
Why this works:
- Upload names are validated during Django's filename generation, rejecting absolute paths and
..segments. - Filenames are cleaned using Django utilities, reducing unsafe characters in stored names.
- Files are accessed through Django's storage API (
doc.file.open()), avoiding direct filesystem path handling in views. - Downloads should enforce authorization on the model object to prevent IDOR (streaming alone is not access control).
Flask - Secure File Operations
import uuid
from pathlib import Path
from flask import Flask, request, abort, send_from_directory
from werkzeug.utils import secure_filename
app = Flask(__name__)
UPLOAD_FOLDER = Path("/var/app/uploads").resolve()
ALLOWED_EXTENSIONS = {"pdf", "txt", "csv", "xlsx"}
def allowed_file(filename: str) -> bool:
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
def save_upload(file) -> str:
if not file or not file.filename:
raise ValueError("No file provided")
original = secure_filename(file.filename)
if not original or not allowed_file(original):
raise ValueError("Invalid file type")
ext = original.rsplit(".", 1)[1].lower()
stored = f"{uuid.uuid4().hex}.{ext}" # server-controlled name
UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True)
dest = UPLOAD_FOLDER / stored
# Avoid overwrites; fail if exists
if dest.exists():
raise ValueError("Upload collision")
file.save(str(dest))
return stored
@app.route("/upload", methods=["POST"])
def upload_file():
f = request.files.get("file")
if not f:
abort(400, "No file provided")
try:
stored_name = save_upload(f)
return {"file": stored_name}, 200
except ValueError as e:
abort(400, str(e))
@app.route("/download/<path:filename>")
def download_file(filename):
# TODO: enforce authorization here (token/user/tenant checks)
return send_from_directory(
UPLOAD_FOLDER,
filename,
as_attachment=True,
download_name=filename, # Flask>=2.0; optional
)
Why this works:
- Uploads are stored under a fixed, server-controlled directory with server-generated filenames (no user-controlled paths).
- Filenames are sanitized and restricted to an extension allowlist (policy enforcement).
- Downloads use
send_from_directory(), which applies Werkzeug'ssafe_join()to prevent path traversal outside the upload directory. - Authorization should be enforced on download requests; safe path handling alone does not prevent IDOR.
FastAPI - Async Secure File Handling
import re
import uuid
from pathlib import Path
from fastapi import FastAPI, UploadFile, HTTPException, File
from fastapi.responses import FileResponse
app = FastAPI()
class FastAPISecureFileHandler:
UPLOAD_DIR = Path("/var/app/uploads").resolve()
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
# Keep both; content_type is advisory, extension is policy
ALLOWED = {
"application/pdf": ".pdf",
"text/plain": ".txt",
"text/csv": ".csv",
}
@classmethod
async def save_upload(cls, file: UploadFile) -> str:
ext = cls.ALLOWED.get(file.content_type)
if not ext:
raise HTTPException(400, f"File type not allowed: {file.content_type}")
cls.UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
safe_filename = f"{uuid.uuid4().hex}{ext}"
file_path = cls.UPLOAD_DIR / safe_filename
try:
size = 0
with file_path.open("wb") as f:
while True:
chunk = await file.read(8192)
if not chunk:
break
size += len(chunk)
if size > cls.MAX_FILE_SIZE:
raise HTTPException(413, "File too large")
f.write(chunk)
except Exception:
if file_path.exists():
file_path.unlink()
raise
finally:
await file.close()
return safe_filename
@app.post("/upload")
async def upload_file(file: UploadFile = File(...)):
return {"filename": await FastAPISecureFileHandler.save_upload(file)}
@app.get("/download/{filename}")
async def download_file(filename: str):
# UUID hex + approved extension
if not re.fullmatch(r"[0-9a-f]{32}\.(pdf|txt|csv)", filename):
raise HTTPException(400, "Invalid filename")
file_path = FastAPISecureFileHandler.UPLOAD_DIR / filename
if not file_path.is_file():
raise HTTPException(404, "File not found")
# TODO: enforce authorization (user/tenant ownership) here.
return FileResponse(path=file_path, filename=filename, media_type="application/octet-stream")
Why this works:
- Files are stored under a fixed server-controlled directory using server-generated UUID filenames (no user-controlled paths).
- Uploads are constrained by an allowlist (size limit plus approved types/extensions), reducing abuse and DoS risk.
- Downloads only serve filenames matching a strict UUID+extension pattern, preventing path traversal via crafted names.
- Partial files are removed on error, avoiding accumulation of incomplete uploads.
- Authorization should still be enforced on downloads; unguessable names reduce risk but are not access control.
Common Pitfalls
- Treating
werkzeug.utils.secure_filename()as a complete traversal fix - it sanitizes a single filename's characters (stripping separators and unsafe symbols), but it is filename hygiene, not a containment check; code that skips the base-directoryrelative_to()/is_relative_to()validation afterward is still relying on an unvalidated final path. - Calling
Path.resolve()withoutstrict=Trueand without a followingrelative_to(base_dir)check -resolve()normalizes./..syntactically, but skipping the containment call afterward means a normalized path that points outside the base directory is never actually rejected. - Comparing paths with
str(path).startswith(str(base_dir))instead ofPath.is_relative_to()- a plain string-prefix comparison treats/app/uploads2as "starting with"/app/uploads, incorrectly passing a sibling directory that was never meant to be in scope.