Skip to content

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).name alone is not: on POSIX, PurePosixPath('..\\..\\etc\\passwd').name is 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('..').name is '..', 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 .name comparison is a backstop for what is left, and it is platform-scoped too: on Windows it rejects the drive-relative C:report.pdf, while on POSIX that name passes the comparison unchanged and is defused by the character allowlist instead, returning as C_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_filename is 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's safe_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-directory relative_to()/is_relative_to() validation afterward is still relying on an unvalidated final path.
  • Calling Path.resolve() without strict=True and without a following relative_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 of Path.is_relative_to() - a plain string-prefix comparison treats /app/uploads2 as "starting with" /app/uploads, incorrectly passing a sibling directory that was never meant to be in scope.

Additional Resources