A Python download handler can normalize a path and still serve a file outside its intended directory. The mistake is treating a matching string prefix as proof that one filesystem path is inside another.
This is a synthetic illustrative walkthrough, not a vulnerability report about Flask or Starlette. No SecuardenAI scan or comparative SAST run has been performed. Assume a POSIX service serves files below /srv/reports, accepts a query parameter named name, and can also read a sibling directory /srv/reports-private.
Where the prefix check fails
from pathlib import Path
ROOT = Path("/srv/reports").resolve()
def download(name):
candidate = (ROOT / name).resolve(strict=True)
if not str(candidate).startswith(str(ROOT)):
raise PermissionError("outside download root")
return candidate.read_bytes()
The dataflow is decoded query parameter → path construction → filesystem resolution → prefix check → file read → response. Supplying ../reports-private/payroll.csv resolves to /srv/reports-private/payroll.csv. That string begins with /srv/reports, so the check permits it. If the file exists and the service account can read it, the response discloses data outside the intended boundary. Authentication alone does not repair this containment error.
The relevant distinction is documented by Python: os.path.commonprefix compares characters, while commonpath operates on path components. A string startswith check has the same conceptual weakness. Normalization does not turn a character comparison into a directory relationship.
Check the resolved path relationship
For an existing-file download under a server-controlled, stable directory tree, resolve both paths and check their component relationship:
def download(name):
supplied = Path(name)
if supplied.is_absolute():
raise PermissionError("relative paths only")
candidate = (ROOT / supplied).resolve(strict=True)
if not candidate.is_relative_to(ROOT) or not candidate.is_file():
raise PermissionError("not a downloadable file")
return candidate.read_bytes()
Path.resolve resolves symbolic links and eliminates parent-directory components. is_relative_to is a lexical relationship check, so resolving first matters here. The handler must also translate missing files, permission failures and invalid paths into controlled responses rather than leaking filesystem details. These snippets illustrate containment; they omit HTTP integration and per-file authorization.
Account for symlink races and authorization
The correction has an explicit limit: checking a path and later opening it are separate operations. If an attacker can replace directories or symlinks between those steps, a time-of-check/time-of-use race remains. Keep the served tree outside untrusted write control, or use platform-appropriate descriptor-based traversal and opening with a reviewed symlink policy. Do not present this snippet as a complete defense for adversary-writable storage.
Prefer maintained framework download helpers where their assumptions fit. Werkzeug documents send_from_directory for serving a client-selected path from a trusted directory. The directory itself must not be client-controlled. A helper still does not decide whether the requester may access a particular report.
Test the download boundary
A regression fixture should contain reports/public.csv and sibling reports-private/payroll.csv. Assert that the legitimate file succeeds and the sibling traversal fails without returning its contents.
Also cover:
- Absolute-path rejection.
- Missing-file handling.
- A symlink pointing outside the root.
- An unauthorized in-root file.
- Requests through the actual HTTP decoding layer.
These tests are proposed, not executed.
