Dynamic handlers¶
servery is a file server. This document plans an optional, opt-in capability to also run dynamic handlers — CGI scripts, WSGI apps, ASGI apps — using only the standard library, on Python 3.14+.
Status (shipped, [Unreleased]): all three phases are implemented and tested. D1
--wsgi(servery/wsgi.py, lean HTTP/1.1 engine,wsgiref.validate-gated, ~20k req/s). D2--cgi(servery/cgi.py, RFC 3875 + the full security suite). D3--asgi(servery/asgi.py, asyncio "mini-uvicorn", lifespan, ~19k req/s, verified against Starlette). Each is off by default; HTTP/1.1 only. The design notes below are kept as the record and for future work (ASGI TLS/WebSocket).
0. The boundary (read first)¶
servery's identity is the file-server lane, not the framework lane
(VISION.md §4). Dynamic handlers do not change that, because they
are governed by one hard rule:
Off by default; explicit opt-in, exactly like
--upload. A plainservery ./publicserves files and cannot execute anything. Dynamic handling exists only when the operator names it:--cgi DIR,--wsgi MODULE:app,--asgi MODULE:app. No flag → no code execution, no behavior change, no risk.
So servery becomes "a file server that can also run a handler when you tell it to," not "a web framework." It defines no routes, no app object, no middleware — it hosts an interface the operator's own code (or script) implements.
Three more invariants:
- Zero third-party runtime deps stay zero. All three interfaces are pure
stdlib (
subprocess,wsgiref,asyncio). The operator's app may have its own deps — that is the operator'spip install, never servery's. - Safe by default. CGI executes processes (a large attack surface); its mitigations (below) are requirements, not options. WSGI/ASGI run in-process code the operator deliberately chose to load — lower risk, but still bounded.
- Free-threading aware. WSGI advertises
wsgi.multithread=True(servery is thread-per-connection); ASGI runs anasyncioloop. No module-level mutable state, per the project rule.
1. Feasibility — proven by prototype¶
Each interface was prototyped against the stdlib before this plan was written (the same "build it to be sure" approach used for the self-signed cert and the HTTP/2 server). All three work with no third-party packages:
| Interface | stdlib substrate | PoC result |
|---|---|---|
| CGI | subprocess + RFC 3875 env mapping |
script executes; body piped; httpoxy + Authorization leak mitigations verified |
| WSGI | wsgiref.handlers.SimpleHandler (engine) + wsgiref.validate (compliance) |
app runs on the sync model; wsgiref.validate passes |
| ASGI | asyncio.start_server + a small HTTP/1.1 parse → scope/receive/send |
real socket round-trip through a spec-shaped echo app |
Note: the cgi module was removed in 3.13 (PEP 594), but that module only
parsed forms inside a script — it has nothing to do with the server side of
CGI, which is just environment variables + a subprocess. No blocker.
2. Per-interface design (modern 3.14+)¶
2.1 WSGI — --wsgi pkg.module:app (smallest; fits the architecture)¶
WSGI (PEP 3333) is synchronous, so it maps directly onto servery's existing thread-per-request handler. Per request:
- Build
environfrom the request (REQUEST_METHOD,SCRIPT_NAME[mount prefix],PATH_INFO,QUERY_STRING,CONTENT_*,SERVER_*,HTTP_*,wsgi.input= the body stream,wsgi.errors,wsgi.url_scheme,wsgi.multithread=True). - Reuse
wsgiref.handlers.BaseHandler/SimpleHandler— the stdlib already implements the server side of WSGI correctly (status/header buffering,write()callable, chunk iteration,close()). servery supplies the request-derived environ and the socket streams; wsgiref does the protocol. - The app is imported once at startup (
module:callable).
Bounds: reuse the existing --max-upload-size-style request-body cap and the
socket --timeout. Mounting model: app at / by default, optionally under a
prefix (the prefix becomes SCRIPT_NAME); static files can still be served from
other paths.
2.2 CGI — --cgi DIR (small core, security-heavy)¶
CGI/1.1 (RFC 3875): per request, spawn the script as a child process, pass the request via environment + stdin, read the response from stdout.
subprocess.run(argv, env=…, input=body, capture_output=True, timeout=…)—argvas a list (shell=False, so no shell, no Shellshock function parsing); a clean, minimal env (never inherit the server's environment); a hard timeout; aCONTENT_LENGTHcap (RFC 3875 §9.6).- Parse the script's response: header block (
\n\n/\r\n\r\nseparator), honoringStatus:andLocation:(document / local-redirect / client-redirect responses, RFC 3875 §6.2). Supportnph-non-parsed-header scripts later if ever needed. - Only files under the named cgi directory, with the executable bit, that pass servery's existing realpath/commonpath containment, are runnable.
2.3 ASGI — --asgi pkg.module:app (largest; a parallel async stack)¶
ASGI (3.0) is asynchronous, so it cannot ride the sync handler — it needs an
event loop. This phase adds a second, opt-in server built on
asyncio.start_server with a small HTTP/1.1 codec that maps each request to
an ASGI scope + receive/send:
scope(type:"http", method, path,raw_path,query_string, headers,http_version,scheme,server,client,asgi:{version:"3.0"}).receive()yieldshttp.requestevents (body,more_bodyfor streaming request bodies);send()consumeshttp.response.start+http.response.bodyevents (with backpressure viadrain()).- lifespan protocol (
startup/shutdown) handled once per process. - Scope it to the HTTP ASGI scope first; WebSocket scope is a later, separate sub-phase (servery has no WebSocket support today). HTTP/2 for ASGI is out of this plan.
This is effectively a minimal uvicorn in the stdlib. It is the highest-effort
phase and the one furthest from servery's sync core — hence last, and explicitly
experimental until proven.
3. Security model (the hard part is CGI)¶
From RFC 3875 §9 and the well-known CGI CVEs, the following are requirements for the CGI phase (and informed the PoC):
| Threat | Requirement |
|---|---|
httpoxy (CVE-2016-5385): Proxy: request header → HTTP_PROXY env → SSRF/MITM |
Never set HTTP_PROXY; drop the Proxy request header before building the env. |
Authorization leak (RFC 3875 §9.2) |
Do not forward Authorization / Proxy-Authorization to the script (servery validated Basic auth itself). |
| Shellshock (CVE-2014-6271) | shell=False (exec the script directly) + a clean, minimal env so crafted values are never shell-parsed. |
| Path traversal (RFC 3875 §9.8) | Resolve .. and run the script + PATH_INFO through servery's existing realpath/commonpath containment before exec. |
| Resource exhaustion (RFC 3875 §9.6) | Cap CONTENT_LENGTH; enforce a per-request timeout; bound concurrency. |
| Privilege (RFC 3875 §9.5) | Document that scripts run as the server user; recommend a dedicated low-privilege user; never run servery as root. |
WSGI/ASGI run in-process code the operator explicitly imported, so the threat
model is "the operator chose to load this code," not "execute arbitrary files."
They still inherit servery's request-body cap, timeout, and the no-Authorization
default unless the app opts in.
4. External validation harnesses (incorporated from the start)¶
Mirroring h2spec (HTTP/2) and testssl.sh (TLS), each interface gets an independent, standard validator wired into its phase from day one:
| Interface | Harness | Role |
|---|---|---|
| WSGI | wsgiref.validate.validator (stdlib!) |
Official PEP 3333 compliance: wrap every test app — it raises on any server- or app-side spec violation. Zero-dep, runs in CI. (Already caught a missing SCRIPT_NAME in the PoC.) |
| WSGI interop | a real app (Flask/Werkzeug via a dev/test extra) driven over the socket with httpx/curl |
proves servery hosts real-world WSGI apps end-to-end. |
| CGI | security regression suite modeled on the CVEs (httpoxy → no HTTP_PROXY; Authorization not forwarded; PATH_INFO traversal contained; CONTENT_LENGTH cap; timeout kills a runaway script) + RFC 3875 conformance tests |
the safety net is the test suite, since there is no single famous CGI scanner. |
| CGI interop | run real CGI scripts (a Python and a shell echo/env script) |
cross-language interop, like curl --http2 for h2. |
| ASGI | asgiref.compatibility (app detection/validation) + httpx ASGITransport + async-asgi-testclient spec-compliance tests |
the closest thing to an ASGI conformance suite. |
| ASGI interop | host a real Starlette app (dev/test extra) and drive it over the socket with httpx | proves servery runs production ASGI apps; uvicorn's behavior is the reference. |
| Benchmark | extend scripts/bench.py / scripts/microbench.py |
WSGI/ASGI throughput vs static; CGI is process-per-request (orders of magnitude slower — measured and documented, not optimized). |
wsgiref.validate is zero-dep and belongs in CI. The interop/harness deps
(flask, starlette, async-asgi-testclient) live in the test dependency
group only — never runtime — and tests skip when absent, exactly like httpx.
5. Phased plan¶
Order = lowest effort + best architectural fit first; highest risk last. Each phase ships behind its own off-by-default flag with its validator and security tests green before merge.
| Phase | Scope | Flag | Harness gating the phase | Risk |
|---|---|---|---|---|
| D1 — WSGI | sync app hosting via wsgiref engine; environ mapping; mount prefix; body/timeout bounds |
--wsgi M:app |
wsgiref.validate (CI) + Flask interop (test extra) |
Low — maps to the sync model |
| D2 — CGI | subprocess execution; RFC 3875 env + response parsing; all §3 mitigations |
--cgi DIR |
CVE-modeled security suite + RFC 3875 conformance + real-script interop | Medium — security surface; mitigations are the work |
| D3 — ASGI (experimental) | asyncio HTTP server; HTTP scope/receive/send; lifespan |
--asgi M:app |
asgiref.compatibility + async-asgi-testclient + Starlette interop |
High — a parallel async server |
| D3b — ASGI WebSocket (stretch) | websocket scope |
(same) | httpx-ws / Starlette ws app | High — new protocol surface |
Suggested release shape: D1 in one minor release, D2 in the next, D3 behind an
experimental label (like the 3.15 CI tier) until the async stack is proven —
each independently revertable.
6. Non-goals / open questions¶
- Not a framework: no routing, no middleware, no app scaffolding. servery hosts an interface; the operator brings the app.
- WebSocket / HTTP-2-for-ASGI: out of the initial plan (D3b stretch / never).
- Hot reload, process managers, multiple workers: out of scope — that is gunicorn/uvicorn territory. servery hosts one app, one process.
- Mounting: does a dynamic handler take the whole server, or mount under a path with static files elsewhere? (Lean: whole-server by default, optional prefix.) — to settle in D1.
- CGI on Windows: shebang handling differs; D2 should test or scope to POSIX first.
7. References¶
- RFC 3875 — The Common Gateway Interface (CGI) Version 1.1 (esp. §4.1 meta-vars, §6 response, §9 security). Local copy: the RFC corpus.
- PEP 3333 — Python Web Server Gateway Interface v1.0.1 (WSGI).
- ASGI 3.0 specification — https://asgi.readthedocs.io/en/latest/specs/main.html.
- httpoxy — https://httpoxy.org/ (CVE-2016-5385 and friends).
- Validators/harnesses:
wsgiref.validate(stdlib),asgiref.compatibility, httpxASGITransport,async-asgi-testclient.