Skip to content

Commit b00f125

Browse files
committed
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
1 parent bdb2ebd commit b00f125

7 files changed

Lines changed: 140 additions & 13 deletions

File tree

gunicorn/asgi/parser.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ class InvalidChunkSize(ParseError):
8686
"""Invalid chunk size in chunked transfer encoding."""
8787

8888

89+
class InvalidChunkExtension(ParseError):
90+
"""Invalid chunk extension per RFC 9112."""
91+
92+
8993
class PythonProtocol:
9094
"""Callback-based HTTP/1.1 parser (pure Python fallback).
9195
@@ -673,7 +677,7 @@ def _parse_chunked_body(self):
673677
# RFC 9112: chunk-ext must not contain bare CR
674678
chunk_ext = size_line[semicolon + 1:]
675679
if b'\r' in chunk_ext:
676-
raise ParseError("Invalid chunk extension: bare CR not allowed")
680+
raise InvalidChunkExtension("bare CR not allowed")
677681
size_line = size_line[:semicolon]
678682

679683
# Strict validation: reject leading/trailing whitespace

gunicorn/asgi/protocol.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from gunicorn.asgi.unreader import AsyncUnreader
1818
from gunicorn.asgi.parser import (
1919
PythonProtocol, CallbackRequest, ParseError,
20-
LimitRequestLine, LimitRequestHeaders
20+
LimitRequestLine, LimitRequestHeaders, InvalidChunkExtension
2121
)
2222
from gunicorn.asgi.uwsgi import AsyncUWSGIRequest
2323
from gunicorn.http.errors import NoMoreData
@@ -283,6 +283,7 @@ class ASGIProtocol(asyncio.Protocol):
283283
_h1c_available = None
284284
_h1c_protocol_class = None
285285
_h1c_has_limits = False # True if >= 0.4.1 (has limit parameters)
286+
_h1c_invalid_chunk_extension = None # Exception class from gunicorn_h1c >= 0.6.3
286287

287288
def __init__(self, worker):
288289
self.worker = worker
@@ -363,6 +364,10 @@ def _check_h1c_protocol_available(cls):
363364
cls._h1c_protocol_class = H1CProtocol
364365
# Require >= 0.4.1 for limit enforcement
365366
cls._h1c_has_limits = hasattr(gunicorn_h1c, 'LimitRequestLine')
367+
# Check for InvalidChunkExtension (>= 0.6.3)
368+
cls._h1c_invalid_chunk_extension = getattr(
369+
gunicorn_h1c, 'InvalidChunkExtension', None
370+
)
366371
except ImportError:
367372
cls._h1c_available = False
368373
cls._h1c_has_limits = False
@@ -474,10 +479,22 @@ def data_received(self, data):
474479
self._send_error_response(431, str(e)) # Request Header Fields Too Large
475480
self._close_transport()
476481
return
482+
except InvalidChunkExtension as e:
483+
self._send_error_response(400, str(e))
484+
self._close_transport()
485+
return
477486
except ParseError as e:
478487
self._send_error_response(400, str(e))
479488
self._close_transport()
480489
return
490+
except Exception as e:
491+
# Handle gunicorn_h1c exceptions (different class hierarchy)
492+
h1c_exc = ASGIProtocol._h1c_invalid_chunk_extension
493+
if h1c_exc and isinstance(e, h1c_exc):
494+
self._send_error_response(400, str(e))
495+
self._close_transport()
496+
return
497+
raise
481498

482499
# Backpressure: pause reading if buffer is too large
483500
if not self._reading_paused and self._is_buffer_full():

pyproject.toml

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,7 @@ tornado = ["tornado>=6.5.0"]
5353
gthread = []
5454
setproctitle = ["setproctitle"]
5555
http2 = ["h2>=4.1.0"]
56-
57-
58-
59-
fast = ["gunicorn_h1c>=0.6.2"]
60-
56+
fast = ["gunicorn_h1c>=0.6.3"]
6157
testing = [
6258
"gevent>=24.10.1",
6359
"eventlet>=0.40.3",
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
POST /chunked_bare_cr_in_extension HTTP/1.1\r\n
2+
Transfer-Encoding: chunked\r\n
3+
\r\n
4+
5;ext=val\x0Due\r\n
5+
hello\r\n
6+
0\r\n
7+
\r\n
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#
2+
# This file is part of gunicorn released under the MIT license.
3+
# See the NOTICE for more information.
4+
5+
from gunicorn.http.errors import InvalidChunkExtension
6+
request = InvalidChunkExtension

tests/test_asgi_callback_parser.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,3 +516,99 @@ def test_percent_encoded_ascii(self, http_parser):
516516

517517
assert request.path == "/hello world"
518518
assert request.raw_path == b"/hello%20world"
519+
520+
521+
class TestChunkExtensionValidation:
522+
"""Test chunk extension validation per RFC 9112."""
523+
524+
def test_valid_chunk_extension(self, http_parser):
525+
"""Valid chunk extensions should be accepted."""
526+
parser_class = get_parser_class(http_parser)
527+
body_chunks = []
528+
529+
parser = parser_class(
530+
on_body=lambda chunk: body_chunks.append(chunk),
531+
)
532+
parser.feed(
533+
b"POST /data HTTP/1.1\r\n"
534+
b"Host: localhost\r\n"
535+
b"Transfer-Encoding: chunked\r\n"
536+
b"\r\n"
537+
b"5;name=value\r\n"
538+
b"Hello\r\n"
539+
b"0\r\n"
540+
b"\r\n"
541+
)
542+
543+
assert b"".join(body_chunks) == b"Hello"
544+
assert parser.is_complete
545+
546+
def test_chunk_extension_with_quoted_string(self, http_parser):
547+
"""Chunk extensions with quoted values should be accepted."""
548+
parser_class = get_parser_class(http_parser)
549+
body_chunks = []
550+
551+
parser = parser_class(
552+
on_body=lambda chunk: body_chunks.append(chunk),
553+
)
554+
parser.feed(
555+
b"POST /data HTTP/1.1\r\n"
556+
b"Host: localhost\r\n"
557+
b"Transfer-Encoding: chunked\r\n"
558+
b"\r\n"
559+
b'5;name="quoted value"\r\n'
560+
b"Hello\r\n"
561+
b"0\r\n"
562+
b"\r\n"
563+
)
564+
565+
assert b"".join(body_chunks) == b"Hello"
566+
assert parser.is_complete
567+
568+
def test_chunk_extension_bare_cr_rejected(self, http_parser):
569+
"""Chunk extensions with bare CR should be rejected per RFC 9112."""
570+
import pytest
571+
from gunicorn.asgi.parser import InvalidChunkExtension
572+
573+
parser_class = get_parser_class(http_parser)
574+
575+
# Build the exception types to catch
576+
exceptions_to_catch = [InvalidChunkExtension]
577+
if http_parser == "fast":
578+
import gunicorn_h1c
579+
if hasattr(gunicorn_h1c, 'InvalidChunkExtension'):
580+
exceptions_to_catch.append(gunicorn_h1c.InvalidChunkExtension)
581+
582+
parser = parser_class()
583+
parser.feed(
584+
b"POST /data HTTP/1.1\r\n"
585+
b"Host: localhost\r\n"
586+
b"Transfer-Encoding: chunked\r\n"
587+
b"\r\n"
588+
)
589+
590+
# Chunk extension with bare CR (not followed by LF)
591+
with pytest.raises(tuple(exceptions_to_catch)):
592+
parser.feed(b"5;ext=val\rue\r\nHello\r\n0\r\n\r\n")
593+
594+
def test_multiple_chunk_extensions(self, http_parser):
595+
"""Multiple chunk extensions should be accepted."""
596+
parser_class = get_parser_class(http_parser)
597+
body_chunks = []
598+
599+
parser = parser_class(
600+
on_body=lambda chunk: body_chunks.append(chunk),
601+
)
602+
parser.feed(
603+
b"POST /data HTTP/1.1\r\n"
604+
b"Host: localhost\r\n"
605+
b"Transfer-Encoding: chunked\r\n"
606+
b"\r\n"
607+
b"5;a=1;b=2;c=3\r\n"
608+
b"Hello\r\n"
609+
b"0\r\n"
610+
b"\r\n"
611+
)
612+
613+
assert b"".join(body_chunks) == b"Hello"
614+
assert parser.is_complete

tests/treq.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -306,13 +306,14 @@ def __init__(self, fname):
306306
self.fname = fname
307307
self.name = os.path.basename(fname)
308308

309-
with open(self.fname) as handle:
309+
with open(self.fname, 'rb') as handle:
310310
self.data = handle.read()
311-
self.data = self.data.replace("\n", "").replace("\\r\\n", "\r\n")
312-
self.data = self.data.replace("\\0", "\000").replace("\\n", "\n").replace("\\t", "\t")
313-
if "\\" in self.data:
314-
raise AssertionError("Unexpected backslash in test data - only handling HTAB, NUL and CRLF")
315-
self.data = self.data.encode('latin1')
311+
self.data = self.data.replace(b"\n", b"").replace(b"\\r\\n", b"\r\n")
312+
self.data = self.data.replace(b"\\0", b"\000").replace(b"\\n", b"\n").replace(b"\\t", b"\t")
313+
# Handle hex escape sequences for binary data (e.g., \x0D for bare CR)
314+
self.data = decode_hex_escapes(self.data)
315+
if b"\\" in self.data:
316+
raise AssertionError("Unexpected backslash in test data - only handling HTAB, NUL, CRLF, and hex escapes")
316317

317318
def send(self):
318319
maxs = round(len(self.data) / 10)

0 commit comments

Comments
 (0)