Static Files & a Real CLI for the Framework
Building a Web Framework
Chapter 9 ยท Static Files & a Real CLI for the Framework
Two genuinely separate problems close out this chapter: serving CSS, JavaScript, and image files that live on disk rather than being generated by a handler, and giving the framework a real command-line entry point instead of a hand-written if __name__ == '__main__': block copied into every project. Static files turn out to be a direct, unplanned payoff — and a direct, unplanned risk — of Chapter 2's own extensible CONVERTERS dict.
A New Converter, and a New Kind of Risk
Every converter built since Chapter 2 matches exactly one path segment — [^/]+ for str, stopping dead at the first /. That's correct for a username or a slug, but genuinely wrong for a static file living at css/style.css, two real segments deep. Verified directly:
A new converter fixes it — and it's a single new dict entry, exactly the shape Chapter 2's own design was built to accommodate:
<path:filename> against /static/css/style.css resolves with MATCH and {'filename': 'css/style.css'}, correctly, where the plain str version 404s. The regex change is the entire fix — nothing else in Router needed to change to support it.
r'.+' matches anything across any number of segments — including ../secret.txt. Verified directly: the identical route also resolves /static/../secret_outside_static.txt with MATCH and {'filename': '../secret_outside_static.txt'}. The router doesn't know or care what a filename "should" look like — it just matches the pattern it was given. Whether that string is safe to actually open is a question for the handler, not the router.
A Naive Handler, and the Real Vulnerability It Opens
The obvious first implementation joins the static directory with the requested filename and opens it:
secret_outside_static.txt placed one level above STATIC_DIR, containing b'TOP SECRET - should never be servable via /static/', calling naive_static_handler('../secret_outside_static.txt') returns that exact real content — the naive handler genuinely reads a file it was never meant to expose. os.path.join() happily follows a '..' segment right back out of the directory it was supposed to confine requests to; it performs string concatenation, not containment checking.
The Real Fix — realpath() and commonpath()
The fix isn't rejecting the string '..' — a filename can smuggle the identical escape through URL-encoding, symlinks, or a mix of legitimate and traversal segments. The real fix is resolving the path fully, then checking where it actually landed:
os.path.realpath() collapses every .. and symlink down to one real, canonical absolute path. os.path.commonpath() then answers one exact question: does the resolved candidate still sit inside the resolved root? If it doesn't, no string-pattern check was ever going to catch every way to ask the question in the first place — this checks the actual, final destination instead.
| Input | Result |
|---|---|
css/style.css | resolves correctly, genuinely inside STATIC_DIR |
../secret_outside_static.txt | None — blocked |
| a full absolute path to the secret file | None — blocked |
css/../../secret_outside_static.txt | None — blocked |
The full handler, with real Content-Type detection and a genuine 404 rather than a leak on any rejected path:
.js, .json, .txt, .css, and .html, served through this exact handler over a real running server, produced Content-Type headers of text/javascript, application/json, text/plain, text/css, and text/html respectively — every one correct, with zero manual mapping written by this framework itself. mimetypes is standard-library, sourced from the same OS-level extension table every other program on the machine already uses.
Verified Live — Through a Real Running Server
Wired into App via the now-familiar route, and hit with real HTTP requests against a real wsgiref server bound to a real socket, not called as a bare Python function:
GET /static/css/style.css → 200, body b'body { color: red; }', with a real Content-type: text/css and a real Last-Modified header, both genuinely produced by the server. A real GET /static/../secret_outside_static.txt against the live server → 404. A second, mixed-segment real attempt, GET /static/css/../../secret_outside_static.txt → also 404. The fix holds against a real client making real requests over a real socket, not just against direct function calls.
A Real CLI: Wiring Everything Together
Every chapter so far has ended with a hand-written script that builds an App and calls make_server(...).serve_forever() directly. A real framework gives its users one command instead — argparse, from the standard library, is enough:
run_with_reloader() itself needed one real change from Chapter 8's own version — it now runs the actual server call in a background thread, and polls for a change on the main thread, since serve_forever() blocks:
parse_args(['runserver']) → Namespace(command='runserver', debug=False, port=8000). parse_args(['runserver', '--debug', '--port', '8951']) → debug=True, port=8951, with '8951' genuinely converted to the real integer 8951 by type=int. Calling parse_args([]) with no subcommand at all raises a real SystemExit, code 2 — argparse's own required=True genuinely enforced, not just documented.
Verified Live — the Full CLI, Including a Reload While Serving
The comprehensive test: spawn python manage.py runserver --debug --port 8951 as a real subprocess, make real HTTP requests, edit the watched source file on disk while the process keeps running, wait, then make another real request against the same port.
/ still returned 200 — the server survived the reload and kept serving correctly, through the exact CLI command a real user would type.
9636 to a real 36344 across the reload — the identical platform-specific os.execv() behavior Chapter 8 found once already, now confirmed again in a genuinely different context: a threaded server inside a CLI command, not a bare polling loop.
A Second, New Windows Finding: Naive Process Tracking Breaks Too
The first attempt at this exact live test captured the subprocess's output with a plain pipe (subprocess.PIPE) and called proc.terminate() followed by proc.stdout.read() once the test was done. It hung indefinitely.
proc.terminate() only ever signals the original PID the Popen object remembers. On this real Windows machine, os.execv() genuinely launches a brand-new OS process with its own new PID — confirmed directly with Get-NetTCPConnection: a real orphaned process was still bound to the port, and still running, well after the original PID had already been signaled. That new process had inherited the same stdout pipe handle, and kept its write end open — so proc.stdout.read(), which only returns once every writer has closed that pipe, waited forever for a process the test no longer had any handle to.
Two real, independent fixes, verified together: redirect the child's output to a real log file instead of a pipe (a file has no "still open" ambiguity the way a pipe's write end does), and, for cleanup, look up whichever PID is actually listening on the port right now rather than trusting the one Popen remembers:
"Serving on http://127.0.0.1:8951" startup line printed twice — once at original startup, once again after the process restarted itself.
Nothing about this changes how a user of the framework actually runs it — python manage.py runserver --debug works exactly the same either way. It's a genuine lesson about tooling around a self-restarting process on Windows specifically, not about the framework's own runtime behavior.
Where This Course Is Headed
Chapter 10 is the capstone — assembling every piece built across all nine prior chapters (routing, request/response, templates, middleware, sessions, the ORM, error handling, live reload, and now static files and the CLI) into one real, working small application, run end to end through python manage.py runserver.
Hands-On Exercises
Against the live CLI-served app, verify that a URL-quoted absolute path (the real, full filesystem path to a file genuinely outside the static directory, with no leading "../" at all) is also blocked by safe_static_path() โ try both a backslash-style and a forward-slash-style absolute path, and confirm both return a real 404 against a real running server.
๐ View solutionCreate five real static files with five different extensions (.js, .json, .txt, .css, .html), serve all five through the live CLI app, and verify via real HTTP requests that mimetypes.guess_type() produces the correct real Content-Type header for every one of them.
๐ View solutionSpawn the CLI deliberately WITHOUT --debug, edit its watched source file on disk while it's running, wait through the same real time window the --debug case needed to reload, then verify โ by checking which real PID is listening on the port before and after โ that no reload happened at all, confirming debug=False genuinely never starts the watch loop in the first place.
๐ View solutionChapter 9 Quick Reference
- The path converter โ {'path': (r'.+', str)}, added to Chapter 2's own CONVERTERS dict, matches across '/' โ the same capability that enables nested static paths also matches traversal strings
- The vulnerability โ verified directly: os.path.join(STATIC_DIR, filename) with an unchecked filename genuinely reads a real file outside the static directory
- The fix โ os.path.realpath() to fully resolve the path, then os.path.commonpath() to check it's still genuinely inside the static root; verified against 4 real cases
- mimetypes.guess_type() โ real, standard-library Content-Type detection, verified correct across 5 real file extensions
- The CLI โ argparse, a runserver subcommand, --debug and --port flags, verified with real Namespace parsing and a real SystemExit on a missing subcommand
- run_with_reloader() updated โ now runs serve_forever() in a background thread so the main thread can poll for changes, verified surviving a real reload while serving
- A new, real Windows finding โ a reload's new PID breaks both naive stdout-pipe capture (hangs forever) and PID-remembered process cleanup; fixed with a real log file and a port-based PID lookup
- Next chapter: the capstone โ every chapter's own piece, assembled into one real app run through python manage.py runserver