gunicorn/tests/test_header_policy_parity.py
Benoit Chesneau e90b1c2c1e fix: address six WSGI/ASGI parser and protocol findings
- WSGI fast parser now applies the same per-header policy as the Python
  parser (Expect, secure_scheme_headers, forwarded_allow_ips trust gate,
  forwarder_headers / header_map). Shared helpers extracted on Message.

- ASGI keepalive no longer resets the parser when the previous request
  body was not fully framed; the connection closes instead, preventing
  request smuggling on pipelined connections.

- BodyReceiver._wait_for_data timeout flips _closed and yields
  http.disconnect rather than synthesizing more_body=False. Timeout
  honors cfg.timeout.

- ASGI chunked encoding now skips HEAD, 204, and 304 (matches
  Response.is_chunked in the WSGI path) via a small helper.

- _setup_callback_parser passes proxy_protocol to PythonProtocol; auto
  falls back to the Python parser when proxy_protocol != off (the C
  parser does not implement PROXY framing). _effective_peername swaps
  the transport peer with the PROXY-supplied client address.

- Parser.finish_body accepts a deadline and a 64KiB byte cap; gthread
  passes a deadline and abandons keepalive on incomplete drain so a
  stalled client cannot tie up a worker thread.
2026-05-03 18:19:08 +02:00

186 lines
5.5 KiB
Python

#
# This file is part of gunicorn released under the MIT license.
# See the NOTICE for more information.
"""Parity tests for WSGI header policy across Python and fast parsers.
These checks ensure that Expect, secure_scheme_headers, forwarder_headers,
and the forwarded_allow_ips trust gate are enforced identically regardless
of the parser implementation selected by ``http_parser``.
"""
import sys
import pytest
from gunicorn.config import Config
from gunicorn.http.parser import RequestParser
from gunicorn.http.errors import (
ExpectationFailed,
InvalidHeaderName,
InvalidSchemeHeaders,
)
def _parse(raw, cfg, peer_addr):
parser = RequestParser(cfg, iter([raw]), peer_addr)
return next(iter(parser))
def _cfg(http_parser, **overrides):
cfg = Config()
cfg.set("http_parser", http_parser)
for k, v in overrides.items():
cfg.set(k, v)
return cfg
@pytest.fixture(params=["python", "fast"])
def parser_name(request):
if request.param == "fast":
if hasattr(sys, "pypy_version_info"):
pytest.skip("gunicorn_h1c not supported on PyPy")
gunicorn_h1c = pytest.importorskip("gunicorn_h1c")
if not hasattr(gunicorn_h1c.H1CProtocol, "asgi_headers"):
pytest.skip("gunicorn_h1c >= 0.6.2 required")
return request.param
class TestExpectPolicy:
def test_expect_100_continue_sets_flag(self, parser_name):
cfg = _cfg(parser_name)
raw = (
b"POST / HTTP/1.1\r\n"
b"Host: example.com\r\n"
b"Content-Length: 0\r\n"
b"Expect: 100-continue\r\n"
b"\r\n"
)
req = _parse(raw, cfg, ("127.0.0.1", 1234))
assert req._expected_100_continue is True
def test_expect_unknown_value_rejected(self, parser_name):
cfg = _cfg(parser_name)
raw = (
b"POST / HTTP/1.1\r\n"
b"Host: example.com\r\n"
b"Content-Length: 0\r\n"
b"Expect: bogus-extension\r\n"
b"\r\n"
)
with pytest.raises(ExpectationFailed):
_parse(raw, cfg, ("127.0.0.1", 1234))
def test_expect_ignored_in_http10(self, parser_name):
cfg = _cfg(parser_name)
raw = (
b"POST / HTTP/1.0\r\n"
b"Host: example.com\r\n"
b"Content-Length: 0\r\n"
b"Expect: 100-continue\r\n"
b"\r\n"
)
req = _parse(raw, cfg, ("127.0.0.1", 1234))
assert req._expected_100_continue is False
class TestSecureSchemeHeaders:
def test_trusted_peer_promotes_https(self, parser_name):
cfg = _cfg(
parser_name,
forwarded_allow_ips="127.0.0.1",
secure_scheme_headers={"X-FORWARDED-PROTO": "https"},
)
raw = (
b"GET / HTTP/1.1\r\n"
b"Host: example.com\r\n"
b"X-Forwarded-Proto: https\r\n"
b"\r\n"
)
req = _parse(raw, cfg, ("127.0.0.1", 1234))
assert req.scheme == "https"
def test_untrusted_peer_keeps_http(self, parser_name):
cfg = _cfg(
parser_name,
forwarded_allow_ips="127.0.0.1",
secure_scheme_headers={"X-FORWARDED-PROTO": "https"},
)
raw = (
b"GET / HTTP/1.1\r\n"
b"Host: example.com\r\n"
b"X-Forwarded-Proto: https\r\n"
b"\r\n"
)
req = _parse(raw, cfg, ("203.0.113.5", 1234))
assert req.scheme == "http"
def test_conflicting_scheme_headers_rejected(self, parser_name):
cfg = _cfg(
parser_name,
forwarded_allow_ips="127.0.0.1",
secure_scheme_headers={
"X-FORWARDED-PROTO": "https",
"X-FORWARDED-SSL": "on",
},
)
raw = (
b"GET / HTTP/1.1\r\n"
b"Host: example.com\r\n"
b"X-Forwarded-Proto: https\r\n"
b"X-Forwarded-Ssl: off\r\n"
b"\r\n"
)
with pytest.raises(InvalidSchemeHeaders):
_parse(raw, cfg, ("127.0.0.1", 1234))
class TestForwarderTrustGate:
def test_untrusted_peer_underscore_header_rejected(self, parser_name):
cfg = _cfg(
parser_name,
forwarded_allow_ips="127.0.0.1",
forwarder_headers="SCRIPT_NAME",
header_map="refuse",
)
raw = (
b"GET / HTTP/1.1\r\n"
b"Host: example.com\r\n"
b"Script_Name: /evil\r\n"
b"\r\n"
)
with pytest.raises(InvalidHeaderName):
_parse(raw, cfg, ("203.0.113.5", 1234))
def test_trusted_peer_underscore_header_accepted(self, parser_name):
cfg = _cfg(
parser_name,
forwarded_allow_ips="127.0.0.1",
forwarder_headers="SCRIPT_NAME",
)
raw = (
b"GET / HTTP/1.1\r\n"
b"Host: example.com\r\n"
b"Script_Name: /api\r\n"
b"\r\n"
)
req = _parse(raw, cfg, ("127.0.0.1", 1234))
names = {n for n, _ in req.headers}
assert "SCRIPT_NAME" in names
def test_header_map_drop_silences_underscore(self, parser_name):
cfg = _cfg(
parser_name,
forwarded_allow_ips="127.0.0.1",
header_map="drop",
)
raw = (
b"GET / HTTP/1.1\r\n"
b"Host: example.com\r\n"
b"Stray_Name: x\r\n"
b"\r\n"
)
req = _parse(raw, cfg, ("203.0.113.5", 1234))
names = {n for n, _ in req.headers}
assert "STRAY_NAME" not in names