Safe File Path Handling
Learn safe file path handling in Python to prevent directory traversal and path injection. Step-by-step tutorial from the Secure development track.
Focus: write safe file path handling
Picture this: your Python web app accepts a user-supplied filename, constructs a path like f"/uploads/{user_input}", and happily writes the file. Then a curious user sends ../../etc/cron.d/evil — and suddenly your app has written outside the intended directory, overwriting system files or planting malware. This is path traversal (or directory traversal), one of the OWASP Top 10 vulnerabilities. The pain is real: naive path handling turns a simple file upload feature into a critical security hole. In this lesson, you’ll master write safe file path handling — the practical techniques and best practices to keep your application’s filesystem access locked down.
The problem this lesson solves
File path handling in Python is deceptively tricky. When you accept a filename from user input, you’re accepting more than a name — you’re accepting a path that could point anywhere on the filesystem. Without safeguards, attackers can exploit this to:
- Read sensitive files — e.g.,
/etc/passwd, application secrets, or configuration files. - Write to unintended locations — overwriting critical files, planting web shells, or modifying app code.
- Cause denial of service — creating files in unexpected directories or exhausting disk space.
The root cause is insufficient validation of user-controlled paths. Many developers assume that prefixing a base directory and adding the user filename is enough. But ../ sequences and absolute paths break that assumption. This lesson shows you how to defend against such attacks without sacrificing usability.
Core concept / mental model
Think of your filesystem as a secure building with many rooms. The uploads directory is one room, and you want users to only place items in that room — never in the vault, the server room, or the ceiling crawlspace. The filesystem path is the corridor map; a malicious path is like a set of directions that includes “go up one floor and break through this wall” (the .. sequence).
Your job is to sanitize the directions before anyone takes them. In Python, the standard library provides tools like os.path and, more robustly, pathlib. The core idea is to canonicalize the path (resolve all .. and symlinks) and then verify that the resolved path still resides within the intended base directory.
Definitions you’ll need:
- Path traversal — An attack that uses
../sequences or absolute paths to access files outside the intended directory. - Canonicalization — Resolving a path to its absolute, normalized form, eliminating
.and..segments and resolving symlinks. - Base directory — The root folder you intend to allow access to (e.g.,
/var/www/uploads). - Sanitization — Cleaning user input by rejecting dangerous characters or patterns.
A simple but powerful mental model: Never trust raw user input as a path. Treat it as a name within a sandboxed space, and validate that the final resolved location stays inside the sandbox.
How it works step by step
Let’s break down the process of safely handling a user-supplied file path.
Step 1: Validate the input format
First, reject obviously dangerous inputs. Check for:
- Null bytes (
\x00) — often used to truncate strings in C-level functions. - Path separators like
/or\\(though sometimes you might allow subdirectories — but if you don’t need them, reject them). - Absolute paths (starting with
/or a Windows drive letter, e.g.,C:). - Backslash escapes that could be interpreted differently on various OS.
A whitelist of allowed characters (e.g., [A-Za-z0-9._-]) is often the safest approach.
Step 2: Construct the full path
Use os.path.join() or pathlib to combine the base directory and the sanitized filename. Avoid string concatenation with + or f"{base}/{user_input}", because that often introduces platform inconsistencies or lets .. slip through.
Step 3: Canonicalize and verify
After joining, get the absolute normalized path using os.path.realpath() (which resolves symlinks) or os.path.abspath() (which normalizes .. but doesn’t resolve symlinks). Then check that this resolved path starts with the resolved base directory. If it does, it’s safe; otherwise, reject the request.
Step 4: Use the file safely
Only after verification should you open the file for writing, reading, or deletion. Remember that race conditions can occur — the path might be swapped between checks — so consider using secure file operations like os.open() with O_NOFOLLOW on Linux to prevent symlink attacks.
Hands-on walkthrough
Let’s implement a safe file upload handler in Python using pathlib.
Example 1: Basic safe path validation
import pathlib
from pathlib import Path
def safe_upload_path(upload_dir: str, user_filename: str) -> Path:
# 1. Validate input format
if not user_filename or "\x00" in user_filename:
raise ValueError("Invalid filename")
# Reject path separators (we only allow flat filenames)
if "/" in user_filename or "\\" in user_filename:
raise ValueError("Subdirectories are not allowed")
if user_filename.startswith(("/", "\\", ":")) or ":" in user_filename[1:3]:
raise ValueError("Absolute paths are not allowed")
# 2. Construct the full path
upload_base = Path(upload_dir).resolve()
full_path = upload_base / user_filename
# 3. Canonicalize and verify
resolved_path = full_path.resolve()
if resolved_path != upload_base / user_filename:
# If canonicalization changed anything (e.g., resolved symlink), reject
raise ValueError("Path traversal detected")
if not resolved_path.parent == upload_base:
# Ensure the final parent is the base directory
raise ValueError("Path traversal detected")
return resolved_path
# Example usage
base = "/tmp/my_uploads"
try:
safe_path = safe_upload_path(base, "report.pdf")
print(f"Safe path: {safe_path}")
except ValueError as e:
print(f"Blocked: {e}")
# Try a traversal attempt
try:
safe_upload_path(base, "../../etc/passwd")
print("This should not happen!")
except ValueError as e:
print(f"Blocked: {e}")
Expected output:
Safe path: /tmp/my_uploads/report.pdf
Blocked: Path traversal detected
Example 2: Using os.path.realpath() for normalization
Sometimes you might want to allow subdirectories, but still prevent escaping the base. Here’s an alternative approach using os.path.
import os
def safe_join(base_dir: str, user_path: str) -> str:
# Reject null bytes and absolute paths
if user_path != os.path.normpath(user_path):
raise ValueError("Invalid characters in path")
full_path = os.path.join(base_dir, user_path)
# Get the canonical absolute path
real_base = os.path.realpath(base_dir)
real_path = os.path.realpath(full_path)
# Ensure the real path starts with the real base + os.sep or equals base
if not (real_path == real_base or real_path.startswith(real_base + os.sep)):
raise ValueError("Path traversal detected")
return real_path
# Test
base = "/var/data"
try:
p = safe_join(base, "images/logo.png")
print(p)
except ValueError as e:
print(f"Blocked: {e}
# This should be allowed (subdirectory)
print(safe_join(base, "docs/readme.txt")) # /var/data/docs/readme.txt
# This should be blocked
try:
safe_join(base, "../secret.txt")
except ValueError as e:
print(f"Blocked: {e}")
Expected output:
/var/data/docs/readme.txt
Blocked: Path traversal detected
Example 3: Secure file writing with os.open
For extra security, especially on Unix, you can use os.open with O_NOFOLLOW to prevent symlink attacks.
import os
def secure_write(base_dir: str, user_filename: str, content: bytes):
# Validate and canonicalize path as above (simplified)
safe_path = safe_join(base_dir, user_filename)
# Open with O_NOFOLLOW to fail if any symlink is encountered
fd = os.open(safe_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o644)
try:
with os.fdopen(fd, "wb") as f:
f.write(content)
except:
os.close(fd)
raise
# Usage
secure_write("/tmp/uploads", "notes.txt", b"Hello, secure world!")
Pro tip:
O_NOFOLLOWis a Linux-specific flag; on macOS it also exists, but on Windows you may need to use other protections. Always test on your target platform.
Compare options / when to choose what
There are several ways to handle file paths in Python. Here’s a comparison:
| Approach | Pros | Cons | When to use |
|---|---|---|---|
os.path.join + realpath |
Standard, works everywhere, explicit | Manual step of canonicalization | Simple scripts and APIs with strict validation |
pathlib.Path |
Intuitive, object-oriented, resolve() can be misleading if symlinks are allowed |
Slightly higher abstraction, but still needs verification | Modern Python codebases, easier to read |
os.open with O_NOFOLLOW |
Prevents symlink attacks at OS level | Platform-dependent, more low-level | High-security environments, writing files that could be symlinked |
| Whitelist regex validation only | Simple to implement | Not sufficient alone — os.path.join may still allow .. if not careful |
First line of defense, combine with canonicalization |
When to choose:
- Use
pathlibfor readability and most applications, but always pair it with aresolve()check. - If you require maximum security (e.g., handling untrusted uploads for a public web service), use
os.openwithO_NOFOLLOWand also verify withrealpath. - If your app runs on Windows, avoid
O_NOFOLLOW(not available) and rely onrealpathplusos.accesschecks.
Troubleshooting & edge cases
Here are common pitfalls and how to fix them.
1. Path.resolve() can mask differences
resolve() on a path that doesn’t exist might still normalize .. but not expand symlinks of the final component. Use strict=False (default) and compare the resolved path to the expected base.
# Wrong: this can allow /tmp/../etc
if not full_path.resolve().startswith(base.resolve()):
pass
# Right: after resolve, ensure it’s still within base
resolved = full_path.resolve()
if not (resolved == base_resolved or resolved.parent == base_resolved):
raise ValueError
2. Windows path confusion
Windows uses \ as separator and allows drive letters. Always normalize using os.path.normpath() and convert to forward slashes for comparison. Use os.path.commonpath() to check if the user path is within the base.
import os
base = r"C:\uploads\"
user_input = "..\..\windows\system32\drivers\etc\hosts"
full = os.path.normpath(os.path.join(base, user_input))
if not full.startswith(os.path.normpath(base)):
print("Blocked")
3. Symlink within base directory
A user might create a symlink inside uploads pointing to /etc. If you only check the parent path, you miss this. Always use realpath() to resolve symlinks.
import os
base = "/tmp/uploads"
os.symlink("/etc", "/tmp/uploads/link")
path = os.path.join(base, "link/passwd")
if not os.path.realpath(path).startswith(os.path.realpath(base)):
raise ValueError("Symlink escape")
4. Null byte injection
Despite Python 3 handling null bytes gracefully in strings, some C extensions or OS calls might truncate. Reject \x00 in any user input.
5. Race conditions (TOCTOU)
Between your validation and the actual write, the file could be replaced with a symlink. Mitigate by using O_NOFOLLOW and opening files immediately after checking, or use file descriptors exclusively.
What you learned & what's next
Congratulations! You now understand safe file path handling. Let’s recap:
- You learned how path traversal attacks work and why naive path concatenation is dangerous.
- You adopted the mental model of canonicalization + prefix check — never trust raw user input.
- You implemented step-by-step validation, path joining, and verification with both
pathlibandos.path. - You compared different approaches and know when to use
pathlib,os.path, oros.openwith secure flags. - You can troubleshoot edge cases like symlinks, Windows quirks, and null bytes.
This skill is a cornerstone of secure application development. Next in this track, you'll tackle Input validation in-depth, where you'll learn to handle not just paths but also other user inputs like emails, numbers, and JSON payloads — building on the same principle: validate, canonicalize, and never trust.
Practice recap
As a mini challenge, write a function download_and_save(url, base_dir) that downloads a file from a URL and saves it using safe path handling. Ensure it rejects any filename that attempts traversal, including URL-encoded versions. Test with a URL containing ?filename=../../etc/passwd and confirm it’s blocked. This will solidify your understanding of filtering untrusted input before touching the filesystem.
Common mistakes
- Using string concatenation like
f"{upload_dir}/{user_filename}"instead ofos.path.joinorPath— this allows../to escape the base directory. - Only checking for
../but missing URL-encoded variants like%2e%2e%2f— always canonicalize withrealpath()and compare. - Forgetting to resolve symlinks — a symlink inside the uploads directory can point outside and bypass parent checks.
- Not rejecting null bytes (
\x00) which can truncate strings in C-level functions. - Using
os.path.abspath()instead ofos.path.realpath()— abspath normalizes..but doesn’t resolve symlinks.
Variations
- Use
os.path.commonpath()to safely compare two paths and ensure the user path is within the base — a more concise alternative to manual prefix checks. - Leverage
pathlib.Pathwithis_relative_to()(Python 3.9+) for a clean, readable containment check. - For web frameworks, use a dedicated upload library like
python-multipartor Django’sUploadedFilewhich abstracts safe path handling.
Real-world use cases
- A file upload feature on a public web app that stores user avatars in a dedicated directory, validating filenames to prevent overwriting server files.
- A backup tool that accepts a user-specified output directory and ensures all writes stay within that directory, guarding against accidental overwrites.
- A CI/CD pipeline that extracts archives from third-party sources and safely writes them to a temp directory without risking path traversal to the runner.
Key takeaways
- Never trust user-supplied paths; treat them as filenames within a sandboxed base directory.
- Always canonicalize the full path with
realpath()and verify it still resides within the allowed base. - Reject absolute paths,
..sequences, null bytes, and unexpected separators early. - Use
pathlibfor readability but combine it with aresolve()check; for high security, useos.openwithO_NOFOLLOW. - Be aware of platform differences — Windows drives, backslashes, and symlink behaviors require extra care.
- Mitigate TOCTOU race conditions by opening files securely immediately after verification.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.