Python
os.path.join throws away the base directory the moment the second argument is absolute. No ../ required, and no filter looking for one will see it.
os.path.join
import os
os.path.join('templates', 'home.html') # 'templates/home.html'
os.path.join('templates', '/etc/passwd') # '/etc/passwd' <- base discarded
# pathlib does the same thing:
from pathlib import Path
Path('templates') / '/etc/passwd' # PosixPath('/etc/passwd')
# So the bug needs no traversal sequence at all. A filter that rejects '..'
# sees nothing wrong with '/etc/passwd'.
@app.route('/view')
def view():
p = os.path.join(app.root_path, 'templates', request.args['page'])
return open(p).read()
# ?page=/etc/passwd -> read
# ?page=../../../etc/passwd -> also read, the ordinary way
# .NET's Path.Combine behaves identically. These two are the only runtimes
# on the cheatsheet that discard the base. See /guide/dotnet-inclusionThe fix
PAGES = {
'home': 'home.html',
'about': 'about.html',
'contact': 'contact.html',
}
@app.route('/view')
def view():
name = PAGES.get(request.args.get('page', 'home'))
if name is None:
abort(404)
return render_template(name)The execute sinks
# Template loaders. Jinja's FileSystemLoader rejects .. segments and
# absolute paths, so this is constrained — but the attacker still chooses
# which template renders, which can reach partials never meant to be
# rendered alone.
env.get_template(request.args['page'])
render_template(request.args['page'])
# Dynamic imports. This is genuine code execution: the module's top level
# runs on import.
importlib.import_module(request.args['mod'])
__import__(request.args['mod'])
# ?mod=os ... and then whatever the code does with the result
# The unambiguous ones.
exec(open(request.args['f']).read())
eval(open(request.args['f']).read())
# Pickle. Not inclusion, but it is what a traversal-plus-write chains into,
# and it is arbitrary code execution by design.
pickle.load(open(request.args['f'], 'rb'))
# yaml.load without a safe loader — same shape, same outcome.
yaml.load(open(request.args['f'])) # use yaml.safe_load
# Fix for all of them: a map from key to module/template, never a path.safe_join and its history
Werkzeug's safe_join — which is what send_from_directory uses — rejects absolute paths, rejects .. segments, and rejects Windows drive letters. It is the right primitive and you should prefer it to hand-rolled checks.
It is worth knowing that this area has a history of getting subtly wrong, which is an argument for using the library rather than reimplementing it:
- Werkzeug's
safe_joinwas hardened more than once, notably around Windows path handling where a UNC-style or drive-relative path could slip through a check written for POSIX. - Django's static-file serving has had traversal advisories, generally involving the interaction between URL decoding and path normalisation.
zipfile.extractall()did not confine entry names for years. Python 3.12 addedfilter='data'to reject traversal and symlink entries, and it becomes the default in 3.14. Before that,extractallon untrusted input is Zip Slip.
The practical rule: if you find yourself writing path-confinement logic in Python, check whether safe_join, send_from_directory, or the storage backend already does it. They usually do, and they have been reviewed more than your version will be.
What to grep for
# The footgun
grep -rnE 'os\.path\.join|Path\(' --include='*.py' . \
| grep -E 'request\.|args\[|form\[|GET\[|POST\[|params'
# Read sinks
grep -rnE '\bopen\(|read_text\(|read_bytes\(|send_file\(' --include='*.py' . \
| grep -E 'request\.|args|form|params'
# Execute sinks
grep -rnE 'importlib\.import_module|__import__|\bexec\(|\beval\(' --include='*.py' .
grep -rnE 'get_template\(|render_template\(' --include='*.py' . | grep -E 'request|args'
grep -rnE 'pickle\.load|yaml\.load\(' --include='*.py' .
# Archive extraction — check for the filter argument on 3.12+
grep -rn 'extractall' --include='*.py' .
# The correct calls, to confirm they are present
grep -rn 'safe_join\|send_from_directory\|is_relative_to\|commonpath' --include='*.py' .Related
Path.Combine discards everything before an absolute path. An application that carefully builds a safe base directory and then combines user input onto it has built nothing.
No stream-wrapper layer, so no php://filter equivalent — but path.join collapses .. before the filesystem sees it, which is where the escape actually happens.
Traversal works because of specific, boring rules about how a string becomes a file. Knowing them turns guessing at ../ counts into arithmetic.
An allowlist is the correct fix, so most of them are not really allowlists. Here is how to tell, and what a prefix does and does not stop.
One fix works and the rest are mitigations. Knowing which is which is the difference between a remediation that holds and a config change that gets reported again next year.