diff --git a/gunicorn/asgi/parser.py b/gunicorn/asgi/parser.py index 63a34fdc..716d70bd 100644 --- a/gunicorn/asgi/parser.py +++ b/gunicorn/asgi/parser.py @@ -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 diff --git a/gunicorn/asgi/protocol.py b/gunicorn/asgi/protocol.py index ce06f0de..ac38d155 100644 --- a/gunicorn/asgi/protocol.py +++ b/gunicorn/asgi/protocol.py @@ -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(): diff --git a/pyproject.toml b/pyproject.toml index 07ae3e41..e43639f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/requests/invalid/chunked_14.http b/tests/requests/invalid/chunked_14.http new file mode 100644 index 00000000..0484f486 --- /dev/null +++ b/tests/requests/invalid/chunked_14.http @@ -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 diff --git a/tests/requests/invalid/chunked_14.py b/tests/requests/invalid/chunked_14.py new file mode 100644 index 00000000..d96ef88c --- /dev/null +++ b/tests/requests/invalid/chunked_14.py @@ -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 diff --git a/tests/test_asgi_callback_parser.py b/tests/test_asgi_callback_parser.py index 7bba3ea2..a3df55ec 100644 --- a/tests/test_asgi_callback_parser.py +++ b/tests/test_asgi_callback_parser.py @@ -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 diff --git a/tests/treq.py b/tests/treq.py index 15148809..04a0b97c 100644 --- a/tests/treq.py +++ b/tests/treq.py @@ -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)