Skip to content
highCVSS 7.5CWE-22A01:2021 – Broken Access Control

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

Pythonthe single most important fact on this pageVulnerable
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-inclusion

The fix

PythonSecure
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

Pythonthese run code, not just read itVulnerable
# 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_join was 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 added filter='data' to reject traversal and symlink entries, and it becomes the default in 3.14. Before that, extractall on 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

Bash
# 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' .