Skip to content

WSGI: process environment variables override request headers in environ #878

Description

@Barsoomx

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    wsgiIssue related to WSGI protocol

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions