mirror of
https://github.com/frappe/gunicorn.git
synced 2026-07-03 19:21:29 +08:00
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:
parent
bdb2ebd5a4
commit
b00f125755
@ -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
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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",
|
||||
|
||||
7
tests/requests/invalid/chunked_14.http
Normal file
7
tests/requests/invalid/chunked_14.http
Normal 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
|
||||
6
tests/requests/invalid/chunked_14.py
Normal file
6
tests/requests/invalid/chunked_14.py
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user