Step 1 of 5 · Reading · ~4 min
Read
Concurrency & Persistence
Serving Static Files
Most of what a web server sends is not generated — it is a file on disk. That makes the static handler the simplest thing in the whole server and, by some distance, the most dangerous, because it is the one place where a string the attacker controls is turned into a path the operating system will open. Two things have to be right: where the path is allowed to point, and what you claim the bytes are.
The one-line vulnerability
filepath = "./public" + request.path # "./public/static/css/main.css"
That looks like string concatenation. It is actually a filesystem API taking user input. A request for /../../../etc/passwd becomes ./public/../../../etc/passwd, the kernel resolves the .. segments happily, and your server hands out the password file. This is path traversal, it is decades old, and it still lands in production every year — because the broken version is the version you write first, and it works perfectly on every path you would think to test by hand.
The fix is two steps that must happen in this order:
Canonicalise first. Checking the raw string for ".." before normalising is the classic bypass factory: the attacker just sends %2e%2e%2f, or ....//, or a UTF-8 overlong encoding, and your blacklist reads a string that contains no .. at all while the kernel sees one after decoding. Normalise to one canonical form, then ask a single question about the result.
Two details in that check earn their place. os.sep is not decoration — a bare target.startswith(root) accepts /var/www-backup for a root of /var/www, and "sibling directory whose name starts with the same letters" is a real escape. And realpath resolves symlinks, which lexical normalisation cannot: a symlink at public/uploads/x pointing to /etc normalises to a path that looks perfectly contained and opens something else entirely. If users can create files under your root, only the symlink-resolving check is a check at all.
Content-Type: telling the truth about bytes
A browser decides what to do with a response from the Content-Type header, not from the file extension in the URL. Serve a stylesheet as text/plain and the page renders unstyled with no error you would notice; serve JavaScript as text/plain and a modern browser refuses to execute it.
| Extension | Content-Type |
|---|---|
.html | text/html; charset=utf-8 |
.css | text/css |
.js | application/javascript |
.json | application/json |
.png / .jpg / .gif | image/png / image/jpeg / image/gif |
.svg | image/svg+xml |
.txt | text/plain; charset=utf-8 |
The charset on the text types is not optional in practice: omit it and the browser guesses an encoding, so an accented character in a UTF-8 page arrives as mojibake. Anything unrecognised gets application/octet-stream, which means "raw bytes, do not try to interpret these" — the browser offers a download instead of rendering. Defaulting to text/html instead would be an XSS hole with extra steps: an uploaded .foo full of <script> would execute in your origin. Guessing is the vulnerability; octet-stream is the safe refusal to guess.
.svg deserves its own warning. It is an image type that can contain script, so serving user-uploaded SVG from your main origin is executable-content territory — production sites push uploads to a separate domain for exactly this reason.
What production adds on top
Once correctness is settled, a real static handler layers on efficiency, and every layer is a lesson of its own later in this course: If-Modified-Since and ETag revalidation turning repeat requests into a bodiless 304; Range requests serving 206 Partial Content so a video can seek and a broken download can resume; gzip or br encoding negotiated through Accept-Encoding; and a sendfile() syscall that streams the file from page cache to socket without ever copying it through your process.
Your exercise: Path Sanitization & MIME
Root and request path in; the resolved host path with its Content-Type out, or 403 when the path escapes. The ladder is the attack list: an ordinary file, a .. climb straight out of root, an interior .. that resolves back inside and must be allowed, a multi-level escape, then the extension table including an unknown extension that has to fall through to octet-stream. Note what the exercise's lexical normalisation can and cannot do — it settles the .. question, and on a real disk you would reach for realpath on top of it so that symlinks cannot answer differently.
Discussion
Ask a question, share an insight, or help someone who’s stuck.
Sign in to post a comment or reply.
Loading…