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:

router.add_route('GET', '/static/<str:filename>', handler) router.resolve('GET', '/static/style.css') # ('MATCH', ...) -- one segment, fine router.resolve('GET', '/static/css/style.css') # ('404', None, {}, None) -- genuinely 404s

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:

CONVERTERS = { 'int': (r'\d+', int), 'str': (r'[^/]+', str), 'path': (r'.+', str), # NEW -- matches across '/' too }
Verified directly — the new converter genuinely captures a nested path
<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.
The same capability that fixes it also opens something new
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:

def naive_static_handler(filename): full_path = os.path.join(STATIC_DIR, filename) with open(full_path, 'rb') as f: return f.read()
Verified directly — a real file OUTSIDE the static directory, genuinely read
With a real file 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:

def safe_static_path(filename): static_root = os.path.realpath(STATIC_DIR) candidate = os.path.realpath(os.path.join(STATIC_DIR, filename)) if os.path.commonpath([static_root, candidate]) != static_root: return None # genuinely escaped -- refuse it return candidate

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.

Verified directly against four distinct real cases
InputResult
css/style.cssresolves correctly, genuinely inside STATIC_DIR
../secret_outside_static.txtNone — blocked
a full absolute path to the secret fileNone — blocked
css/../../secret_outside_static.txtNone — blocked

The full handler, with real Content-Type detection and a genuine 404 rather than a leak on any rejected path:

import mimetypes def serve_static(request): resolved = safe_static_path(request.params['filename']) if resolved is None or not os.path.isfile(resolved): return Response('404 Not Found', status=404) with open(resolved, 'rb') as f: body = f.read() content_type, _ = mimetypes.guess_type(resolved) return Response(body, content_type=content_type or 'application/octet-stream')
Verified directly — real Content-Type detection across five real extensions
Real files created for .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:

app.add_route('GET', '/static/<path:filename>', serve_static)
Verified directly — real HTTP, real headers, a legitimate request and two real attacks
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:

import argparse def cmd_runserver(args): app = build_app(debug=args.debug) server = make_server('127.0.0.1', args.port, app) print(f"Serving on http://127.0.0.1:{args.port} (debug={args.debug})") if args.debug: run_with_reloader(server.serve_forever, watch_paths=[__file__]) else: server.serve_forever() def main(): parser = argparse.ArgumentParser(prog='manage.py') sub = parser.add_subparsers(dest='command', required=True) runserver = sub.add_parser('runserver') runserver.add_argument('--debug', action='store_true') runserver.add_argument('--port', type=int, default=8000) runserver.set_defaults(func=cmd_runserver) args = parser.parse_args() args.func(args) if __name__ == '__main__': main()

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:

import threading def run_with_reloader(serve_fn, watch_paths, poll_interval=0.25): watcher = FileWatcher(watch_paths) threading.Thread(target=serve_fn, daemon=True).start() while True: time.sleep(poll_interval) if watcher.changed(): os.execv(sys.executable, [sys.executable] + sys.argv)
Verified directly — real argparse parsing
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.

spawned PID (original): 9636 GET / -> 200 b'CLI-served app is running\n' GET /static/hello.txt -> 200 b'hello from cli static file' # ... manage.py's own source is genuinely edited on disk here ... GET / (post-reload) -> 200 b'CLI-served app is running\n' PID listening on port after edit: 36344
Verified directly — the CLI, argparse, static files, and the reloader, all genuinely working together
Both real requests before the edit succeeded. The watched source file was then genuinely modified while the server kept running. After the reload window, a fresh real request to / still returned 200 — the server survived the reload and kept serving correctly, through the exact CLI command a real user would type.
Chapter 8's own Windows PID-change finding, reproduced a second time
The PID listening on the port changed from a real 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.

Verified directly — a real hang, and its real cause
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:

log_file = open('server.log', 'w') proc = subprocess.Popen([...], stdout=log_file, stderr=subprocess.STDOUT) # ... later, cleanup -- find whoever is REALLY on the port, not proc.pid ... real_pid = get_pid_listening_on_port(port) subprocess.run(['taskkill', '/PID', real_pid, '/F', '/T'])
Verified directly — both fixes confirmed together, end to end
With output redirected to a real file and cleanup done by port lookup rather than remembered PID, the same full reload scenario ran to completion cleanly: two real requests before the edit, a genuine reload, one more real request afterward, then a clean process kill with no hang and no orphaned process left listening on the port. The server's own captured log file showed the real "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

Exercise 1

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 solution
Exercise 2

Create 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 solution
Exercise 3

Spawn 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 solution

Chapter 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