What happened
In the WSGI interface, process environment variables override request data in the WSGI environ. Any environment variable named HTTP_<SOMETHING> (or matching any CGI key like REQUEST_METHOD, CONTENT_TYPE, ...) silently replaces the corresponding value coming from the actual HTTP request, on every request.
We hit this while migrating a Django app from gunicorn to Granian in Kubernetes: the pod had an environment variable named HTTP_HOST (used by the app for unrelated purposes), which replaced the Host header of every incoming request. Django's request.get_host() then failed with DisallowedHost on every request (the env value contained a URL scheme, which is never a valid host), so health probes got HTTP 400 and the deployment crash-looped.
The same mechanism would also let e.g. an HTTP_AUTHORIZATION or HTTP_X_FORWARDED_FOR environment variable silently replace the request's auth header / client IP.
Expected behavior
Request data should take precedence over the process environment. This matches the stdlib reference behavior: wsgiref uses os.environ as the base of the environ (WSGIServer.setup_environ) and then writes request data on top of it (BaseHandler). PEP 3333 also describes the CGI variables as coming from the request.
Reproduction
# app.py
def application(environ, start_response):
body = environ.get('HTTP_HOST', '<missing>').encode()
start_response('200 OK', [('content-type', 'text/plain')])
return [body]
pip install granian==2.7.9
HTTP_HOST=https://evil.example granian --interface wsgi app:application --port 8000 &
curl -H 'Host: localhost:8000' http://127.0.0.1:8000/
# Expected: localhost:8000
# Actual: https://evil.example
Root cause
granian/wsgi.py, _callback_wrapper:
basic_env: dict[str, Any] = dict(os.environ) # os.environ as the base
...
def _runner(proto, scope):
resp = Response()
scope.update(basic_env) # env applied ON TOP of request scope
scope arrives from the Rust side already populated with request data (including HTTP_* header keys), and scope.update(basic_env) then overwrites those keys with the process environment - the opposite of the wsgiref precedence.
Suggested fix
Build the environ with the environment as the base and request data on top, e.g.:
environ = basic_env | scope
The wsgi.* / GATEWAY_INTERFACE / SERVER_SOFTWARE / SCRIPT_NAME keys set by Granian itself are not present in the request scope, so they are still applied as before; only the request-provided keys stop being overwritten. In a quick microbenchmark this is also at parity with (or slightly faster than) the current scope.update(basic_env) since it avoids re-inserting the environment into the request dict.
What happened
In the WSGI interface, process environment variables override request data in the WSGI
environ. Any environment variable namedHTTP_<SOMETHING>(or matching any CGI key likeREQUEST_METHOD,CONTENT_TYPE, ...) silently replaces the corresponding value coming from the actual HTTP request, on every request.We hit this while migrating a Django app from gunicorn to Granian in Kubernetes: the pod had an environment variable named
HTTP_HOST(used by the app for unrelated purposes), which replaced theHostheader of every incoming request. Django'srequest.get_host()then failed withDisallowedHoston every request (the env value contained a URL scheme, which is never a valid host), so health probes got HTTP 400 and the deployment crash-looped.The same mechanism would also let e.g. an
HTTP_AUTHORIZATIONorHTTP_X_FORWARDED_FORenvironment variable silently replace the request's auth header / client IP.Expected behavior
Request data should take precedence over the process environment. This matches the stdlib reference behavior:
wsgirefusesos.environas the base of the environ (WSGIServer.setup_environ) and then writes request data on top of it (BaseHandler). PEP 3333 also describes the CGI variables as coming from the request.Reproduction
Root cause
granian/wsgi.py,_callback_wrapper:scopearrives from the Rust side already populated with request data (includingHTTP_*header keys), andscope.update(basic_env)then overwrites those keys with the process environment - the opposite of thewsgirefprecedence.Suggested fix
Build the environ with the environment as the base and request data on top, e.g.:
The
wsgi.*/GATEWAY_INTERFACE/SERVER_SOFTWARE/SCRIPT_NAMEkeys set by Granian itself are not present in the request scope, so they are still applied as before; only the request-provided keys stop being overwritten. In a quick microbenchmark this is also at parity with (or slightly faster than) the currentscope.update(basic_env)since it avoids re-inserting the environment into the request dict.