šŸš€ LEVEL UP TO SENIOR:Unlock 500+ Advanced Practical Challenges & Exercises.
šŸŽ“ COURSERA PARTNER:Earn professional Google, Meta, and IBM certificates to supercharge your resume.
HTML MASTER CLASS /// LEARN TAGS /// BUILD STRUCTURE /// SEMANTIC WEB /// HTML MASTER CLASS /// LEARN TAGS ///

Secure File Handling in Python

Path traversal, unsafe temp files, and permission handling — the file-operation vulnerabilities that arise the moment a filename or path comes from outside your own code.

⚔ Total XP: 0|šŸ’» python XP: 0

Skill Matrix

UNLOCK NODES BY LEARNING NEW TAGS.

System Hub

Core logic.

Quick Quiz //

Why must the containment check (is_relative_to) happen AFTER calling .resolve(), not before?


šŸš€ LEVEL UP TO SENIOR:Unlock 500+ Advanced Practical Challenges & Exercises.
šŸŽ“ COURSERA PARTNER:Earn professional Google, Meta, and IBM certificates to supercharge your resume.

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 entirely
localhost:3000
Resolve-Then-Verify
requested.resolve().is_relative_to(UPLOAD_DIR)
Verifies 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()
localhost:3000
Atomic, Race-Free Creation
tempfile.mkstemp()
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)
localhost:3000
Explicit Permission Control
os.chmod(path, 0o600)
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

ChromeSupported

Fully supported (via server-side Python execution).

FirefoxSupported

Fully supported (via server-side Python execution).

SafariSupported

Fully supported (via server-side Python execution).

EdgeSupported

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

THE BUG

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.

THE FIX

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()

Interview Prep

?Frequently Asked Questions

Pascual Vila

Pascual Vila

Frontend Instructor // Code Syllabus

Common Pitfalls & Errors

The Error //

Validating a user-supplied filename by checking the raw, unresolved string for '..' substrings, which can be bypassed by symlinks or more elaborate path constructions that don't literally contain '..' but still resolve outside the intended directory.

# Wrong: string-based check, bypassable by symlinks or clever path construction if ".." in filename: raise ValueError("Invalid filename") path = UPLOAD_DIR / filename # Correct: resolve fully, then verify true containment requested = (UPLOAD_DIR / filename).resolve() if not requested.is_relative_to(UPLOAD_DIR.resolve()): raise ValueError("Invalid filename")

The Solution //

Always resolve the full path first, then explicitly verify the resolved result is contained within the intended base directory using is_relative_to(), rather than pattern-matching on the raw input string.

Lesson Glossary

[01]Path traversal

An attack using ".." sequences or symlinks in a file path to access files outside an intended directory.

Code Preview
// Path traversal context

[02]is_relative_to()

A pathlib Path method verifying whether a resolved path is genuinely contained within another path, used for safe path validation.

Code Preview
// is_relative_to() context

[03]TOCTOU race condition

A vulnerability arising from a time gap between checking a condition and acting on it, exploitable if another process can intervene in that gap.

Code Preview
// TOCTOU race condition context

[04]tempfile.mkstemp()

A standard library function creating a temporary file atomically, with a unique name and conservative default permissions.

Code Preview
// tempfile.mkstemp() context

Continue Learning