CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') - Python
Overview
Path traversal in Python usually starts with a request value reaching open(), send_file(), shutil.copy() or an archive extractor after being joined to a base directory. The join is where most of the damage happens, because Python's two joining APIs both discard the base when the second segment is absolute: os.path.join('/srv/data', '/etc/passwd') returns /etc/passwd, and Path('/srv/data') / '/etc/passwd' returns the same. Neither raises.
The functions that look like sanitizers are not. os.path.normpath() collapses .. textually and never touches the filesystem, so it cannot tell a contained path from an escaped one; os.path.abspath() prefixes the working directory and resolves nothing else. Only Path.resolve() and os.path.realpath() consult the filesystem and follow symbolic links.
Primary Defence: Use indirect reference mapping (map IDs to filenames). Where the path must be derived from input, resolve the joined path with Path.resolve() and confirm containment with is_relative_to() (Python 3.9+), before opening anything. For archives, extract with tarfile's filter='data'. In web code, prefer the framework's own containment helper - flask.send_from_directory() or Django's FileSystemStorage - over rebuilding the check.
Common Vulnerable Patterns
Direct Path Concatenation
# VULNERABLE - user input becomes part of the path
@app.route('/download')
def download():
name = request.args['file']
with open(f'/srv/app/documents/{name}', 'rb') as fh:
return fh.read()
# Attack: ?file=../../../etc/passwd
# Result: reads /etc/passwd
Why this is vulnerable: An f-string builds a path out of whatever the request supplied. Nothing between the request and open() looks at the result, so any ../ the attacker includes is a directory the filesystem walks up.
os.path.join and the pathlib Slash Operator Discard the Base
import os
from pathlib import Path
# VULNERABLE - an absolute second segment replaces the first
full = os.path.join('/srv/app/documents', request.args['file'])
full = Path('/srv/app/documents') / request.args['file']
# Attack: ?file=/etc/passwd
# os.path.join('/srv/app/documents', '/etc/passwd') -> '/etc/passwd'
# Path('/srv/app/documents') / '/etc/passwd' -> PosixPath('/etc/passwd')
Why this is vulnerable: Both APIs treat an absolute segment as a fresh start rather than as something to append, which is documented behaviour and almost never what the caller wanted. The attack needs no .. at all, so a denylist looking for traversal sequences does not fire. The same rule is why a Windows drive letter escapes on that platform: os.path.join('data', 'C:\\Windows\\win.ini') keeps only the second argument.
normpath as a Sanitizer
import os
# VULNERABLE - normpath before the join does nothing
path = os.path.join(BASE, os.path.normpath(request.args['file']))
# VULNERABLE - normpath after the join hides the escape
path = os.path.normpath(os.path.join(BASE, request.args['file']))
if '..' in path: # never true after normpath
abort(400)
Why this is vulnerable: These two orderings fail in opposite directions, and neither is a containment check. normpath cannot remove a leading .. because there is nothing to its left to cancel, so sanitizing first leaves the traversal intact for the join to apply. Normalizing after the join does collapse the sequence - into a clean absolute path that points outside the base and no longer contains the substring anyone is grepping for:
BASE = '/srv/app/documents', user input = '../../etc/passwd'
join(BASE, normpath(user)) -> '/srv/app/documents/../../etc/passwd'
normpath(join(BASE, user)) -> '/etc/passwd'
The second result is what gets opened, and a '..' in path guard placed after it passes.
abspath Does Not Resolve Symbolic Links
import os
@app.route('/notes')
def notes():
# VULNERABLE - abspath does not follow links
path = os.path.abspath(os.path.join(BASE, request.args['file']))
if not path.startswith(BASE):
abort(403)
with open(path) as fh: # follows the link
return fh.read()
# If /srv/app/documents/notes is a symlink to /etc/shadow:
# Attack: ?file=notes
# path is '/srv/app/documents/notes' - the check passes
# open() resolves the link and reads /etc/shadow
Why this is vulnerable: os.path.abspath() prefixes the working directory and calls normpath(); it makes no filesystem calls, so a symbolic link inside the base directory is invisible to it. The containment check sees a path that is genuinely inside the base while open() follows the link out. os.path.realpath() and Path.resolve() are the calls that resolve links.
The startswith comparison is a second defect on the same line: /srv/app/documents-archive begins with /srv/app/documents as text, so a sibling directory passes a check meant to keep the reader inside one folder.
Archive Extraction Without a Filter
import tarfile
# VULNERABLE - member names are attacker-controlled paths
with tarfile.open(uploaded) as tar:
tar.extractall('/srv/app/uploads')
# Attack: an archive containing a member named ../../evil.txt
# Result on Python 3.13: the member is written two directories above
# /srv/app/uploads, and extractall() emits only a DeprecationWarning
Why this is vulnerable: A tar member name is a path chosen by whoever built the archive, and until the extraction filters arrived extractall() applied it as given - the Zip Slip class of bug, tracked for Python as CVE-2007-4559. Python 3.12 and 3.13 warn about it and still extract; Python 3.14 makes filter='data' the default. Code that must run on 3.12 or 3.13 has to pass the filter explicitly.
zipfile is not affected. ZipFile.extractall() sanitizes member names, so an entry named ../../evil.txt lands in the destination as evil.txt. The exposure with zip archives is code that reads namelist() and joins the names itself.
Secure Patterns
Canonical Path Validation for Reads
from pathlib import Path
BASE_DIR = Path('/srv/app/documents').resolve(strict=True)
def open_document(user_path: str) -> Path:
"""SECURE - resolve first, then check containment, then open."""
candidate = (BASE_DIR / user_path).resolve(strict=True)
if not candidate.is_relative_to(BASE_DIR): # Python 3.9+
raise PermissionError('outside the document root')
if not candidate.is_file():
raise FileNotFoundError(user_path)
return candidate
Why this works: resolve() is the only step that consults the filesystem. It collapses . and .., makes the path absolute, and follows every symbolic link, so what is_relative_to() compares is the path open() will actually use rather than the string the request carried. is_relative_to() compares path components, so /srv/app/documents-archive is not treated as being inside /srv/app/documents the way a string prefix would be. The absolute-input case is covered without a separate branch: BASE_DIR / '/etc/passwd' discards the base, resolves to /etc/passwd, and fails containment.
strict=True requires the target to exist, which suits a download and raises FileNotFoundError rather than silently proceeding. The is_file() check keeps the caller from opening a directory, a FIFO or a device node.
Containment for a Write Destination
from pathlib import Path
import os
UPLOAD_DIR = Path('/srv/app/uploads')
def create_upload(filename: str) -> int:
"""SECURE - the destination does not exist yet, so resolve its parent."""
if filename in {'', '.', '..'} or '/' in filename or '\\' in filename:
raise ValueError('filename must be a single path component')
parent = UPLOAD_DIR.resolve(strict=True) # the directory does exist
dest = parent / filename
if dest.parent != parent: # nothing reintroduced a separator
raise PermissionError('outside the upload directory')
# O_EXCL fails rather than overwriting an existing file
return os.open(dest, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
Why this works: A write destination does not exist when it is checked, and resolve(strict=True) raises FileNotFoundError on a path that is not there - so the read pattern above cannot be reused unchanged. Resolving the parent instead gets the same guarantee where it matters: symbolic links in the directory chain are followed, and the check runs against the real directory. Comparing dest.parent against the resolved parent confirms nothing in the name reintroduced a separator.
It rejects rather than strips, and that is deliberate. The common idiom is Path(filename).name, which reduces ../../evil.txt to evil.txt - safe, because the result lands in the upload directory either way, but silent. The caller is not told that the name it stored is not the name it was given, the audit log records a routine upload, and two hostile inputs that differ only in their directory part now collide on one filename. Rejecting makes the attempt visible.
Both separators are checked explicitly because Path only knows the platform's own. On Linux, Path('..\\..\\evil.txt').name returns the whole string unchanged - a backslash is an ordinary filename character there - so a Windows-shaped payload would pass a .name check and create a file with backslashes in its name.
O_EXCL closes the follow-on problem: without it, a name that collides with an existing file silently replaces it, and a name pointing at a symlink an attacker planted would be written through.
Safe Archive Extraction
import tarfile
def extract_upload(archive_path: str, dest: str) -> None:
"""SECURE - the data filter refuses members that escape the destination."""
with tarfile.open(archive_path) as tar:
tar.extractall(dest, filter='data') # Python 3.12+
Why this works: The filter resolves each member against the destination and raises a tarfile.FilterError subclass rather than writing the file when the result would land outside it. For a member named ../../evil.txt that is OutsideDestinationError, naming both the member and the path it would have written. Links are judged by where they point rather than by how they are spelled: an absolute linkname raises AbsoluteLinkError, and one resolving outside the destination raises LinkOutsideDestinationError. Anything that is not a regular file, directory, symlink or hard link - a device node, a FIFO - raises SpecialFileError. It is a property of the extractor rather than a check the caller has to remember, which is why it beats inspecting getnames() first: inspection leaves a window between the check and the extraction.
What it does quietly rather than loudly is worth knowing. The filter is not a .. denylist and does not reject a name for containing one - sub/../ok.txt resolves inside the destination and extracts as ok.txt. A leading / is stripped rather than refused, and AbsolutePathError is raised only where the name is still absolute after stripping, which on Windows means a form like C:/foo. Permissions are rewritten rather than rejected: the mode is masked with 0o755, so setuid, setgid, sticky and group- or other-write bits are cleared silently, and the ownership fields are dropped. Code that needs an archive refused rather than sanitized has to inspect the members itself.
filter='data' is available from Python 3.12 and is the default from 3.14. On 3.11 and earlier, filter members individually against a resolved destination before extracting.
Framework-Specific Guidance
Flask and Werkzeug
from flask import Flask, send_from_directory, abort
from werkzeug.exceptions import NotFound
from werkzeug.utils import safe_join
app = Flask(__name__)
DOCUMENT_ROOT = '/srv/app/documents'
@app.route('/documents/<path:filename>')
def documents(filename):
"""SECURE - send_from_directory applies safe_join internally."""
try:
return send_from_directory(DOCUMENT_ROOT, filename)
except NotFound:
abort(404)
# safe_join is the same check, available on its own:
# safe_join('/srv/app/documents', 'reports/q3.pdf') -> '/srv/app/documents/reports/q3.pdf'
# safe_join('/srv/app/documents', '../../etc/passwd') -> None
# safe_join('/srv/app/documents', '/etc/passwd') -> None
Why this works: werkzeug.utils.safe_join() normalizes the untrusted segment with posixpath.normpath(), rejects it if it is absolute or still starts with .. afterwards, and returns None rather than a path - so there is no value left to use by accident. send_from_directory() calls it and turns a rejection into a 404, which also avoids telling the caller whether the file they aimed at exists.
It treats the untrusted part as a URL path, so / is the only separator it is guaranteed to understand. Backslash is refused only where the platform treats it as a separator - the check is against os.sep and os.altsep. On Windows safe_join('/srv/app/documents', '..\\..\\etc\\passwd') returns None; on Linux the same call returns a path, because there a backslash is an ordinary filename character and nothing has escaped. Do not treat a result observed on one platform as the general rule: if backslash should be refused everywhere, that is a check of your own before the call.
Django
Django has the equivalent in FileSystemStorage, and it raises rather than returning a sentinel:
from django.core.files.storage import FileSystemStorage
storage = FileSystemStorage(location='/srv/app/media')
storage.path('reports/q3.pdf') # '/srv/app/media/reports/q3.pdf'
storage.path('../../etc/passwd') # raises SuspiciousFileOperation
storage.path('/etc/passwd') # raises SuspiciousFileOperation
Why this works: FileSystemStorage holds the base directory as configuration rather than taking it per call, so every path it produces is checked against the same root. The check itself is a startswith() against the base with a separator appended rather than the component comparison is_relative_to() performs, which is enough to keep a sibling such as /srv/app/media-archive out. SuspiciousFileOperation subclasses SuspiciousOperation, which Django's exception handler turns into a 400 and logs under django.security, so an unhandled one is a refusal rather than a 500 - and it is visible to whoever watches that logger. With DEBUG = True the same path renders a technical error page carrying the resolved path, which is one more reason that setting does not belong in production.
Considerations
Whether the finding is material depends on what the base directory contains. A traversal that can only reach other files the same user is already entitled to download is a weaker finding than one that reaches /etc, application configuration, or another tenant's uploads. That does not make it a false positive - the reachable set changes as the deployment does - but it is the difference between an urgent fix and a scheduled one. Recording a finding as a false positive is legitimate where the "user input" is not user input: a value read from a database column the application itself wrote, with no request data in its history, is a different question from a request parameter.
Reads and writes need different code, and the difference is strict=. resolve(strict=True) raises FileNotFoundError on a path that does not exist, which is correct for a download and wrong for an upload destination. resolve() without strict resolves as much of the path as exists and appends the rest, so it will not raise - but it also cannot follow a symbolic link that does not exist yet, which means the containment check covers the directory chain and not the leaf. Resolving the parent, as the write pattern above does, is what makes that explicit rather than accidental.
Containment is not authorization. Confirming a path is inside /srv/app/uploads says nothing about whether this user may read that file. Where uploads belong to accounts, the ownership check is a separate lookup and belongs before the file is opened, not after. See CWE-73 for the broader case where the weakness is file selection rather than escaping the directory.
A resolved path can stop being correct between the check and the open. Every pattern here validates a path and then uses it, which leaves a window an attacker with write access to the base directory can exploit by replacing a component with a symlink. On Linux, passing os.O_NOFOLLOW to os.open() narrows it to the final component, and holding a directory file descriptor and using the dir_fd argument closes it properly. That is worth the complexity only where untrusted local processes share the directory.
Testing
Re-running the scanner proves the pattern is gone, not that containment holds - and the containment fix is the one that breaks legitimate downloads. Assert both directions:
open_document('reports/q3.pdf')returns a path underBASE_DIRand reads the file. A fix that rejects every subdirectory passes a traversal test and fails users.open_document('../../etc/passwd')raisesPermissionError, and so doesopen_document('/etc/passwd'). The absolute form takes a different route through the code - it discards the base at the join rather than walking up - so it is a separate case, not a variant.- A path resolving to a sibling of the base, such as
../documents-archive/notes.txtwhere that directory exists, raises. This is the case astr().startswith()check gets wrong andis_relative_to()gets right, so it is the assertion that distinguishes them. create_upload('report.pdf')succeeds once and raisesFileExistsErrorthe second time. WithoutO_EXCLthe second call silently overwrites, and no traversal payload reveals that.create_upload('../../evil.txt')raisesValueErrorrather than creatingevil.txt. Assert the exception, not just the absence of a file above the directory - a helper that strips the traversal instead of rejecting it also leaves nothing above the directory, and the two are only distinguishable here. Test'..\\..\\evil.txt'separately: on Linux that is one filename rather than a path, so it exercises a different branch.- Extracting an archive whose member is named
../../evil.txtraisestarfile.OutsideDestinationError, and no file appears above the destination. Assert the second half too: an extractor that reports an error after writing part of the archive has still written it.
Common Pitfalls
- Checking the input and opening something else. Validating
request.args['file']and then rebuilding the path from a different variable, or joining it a second time, means the value that was checked is not the value that is opened. Resolve once, into a variable, and pass that variable toopen(). str(path).startswith(str(base))instead ofPath.is_relative_to(). A string prefix has no notion of a path component, so/srv/app/documents-archivematches a base of/srv/app/documents. Appending a separator to the base fixes the immediate case;is_relative_to()is what the comparison is actually for.- Treating
secure_filename()orPath(name).nameas a traversal fix. Both reduce input to a single filename, which contains the traversal but says nothing about which directory the result ends up in, whether it overwrites an existing file, or whether this user may write there at all. They are one step in the write pattern above, not a substitute for it. - Trusting
normpath()orabspath()because the output looks canonical. Neither makes a filesystem call, so neither can see a symbolic link. The output being absolute and free of..is exactly what makes the result convincing and exactly why the escape is no longer visible in it.
Additional Resources
- CWE-22: Path Traversal
- Python pathlib -
Path.resolve(),Path.is_relative_to() - Python os.path -
realpath(),normpath(),abspath(),join() - Python tarfile extraction filters - the
datafilter and its error types - Werkzeug safe_join
- Flask send_from_directory
- Django FileSystemStorage
- OWASP Path Traversal