Integrate gunicorn_h1c 0.6.3 with InvalidChunkExtension support

Update to gunicorn_h1c >= 0.6.3 which adds InvalidChunkExtension
validation for rejecting chunk extensions with bare CR bytes per
RFC 9112.

Changes:
- Update pyproject.toml to require gunicorn_h1c >= 0.6.3
- Add InvalidChunkExtension exception to gunicorn/asgi/parser.py
- Handle InvalidChunkExtension from both Python and C parsers in protocol.py
- Add chunk extension validation tests
- Update treq.py badrequest class to support hex escapes
This commit is contained in:
Benoit Chesneau 2026-03-26 15:31:24 +01:00
parent bdb2ebd5a4
commit b00f125755
7 changed files with 140 additions and 13 deletions

View File

@ -86,6 +86,10 @@ class InvalidChunkSize(ParseError):
"""Invalid chunk size in chunked transfer encoding."""
class InvalidChunkExtension(ParseError):
"""Invalid chunk extension per RFC 9112."""
class PythonProtocol:
"""Callback-based HTTP/1.1 parser (pure Python fallback).
@ -673,7 +677,7 @@ class PythonProtocol:
# RFC 9112: chunk-ext must not contain bare CR
chunk_ext = size_line[semicolon + 1:]
if b'\r' in chunk_ext:
raise ParseError("Invalid chunk extension: bare CR not allowed")
raise InvalidChunkExtension("bare CR not allowed")
size_line = size_line[:semicolon]
# Strict validation: reject leading/trailing whitespace

View File

@ -17,7 +17,7 @@ import time
from gunicorn.asgi.unreader import AsyncUnreader
from gunicorn.asgi.parser import (
PythonProtocol, CallbackRequest, ParseError,
LimitRequestLine, LimitRequestHeaders
LimitRequestLine, LimitRequestHeaders, InvalidChunkExtension
)
from gunicorn.asgi.uwsgi import AsyncUWSGIRequest
from gunicorn.http.errors import NoMoreData
@ -283,6 +283,7 @@ class ASGIProtocol(asyncio.Protocol):
_h1c_available = None
_h1c_protocol_class = None
_h1c_has_limits = False # True if >= 0.4.1 (has limit parameters)
_h1c_invalid_chunk_extension = None # Exception class from gunicorn_h1c >= 0.6.3
def __init__(self, worker):
self.worker = worker
@ -363,6 +364,10 @@ class ASGIProtocol(asyncio.Protocol):
cls._h1c_protocol_class = H1CProtocol
# Require >= 0.4.1 for limit enforcement
cls._h1c_has_limits = hasattr(gunicorn_h1c, 'LimitRequestLine')
# Check for InvalidChunkExtension (>= 0.6.3)
cls._h1c_invalid_chunk_extension = getattr(
gunicorn_h1c, 'InvalidChunkExtension', None
)
except ImportError:
cls._h1c_available = False
cls._h1c_has_limits = False
@ -474,10 +479,22 @@ class ASGIProtocol(asyncio.Protocol):
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
if h1c_exc 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
if not self._reading_paused and self._is_buffer_full():

View File

@ -53,11 +53,7 @@ tornado = ["tornado>=6.5.0"]
gthread = []
setproctitle = ["setproctitle"]
http2 = ["h2>=4.1.0"]
fast = ["gunicorn_h1c>=0.6.2"]
fast = ["gunicorn_h1c>=0.6.3"]
testing = [
"gevent>=24.10.1",
"eventlet>=0.40.3",

View File

@ -0,0 +1,7 @@
POST /chunked_bare_cr_in_extension HTTP/1.1\r\n
Transfer-Encoding: chunked\r\n
\r\n
5;ext=val\x0Due\r\n
hello\r\n
0\r\n
\r\n

View File

@ -0,0 +1,6 @@
#
# This file is part of gunicorn released under the MIT license.
# See the NOTICE for more information.
from gunicorn.http.errors import InvalidChunkExtension
request = InvalidChunkExtension

View File

@ -516,3 +516,99 @@ class TestCallbackRequest:
assert request.path == "/hello world"
assert request.raw_path == b"/hello%20world"
class TestChunkExtensionValidation:
"""Test chunk extension validation per RFC 9112."""
def test_valid_chunk_extension(self, http_parser):
"""Valid chunk extensions should be accepted."""
parser_class = get_parser_class(http_parser)
body_chunks = []
parser = parser_class(
on_body=lambda chunk: body_chunks.append(chunk),
)
parser.feed(
b"POST /data HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"Transfer-Encoding: chunked\r\n"
b"\r\n"
b"5;name=value\r\n"
b"Hello\r\n"
b"0\r\n"
b"\r\n"
)
assert b"".join(body_chunks) == b"Hello"
assert parser.is_complete
def test_chunk_extension_with_quoted_string(self, http_parser):
"""Chunk extensions with quoted values should be accepted."""
parser_class = get_parser_class(http_parser)
body_chunks = []
parser = parser_class(
on_body=lambda chunk: body_chunks.append(chunk),
)
parser.feed(
b"POST /data HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"Transfer-Encoding: chunked\r\n"
b"\r\n"
b'5;name="quoted value"\r\n'
b"Hello\r\n"
b"0\r\n"
b"\r\n"
)
assert b"".join(body_chunks) == b"Hello"
assert parser.is_complete
def test_chunk_extension_bare_cr_rejected(self, http_parser):
"""Chunk extensions with bare CR should be rejected per RFC 9112."""
import pytest
from gunicorn.asgi.parser import InvalidChunkExtension
parser_class = get_parser_class(http_parser)
# Build the exception types to catch
exceptions_to_catch = [InvalidChunkExtension]
if http_parser == "fast":
import gunicorn_h1c
if hasattr(gunicorn_h1c, 'InvalidChunkExtension'):
exceptions_to_catch.append(gunicorn_h1c.InvalidChunkExtension)
parser = parser_class()
parser.feed(
b"POST /data HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"Transfer-Encoding: chunked\r\n"
b"\r\n"
)
# Chunk extension with bare CR (not followed by LF)
with pytest.raises(tuple(exceptions_to_catch)):
parser.feed(b"5;ext=val\rue\r\nHello\r\n0\r\n\r\n")
def test_multiple_chunk_extensions(self, http_parser):
"""Multiple chunk extensions should be accepted."""
parser_class = get_parser_class(http_parser)
body_chunks = []
parser = parser_class(
on_body=lambda chunk: body_chunks.append(chunk),
)
parser.feed(
b"POST /data HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"Transfer-Encoding: chunked\r\n"
b"\r\n"
b"5;a=1;b=2;c=3\r\n"
b"Hello\r\n"
b"0\r\n"
b"\r\n"
)
assert b"".join(body_chunks) == b"Hello"
assert parser.is_complete

View File

@ -306,13 +306,14 @@ class badrequest:
self.fname = fname
self.name = os.path.basename(fname)
with open(self.fname) as handle:
with open(self.fname, 'rb') as handle:
self.data = handle.read()
self.data = self.data.replace("\n", "").replace("\\r\\n", "\r\n")
self.data = self.data.replace("\\0", "\000").replace("\\n", "\n").replace("\\t", "\t")
if "\\" in self.data:
raise AssertionError("Unexpected backslash in test data - only handling HTAB, NUL and CRLF")
self.data = self.data.encode('latin1')
self.data = self.data.replace(b"\n", b"").replace(b"\\r\\n", b"\r\n")
self.data = self.data.replace(b"\\0", b"\000").replace(b"\\n", b"\n").replace(b"\\t", b"\t")
# Handle hex escape sequences for binary data (e.g., \x0D for bare CR)
self.data = decode_hex_escapes(self.data)
if b"\\" in self.data:
raise AssertionError("Unexpected backslash in test data - only handling HTAB, NUL, CRLF, and hex escapes")
def send(self):
maxs = round(len(self.data) / 10)