Get unstuck

Hint 1: Resolve, do not sanitize

Stripping ".." misses encodings, symlinks and absolute paths. Let os.path.realpath compute the real target, then check where it landed.

Hint 2: Compare against the real base

os.path.commonpath([base, target]) == base is true only when target is inside base. realpath both paths first so a symlink cannot hide the destination.

Reveal the reference patch and explanation
Fixeduploads.pyPython
import os


def read_upload(base_dir, name):
    base = os.path.realpath(base_dir)
    target = os.path.realpath(os.path.join(base, name))
    if base != target and os.path.commonpath([base, target]) != base:
        raise ValueError("path escapes the upload directory")
    with open(target, "r", encoding="utf-8") as handle:
        return handle.read()

notes.txt and sub/data.txt resolve to paths inside the upload directory and return their contents. "../secret.txt" resolves to the secret one level up, so commonpath is the parent, not base, and the function raises; an absolute path such as /etc/hostname does too, because os.path.join drops base when the second argument is absolute. The symlink inside the directory resolves through realpath to the secret outside, so it is refused even though its own name looks ordinary. A file named upload beside the uploads directory is refused too: its path is a prefix of the directory’s as text, but commonpath compares whole components. base_dir given with a trailing separator still reads notes.txt, because realpath normalizes it as well. A name for a file that does not exist raises as well, so nothing is read. Dotless, well-behaved names keep working, because the fix checks the resolved location rather than editing the name.

Compare it with your own fix, then rerun the tests. The editor is not changed when you open this explanation.

Inspect the actual test harness

The browser and the lab download run the same cases, including cases that catch common wrong fixes, such as comparing with one fixture value instead of the real rule. The download includes the complete test adapter and cryptographic fixtures.

Download the grader, python/cases.mjs
How the browser runs your Python

Your code runs in CPython 3.14.2 compiled to WebAssembly (Pyodide 314.0.7), in a Web Worker of its own. The test cases and the pass or fail verdict run in a second worker, which only sends your functions their inputs and reads back the values they return, so changing built-ins or the harness inside Python cannot change the result. All the cases together have a 8-second limit.

The first run downloads about 6.3 MB from this site, which your browser then keeps; later runs start in a second or two. Other pages never load it.

Available: the Python standard library, including sqlite3, ipaddress, pathlib, os.path and urllib.parse, and print(), whose output appears with your results.

Not available: network access, subprocesses and threads, and other packages from PyPI. The lab download runs the same cases with your local python3 (3.12 or newer).

Use the lesson in your own system

The lab uses a small in-memory filesystem the grader builds, and reads text files. A real file server also needs to avoid a time-of-check/time-of-use race between the check and the open (open the file, then verify with os.path.realpath of /proc/self/fd or O_NOFOLLOW), restrict by a filename allowlist where it can, set correct content types, and apply per-user authorization: traversal prevention is one layer of several. The archive-extraction lab applies the same idea to ZIP entries.

Your next task: Where does your code build a filesystem path from a request value, and does it check the resolved real path against the directory it must stay in?

OWASP: Path Traversal

Related guide: Design a safer file-upload pipeline