
path traversal hands over every file on the server
a download link. a filename in the url. and suddenly the entire server's file system is up for grabs. that's path traversal, and it's one of those bugs that's embarrassingly simple to create and embarrassingly common to find in the wild. let's break down how it happens and how to make sure it never happens on anything you run.
the setup that looks fine until it isn't
picture a normal feature. your app lets users download invoices. the code grabs a filename from the url and hands it straight to the file system:
send_file(f'/var/invoices/{request.args["file"]}')
works great in testing. someone requests ?file=invoice_204.pdf, they get their invoice. nobody thinks twice about it because the happy path always works. the problem is what happens on the unhappy path, when someone requests something you didn't expect.
how ../../../ breaks everything
the attacker doesn't need sql injection, credentials, or anything fancy. they just need to know that ../ means "go up one directory" in basically every operating system on earth. so instead of a normal filename, they send:
?file=../../../../etc/passwd
your code takes that string, glues it onto /var/invoices/, and the file system happily walks back up through the folders until it lands on /etc/passwd. the server doesn't know it's being tricked. it just sees a path and reads whatever is at the end of it. no exploit code, no malware, just string concatenation doing exactly what string concatenation does. the same trick works on config files, source code, ssh keys, anything the server's user account can read.
why the obvious fix doesn't work
a lot of people's first instinct is to check the string for ../ before using it. block that pattern and you're safe, right? not quite. attackers encode it, double encode it, use absolute paths, mix in null bytes, or rely on quirks in how different systems normalize paths. blacklisting patterns in a raw string is a losing game because you're trying to predict every way someone can spell "go up a directory," and there are more of those than you think.
the real fix: resolve first, then check
the trick is to stop reasoning about the string and start reasoning about the actual, final path on disk. resolve the path completely first, collapsing all the ../ tricks, and only then check where it actually landed:
p = (INVOICES / name).resolve()
if not p.is_relative_to(INVOICES): abort(403)
here's why order matters so much. .resolve() walks the path and collapses every ../ into its real destination before you ever compare anything. so ../../../../etc/passwd resolves down to /etc/passwd, and then is_relative_to(INVOICES) checks if that final destination is actually still inside your invoices folder. it isn't, so you reject it. if you check the raw string before resolving, the traversal sequences are still sitting there unresolved and your check just isn't looking at the right thing. resolve first, or the check is decorative.
the best fix skips the problem entirely
the cleanest solution isn't a smarter check, it's removing user input from the path altogether. give each file an id in your database, and when a user requests a download, look up the real filename server side:
invoice = db.get(invoice_id)
send_file(INVOICES / invoice.filename)
the user's string never touches the file system path. there's nothing to sanitize because there's nothing dangerous being passed in. this is the pattern to reach for whenever you're building anything that maps user input to files on disk, images, uploads, exports, whatever it is.
the takeaway
if your app ever builds a file path using something a user typed, go check it right now. grep your codebase for request.args, request.params, or similar getting fed into any file read function. if you find raw user input touching a path, fix it in this order of preference: use an id and a database lookup, or if you truly need a filename, resolve the full path first and confirm it's still inside your intended directory before you open it. don't trust string checks on unresolved paths, they're checking the wrong thing. this bug costs nothing to fix and costs everything if you don't.