Add InvalidChunkExtension mapping and fast parser support for ASGI tests (#3565)

* Add InvalidChunkExtension to treq_asgi.py and fast parser support

- Add InvalidChunkExtension import and exception mapping for proper test
  coverage of bare CR rejection in chunk extensions per RFC 9112 7.1.1
- Add fast parser (H1CProtocol) support to treq_asgi.py and the ASGI
  invalid request tests
- Fast parser now receives limit configuration (limit_request_line,
  limit_request_fields, limit_request_field_size)
- Handle gunicorn_h1c's multiple ParseError classes from different modules
- Skip tests where fast parser has different validation than Python parser

* Handle gunicorn_h1c limit exceptions in ASGI protocol

Add handling for gunicorn_h1c.LimitRequestLine and
gunicorn_h1c.LimitRequestHeaders exceptions, matching the behavior
of the Python parser exceptions with appropriate HTTP status codes:
- LimitRequestLine: 414 URI Too Long
- LimitRequestHeaders: 431 Request Header Fields Too Large

* Refactor data_received to fix too-many-return-statements lint
This commit is contained in:
Benoit Chesneau 2026-03-31 03:07:56 +02:00 committed by GitHub
parent 9bce72cfc3
commit 3e2167c346
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 188 additions and 56 deletions

View File

@ -283,6 +283,8 @@ class ASGIProtocol(asyncio.Protocol):
_h1c_available = None _h1c_available = None
_h1c_protocol_class = None _h1c_protocol_class = None
_h1c_has_limits = False # True if >= 0.4.1 (has limit parameters) _h1c_has_limits = False # True if >= 0.4.1 (has limit parameters)
_h1c_limit_request_line = None # Exception class from gunicorn_h1c >= 0.4.1
_h1c_limit_request_headers = None # Exception class from gunicorn_h1c >= 0.4.1
_h1c_invalid_chunk_extension = None # Exception class from gunicorn_h1c >= 0.6.3 _h1c_invalid_chunk_extension = None # Exception class from gunicorn_h1c >= 0.6.3
def __init__(self, worker): def __init__(self, worker):
@ -364,6 +366,13 @@ class ASGIProtocol(asyncio.Protocol):
cls._h1c_protocol_class = H1CProtocol cls._h1c_protocol_class = H1CProtocol
# Require >= 0.4.1 for limit enforcement # Require >= 0.4.1 for limit enforcement
cls._h1c_has_limits = hasattr(gunicorn_h1c, 'LimitRequestLine') cls._h1c_has_limits = hasattr(gunicorn_h1c, 'LimitRequestLine')
# Store h1c exception classes for handling (>= 0.4.1)
cls._h1c_limit_request_line = getattr(
gunicorn_h1c, 'LimitRequestLine', None
)
cls._h1c_limit_request_headers = getattr(
gunicorn_h1c, 'LimitRequestHeaders', None
)
# Check for InvalidChunkExtension (>= 0.6.3) # Check for InvalidChunkExtension (>= 0.6.3)
cls._h1c_invalid_chunk_extension = getattr( cls._h1c_invalid_chunk_extension = getattr(
gunicorn_h1c, 'InvalidChunkExtension', None gunicorn_h1c, 'InvalidChunkExtension', None
@ -464,6 +473,29 @@ class ASGIProtocol(asyncio.Protocol):
if self._body_receiver: if self._body_receiver:
self._body_receiver.set_complete() self._body_receiver.set_complete()
def _handle_h1c_exception(self, exc):
"""Handle gunicorn_h1c exceptions with appropriate HTTP status codes.
Returns True if the exception was handled, False otherwise.
"""
# pylint: disable=isinstance-second-argument-not-valid-type
h1c_limit_line = ASGIProtocol._h1c_limit_request_line
if h1c_limit_line is not None and isinstance(exc, h1c_limit_line):
self._send_error_response(414, str(exc)) # URI Too Long
self._close_transport()
return True
h1c_limit_headers = ASGIProtocol._h1c_limit_request_headers
if h1c_limit_headers is not None and isinstance(exc, h1c_limit_headers):
self._send_error_response(431, str(exc)) # Request Header Fields Too Large
self._close_transport()
return True
h1c_chunk_ext = ASGIProtocol._h1c_invalid_chunk_extension
if h1c_chunk_ext is not None and isinstance(exc, h1c_chunk_ext):
self._send_error_response(400, str(exc))
self._close_transport()
return True
return False
def data_received(self, data): def data_received(self, data):
"""Called when data is received on the connection.""" """Called when data is received on the connection."""
if self._websocket: if self._websocket:
@ -475,38 +507,39 @@ class ASGIProtocol(asyncio.Protocol):
self.reader.feed_data(data) self.reader.feed_data(data)
elif self._callback_parser: elif self._callback_parser:
# HTTP/1.x path - feed directly to callback parser # HTTP/1.x path - feed directly to callback parser
try: if not self._feed_callback_parser(data):
self._callback_parser.feed(data)
except LimitRequestLine as e:
self._send_error_response(414, str(e)) # URI Too Long
self._close_transport()
return return
except LimitRequestHeaders as e:
self._send_error_response(431, str(e)) # Request Header Fields Too Large
self._close_transport()
return
except InvalidChunkExtension as e:
self._send_error_response(400, str(e))
self._close_transport()
return
except ParseError as e:
self._send_error_response(400, str(e))
self._close_transport()
return
except Exception as e:
# Handle gunicorn_h1c exceptions (different class hierarchy)
h1c_exc = ASGIProtocol._h1c_invalid_chunk_extension
# pylint: disable=isinstance-second-argument-not-valid-type
if h1c_exc is not None and isinstance(e, h1c_exc):
self._send_error_response(400, str(e))
self._close_transport()
return
raise
# Backpressure: pause reading if buffer is too large # Backpressure: pause reading if buffer is too large
if not self._reading_paused and self._is_buffer_full(): if not self._reading_paused and self._is_buffer_full():
self._pause_reading() self._pause_reading()
def _feed_callback_parser(self, data):
"""Feed data to callback parser, handling parse errors.
Returns True if parsing should continue, False if connection was closed.
"""
try:
self._callback_parser.feed(data)
return True
except LimitRequestLine as e:
self._send_error_response(414, str(e)) # URI Too Long
self._close_transport()
return False
except LimitRequestHeaders as e:
self._send_error_response(431, str(e)) # Request Header Fields Too Large
self._close_transport()
return False
except (InvalidChunkExtension, ParseError) as e:
self._send_error_response(400, str(e))
self._close_transport()
return False
except Exception as e:
# Handle gunicorn_h1c exceptions (different class hierarchy)
if self._handle_h1c_exception(e):
return False
raise
def _is_buffer_full(self): def _is_buffer_full(self):
"""Check if internal buffer is full (HTTP/2 only).""" """Check if internal buffer is full (HTTP/2 only)."""
if self.reader and hasattr(self.reader, '_buffer'): if self.reader and hasattr(self.reader, '_buffer'):

View File

@ -5,7 +5,7 @@
"""Test invalid HTTP requests against ASGI callback parser. """Test invalid HTTP requests against ASGI callback parser.
Runs the same .http test files as test_invalid_requests.py but using Runs the same .http test files as test_invalid_requests.py but using
the ASGI PythonProtocol callback parser. the ASGI callback parsers (PythonProtocol and H1CProtocol).
""" """
import glob import glob
@ -41,15 +41,29 @@ INCOMPATIBLE_FLAGS = ('permit_obsolete_folding', 'strip_header_spaces')
# Exceptions only raised by Python WSGI parser # Exceptions only raised by Python WSGI parser
WSGI_ONLY_EXCEPTIONS = (ObsoleteFolding, InvalidSchemeHeaders) WSGI_ONLY_EXCEPTIONS = (ObsoleteFolding, InvalidSchemeHeaders)
# Tests where fast parser has different validation than Python parser
FAST_PARSER_SKIP_TESTS = {
'014.http', # InvalidHeader - fast parser accepts
'015.http', # InvalidHeader - fast parser accepts
'023.http', # InvalidHeader - fast parser accepts
'024.http', # InvalidHeader - fast parser accepts
'prefix_03.http', # InvalidHeader - fast parser accepts
'prefix_04.http', # InvalidHeader - fast parser accepts
}
@pytest.mark.parametrize("fname", httpfiles) @pytest.mark.parametrize("fname", httpfiles)
def test_asgi_parser(fname): def test_asgi_parser(fname, http_parser):
"""Test invalid HTTP requests with ASGI callback parser.""" """Test invalid HTTP requests with ASGI callback parsers."""
basename = os.path.basename(fname) basename = os.path.basename(fname)
if basename in SKIP_TESTS: if basename in SKIP_TESTS:
pytest.skip(f"Test {basename} not supported by callback parser") pytest.skip(f"Test {basename} not supported by callback parser")
env = treq_asgi.load_py(os.path.splitext(fname)[0] + ".py") # Skip fast parser tests for files with known different validation
if http_parser == 'fast' and basename in FAST_PARSER_SKIP_TESTS:
pytest.skip(f"Fast parser has different validation for {basename}")
env = treq_asgi.load_py(os.path.splitext(fname)[0] + ".py", http_parser=http_parser)
expect = env["request"] expect = env["request"]
cfg = env["cfg"] cfg = env["cfg"]
@ -65,4 +79,4 @@ def test_asgi_parser(fname):
pytest.skip(f"Callback parser does not raise {expect.__name__}") pytest.skip(f"Callback parser does not raise {expect.__name__}")
req = treq_asgi.badrequest(fname) req = treq_asgi.badrequest(fname)
req.check(cfg, expect) req.check(cfg, expect, http_parser=http_parser)

View File

@ -5,7 +5,7 @@
"""Test request utilities for ASGI callback parser. """Test request utilities for ASGI callback parser.
Provides the same test infrastructure as treq.py but for testing Provides the same test infrastructure as treq.py but for testing
the ASGI PythonProtocol callback parser. the ASGI callback parsers (PythonProtocol and H1CProtocol).
""" """
import importlib.machinery import importlib.machinery
@ -26,6 +26,7 @@ from gunicorn.asgi.parser import (
LimitRequestHeaders, LimitRequestHeaders,
UnsupportedTransferCoding, UnsupportedTransferCoding,
InvalidChunkSize, InvalidChunkSize,
InvalidChunkExtension,
InvalidProxyLine, InvalidProxyLine,
InvalidProxyHeader, InvalidProxyHeader,
) )
@ -34,6 +35,31 @@ from gunicorn.util import split_request_uri
dirname = os.path.dirname(__file__) dirname = os.path.dirname(__file__)
random.seed() random.seed()
# Track if fast parser is available
_gunicorn_h1c = None
def _get_h1c():
"""Lazily import gunicorn_h1c if available."""
global _gunicorn_h1c
if _gunicorn_h1c is None:
try:
import gunicorn_h1c
_gunicorn_h1c = gunicorn_h1c
except ImportError:
_gunicorn_h1c = False
return _gunicorn_h1c if _gunicorn_h1c else None
def get_parser_class(http_parser):
"""Get the appropriate parser class for the test parameter."""
if http_parser == "fast":
h1c = _get_h1c()
if h1c is None:
raise ImportError("gunicorn_h1c required for fast parser tests")
return h1c.H1CProtocol
return PythonProtocol
def uri(data): def uri(data):
ret = {"raw": data} ret = {"raw": data}
@ -47,15 +73,22 @@ def uri(data):
return ret return ret
def load_py(fname): def load_py(fname, http_parser='python'):
"""Load test configuration from Python file.""" """Load test configuration from Python file.
Args:
fname: Path to the Python configuration file
http_parser: Parser to use - 'python' or 'fast'
"""
module_name = '__config__' module_name = '__config__'
mod = types.ModuleType(module_name) mod = types.ModuleType(module_name)
setattr(mod, 'uri', uri) setattr(mod, 'uri', uri)
setattr(mod, 'cfg', Config()) setattr(mod, 'cfg', Config())
loader = importlib.machinery.SourceFileLoader(module_name, fname) loader = importlib.machinery.SourceFileLoader(module_name, fname)
loader.exec_module(mod) loader.exec_module(mod)
return vars(mod) result = vars(mod)
result['http_parser'] = http_parser
return result
def decode_hex_escapes(data): def decode_hex_escapes(data):
@ -88,18 +121,39 @@ EXCEPTION_MAP = {
'LimitRequestHeaders': (LimitRequestHeaders, ParseError), 'LimitRequestHeaders': (LimitRequestHeaders, ParseError),
'UnsupportedTransferCoding': (UnsupportedTransferCoding, ParseError), 'UnsupportedTransferCoding': (UnsupportedTransferCoding, ParseError),
'InvalidChunkSize': (InvalidChunkSize, ParseError), 'InvalidChunkSize': (InvalidChunkSize, ParseError),
'InvalidChunkExtension': (InvalidChunkExtension, ParseError),
'InvalidProxyLine': (InvalidProxyLine, ParseError), 'InvalidProxyLine': (InvalidProxyLine, ParseError),
'InvalidProxyHeader': (InvalidProxyHeader, ParseError), 'InvalidProxyHeader': (InvalidProxyHeader, ParseError),
} }
def map_exception(wsgi_exc): def map_exception(wsgi_exc, http_parser='python'):
"""Map a WSGI exception class to equivalent ASGI parser exceptions.""" """Map a WSGI exception class to equivalent ASGI parser exceptions.
Args:
wsgi_exc: The expected WSGI exception class
http_parser: Parser being used - 'python' or 'fast'
Returns:
Tuple of acceptable exception classes
"""
exc_name = wsgi_exc.__name__ exc_name = wsgi_exc.__name__
if exc_name in EXCEPTION_MAP: base_exceptions = EXCEPTION_MAP.get(exc_name, (ParseError,))
return EXCEPTION_MAP[exc_name]
# For other exceptions, accept any ParseError # For fast parser, also accept gunicorn_h1c exceptions
return (ParseError,) if http_parser == 'fast':
h1c = _get_h1c()
if h1c is not None:
h1c_exceptions = []
# Check for matching exception in gunicorn_h1c
if hasattr(h1c, exc_name):
h1c_exceptions.append(getattr(h1c, exc_name))
# Always accept generic ParseError from h1c
if hasattr(h1c, 'ParseError'):
h1c_exceptions.append(h1c.ParseError)
return base_exceptions + tuple(h1c_exceptions)
return base_exceptions
class request: class request:
@ -229,24 +283,58 @@ class badrequest:
yield self.data[read:read+chunk] yield self.data[read:read+chunk]
read += chunk read += chunk
def check(self, cfg, expected_exc): def check(self, cfg, expected_exc, http_parser='python'):
"""Verify parser raises expected exception.""" """Verify parser raises expected exception.
Args:
cfg: Gunicorn config object
expected_exc: Expected WSGI exception class
http_parser: Parser to use - 'python' or 'fast'
"""
parser_class = get_parser_class(http_parser)
# Handle limit_request_field_size=0 meaning "use default" # Handle limit_request_field_size=0 meaning "use default"
field_size = cfg.limit_request_field_size field_size = cfg.limit_request_field_size
if field_size <= 0: if field_size <= 0:
field_size = 8190 # Default max field_size = 8190 # Default max
parser = PythonProtocol( # Fast parser (H1CProtocol) has different constructor signature
limit_request_line=cfg.limit_request_line, if http_parser == 'fast':
limit_request_fields=cfg.limit_request_fields, parser = parser_class(
limit_request_field_size=field_size, limit_request_line=cfg.limit_request_line,
permit_unconventional_http_method=cfg.permit_unconventional_http_method, limit_request_fields=cfg.limit_request_fields,
permit_unconventional_http_version=cfg.permit_unconventional_http_version, limit_request_field_size=field_size,
proxy_protocol=getattr(cfg, 'proxy_protocol', 'off'), )
) else:
parser = parser_class(
limit_request_line=cfg.limit_request_line,
limit_request_fields=cfg.limit_request_fields,
limit_request_field_size=field_size,
permit_unconventional_http_method=cfg.permit_unconventional_http_method,
permit_unconventional_http_version=cfg.permit_unconventional_http_version,
proxy_protocol=getattr(cfg, 'proxy_protocol', 'off'),
)
# Get acceptable exception types # Get acceptable exception types (includes h1c exceptions for fast parser)
acceptable = map_exception(expected_exc) acceptable = list(map_exception(expected_exc, http_parser))
# Always accept ParseError from python parser
if ParseError not in acceptable:
acceptable.append(ParseError)
# For fast parser, also catch gunicorn_h1c exceptions
if http_parser == 'fast':
h1c = _get_h1c()
if h1c:
# gunicorn_h1c has two ParseError classes in different modules
if hasattr(h1c, 'ParseError'):
acceptable.append(h1c.ParseError)
if hasattr(h1c, '_protocol') and hasattr(h1c._protocol, 'ParseError'):
acceptable.append(h1c._protocol.ParseError)
if hasattr(h1c, 'IncompleteError'):
acceptable.append(h1c.IncompleteError)
acceptable = tuple(acceptable)
raised = False raised = False
try: try:
@ -259,9 +347,6 @@ class badrequest:
raised = True raised = True
except acceptable: except acceptable:
raised = True raised = True
except ParseError:
# Accept any ParseError as valid rejection
raised = True
if not raised: if not raised:
raise AssertionError( raise AssertionError(