The moment a filename or path is derived from user input ā an upload's filename, a URL parameter, a form field ā file operations stop being purely mechanical and become a genuine security boundary. This lesson covers the specific vulnerabilities that arise there and how to close them.
1Path Traversal: The Same Attack, a Different Attack Surface
The path traversal vulnerability covered for ZIP archive extraction in the File Processing module applies identically, and just as seriously, to any file operation where a path or filename component derives from external, untrusted input ā a file-serving endpoint accepting a filename as a URL parameter or form field is exactly this scenario. serve_file("../../etc/passwd"), naively concatenated as f"/app/uploads/{filename}", doesn't stay confined to the intended /app/uploads/ directory at all ā the .. sequences walk back up the directory tree, and the resulting path can reference essentially any file the running process has permission to read, anywhere on the filesystem.
The fix follows the identical structural pattern the ZIP extraction lesson established: validate that the *final, actual* destination path is genuinely contained within the intended directory before performing the operation. (UPLOAD_DIR / filename).resolve() computes the absolute, fully-resolved path ā critically, resolve() collapses any .. sequences *and* follows symlinks to their genuine final target, revealing exactly where the path actually points, which a naive string check on the unresolved input could never reliably determine (a symlink inside the uploads directory could itself point somewhere else entirely, bypassing a check that only inspected the raw filename string).
requested.is_relative_to(UPLOAD_DIR), called *after* resolving, correctly verifies whether that final, real destination genuinely sits inside the intended directory ā rejecting the request if not, regardless of how many .. sequences or symlink indirections were used to try to escape it. This ordering ā resolve first, then check containment ā is the specific, correct sequence; checking containment on the unresolved path is a common, exploitable mistake.
# DANGEROUS: filename comes directly from the user, unvalidated
def serve_file(filename: str) -> bytes:
path = f"/app/uploads/{filename}"
with open(path, "rb") as f:
return f.read()
# A malicious filename like '../../etc/passwd' escapes the uploads directory entirelyVerifies the TRUE final destination, after resolving .. and symlinks
2Temporary Files: Avoiding a Genuine Race Condition
Constructing a 'temporary' filename by hand ā some combination of a fixed prefix and a random number or timestamp ā has a subtle but genuine security flaw: there's a gap in time between checking whether that guessed name is available and actually creating the file at that name, and during that gap, another process (potentially a malicious one, on a shared or multi-tenant system) could create its own file at that exact same guessed path first. This is a TOCTOU (time-of-check-to-time-of-use) race condition ā a well-documented class of vulnerability that has historically been exploited to, for instance, trick a privileged process into writing to (or reading from) a file an attacker planted at a predictable, guessed location.
tempfile.mkstemp(), from the standard library, closes this gap entirely by performing file creation *atomically* ā the operating system guarantees, as a single indivisible operation, that the returned file descriptor refers to a genuinely new, exclusively-created file with a name that's cryptographically unpredictable, not a name your code guessed and then hoped nobody else grabbed first. There's no window of vulnerability between checking and creating, because there's no separate checking step at all ā the OS-level primitive mkstemp() uses guarantees exclusivity as part of the creation itself.
os.fdopen(fd, "wb") wraps the returned raw file descriptor in an ordinary, familiar Python file object, so the rest of the code interacts with it exactly like any other opened file ā the security benefit is entirely in how the file was *created*, not in how it's subsequently read from or written to. This is a specific, concrete instance of a general security principle worth internalizing: prefer OS- or library-provided atomic primitives over hand-rolled 'check then act' sequences whenever a race condition between the check and the act could be exploited.
from pathlib import Path
UPLOAD_DIR = Path("/app/uploads").resolve()
def serve_file(filename: str) -> bytes:
requested = (UPLOAD_DIR / filename).resolve()
if not requested.is_relative_to(UPLOAD_DIR):
raise ValueError("Invalid file path")
return requested.read_bytes()Atomically unique ā no time-of-check-to-time-of-use gap to exploit
3File Permissions: Defaults Are Not Always Appropriate
Files created by a Python process inherit permissions based on the process's default umask, which is often more permissive than is actually appropriate for genuinely sensitive files ā a temporary file holding a decrypted secret, or an exported file containing personal user data, created with default, world-readable permissions on a shared or multi-tenant system is a real, if easy-to-overlook, exposure. os.chmod(path, 0o600) (owner read/write only, no access for group or others) explicitly tightens permissions on a file after creation whenever its contents are genuinely sensitive and shouldn't be readable by other users or processes on the same system.
tempfile.mkstemp() itself already creates files with conservative permissions by default (readable and writable only by the file's owner, on POSIX systems) ā another concrete reason it's preferable to a hand-rolled temporary file, beyond just the race-condition safety covered above; the security-conscious default is baked into the standard library function rather than something you need to remember to configure separately every time.
The general principle worth internalizing here, echoing the Zip Files lesson's approach to extraction safety: any time file operations touch data with real sensitivity (secrets, personal information, anything with genuine confidentiality requirements), explicitly consider ā rather than simply trusting language or OS defaults ā whether the resulting file's permissions, location, and lifetime (is it cleaned up promptly, or does it linger indefinitely) are actually appropriate for that data's sensitivity level.
import tempfile
import os
# DANGEROUS: a guessable name, and a gap between checking and creating it
# path = f"/tmp/upload_{random.randint(0, 9999)}.tmp"
# SAFE: atomically creates a guaranteed-unique file, no race condition possible
fd, path = tempfile.mkstemp(suffix=".tmp")
with os.fdopen(fd, "wb") as f:
f.write(data)Owner-only access for genuinely sensitive files ā never assume defaults are appropriate
4Step-by-Step Breakdown
A filename that looks harmless ā '../../etc/passwd' ā is exactly how a naive file-serving endpoint can be tricked into reading files it was never meant to expose.
A file-serving endpoint that trusts a user-provided filename directly is vulnerable to PATH TRAVERSAL -- the same class of attack covered for ZIP extraction earlier.
pathlib's resolve() plus an explicit containment check closes the path traversal hole -- verify the FINAL resolved path is still inside the intended directory.
Checkpoint: Why must the containment check (is_relative_to) happen AFTER calling .resolve(), not before?
- ā.resolve() collapses ".." sequences and symlinks into the actual final path -- checking BEFORE resolving could be bypassed by exactly those tricks
- āIt is only a minor performance optimization with no security implication
tempfile.mkstemp() (not a hand-built 'random' filename) creates a temp file SAFELY -- avoiding a race condition where two processes could collide on the same guessed name.
Checkpoint: What race condition does tempfile.mkstemp() avoid, compared to manually constructing a "random" filename?
- āA time-of-check-to-time-of-use gap where another process could create a file at the same guessed name between when you check it doesn't exist and when you create it
- āmkstemp() simply creates the file measurably faster
Safe file handling protects the filesystem; Secure Serialization is next, protecting what happens when you convert data to and from a serialized format.
Defend Against Real Path Traversal. Finish is_safe_path(): verify the final resolved path never escapes the intended directory.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Always resolve a path fully before checking whether it's contained within an intended directory
Checking containment on an unresolved path can be bypassed by '..' sequences or symlinks ā resolve() reveals the TRUE final destination, which is what actually needs to be verified as safely contained.
Use tempfile.mkstemp() (or equivalent) for temporary files, never a hand-constructed "random" filename
It provides both atomic, race-condition-free creation AND conservative, security-appropriate default permissions ā both properties a hand-rolled approach would need to separately, correctly implement.
Frequent Bugs
Concatenating a user-supplied filename directly into a file path without validating the resolved, final destination is genuinely contained within the intended directory, creating a path traversal vulnerability.
Always resolve the full path and explicitly verify containment (is_relative_to()) against the intended base directory before performing any file operation with a path derived from external input.
Real-World Examples
A Safe File-Download Endpoint
A web application lets authenticated users download files from their own designated storage directory by filename, and needs to prevent any request from escaping that directory via a maliciously crafted filename.
from pathlib import Path
def download_user_file(user_id: int, filename: str) -> bytes:
user_dir = (Path("/app/storage") / str(user_id)).resolve()
requested = (user_dir / filename).resolve()
if not requested.is_relative_to(user_dir):
raise PermissionError("Invalid file path")
return requested.read_bytes()