diff --git a/docs/content/2026-news.md b/docs/content/2026-news.md index c2aa73eb..8799fc5d 100644 --- a/docs/content/2026-news.md +++ b/docs/content/2026-news.md @@ -15,6 +15,9 @@ - **HTTP/2 Documentation**: Fix `http_protocols` examples to use comma-separated string instead of list syntax ([#3561](https://github.com/benoitc/gunicorn/issues/3561)) +- **Chunked Encoding**: Reject chunk extensions containing bare CR bytes per RFC 9112 + ([#3556](https://github.com/benoitc/gunicorn/discussions/3556)) + ### Security - **ASGI Parser Header Validation**: Add security checks per RFC 9110/9112: diff --git a/docs/content/news.md b/docs/content/news.md index 1505fb85..648f29f5 100644 --- a/docs/content/news.md +++ b/docs/content/news.md @@ -15,6 +15,9 @@ - **HTTP/2 Documentation**: Fix `http_protocols` examples to use comma-separated string instead of list syntax ([#3561](https://github.com/benoitc/gunicorn/issues/3561)) +- **Chunked Encoding**: Reject chunk extensions containing bare CR bytes per RFC 9112 + ([#3556](https://github.com/benoitc/gunicorn/discussions/3556)) + ### Security - **ASGI Parser Header Validation**: Add security checks per RFC 9110/9112: diff --git a/gunicorn/asgi/parser.py b/gunicorn/asgi/parser.py index a1f608a6..63a34fdc 100644 --- a/gunicorn/asgi/parser.py +++ b/gunicorn/asgi/parser.py @@ -670,6 +670,10 @@ class PythonProtocol: # Handle chunk extensions (e.g., "5;ext=value") semicolon = size_line.find(b';') if semicolon != -1: + # 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") size_line = size_line[:semicolon] # Strict validation: reject leading/trailing whitespace diff --git a/gunicorn/http/body.py b/gunicorn/http/body.py index d7ee29e7..e433baee 100644 --- a/gunicorn/http/body.py +++ b/gunicorn/http/body.py @@ -6,7 +6,7 @@ import io import sys from gunicorn.http.errors import (NoMoreData, ChunkMissingTerminator, - InvalidChunkSize) + InvalidChunkSize, InvalidChunkExtension) class ChunkedReader: @@ -90,6 +90,9 @@ class ChunkedReader: # RFC9112 7.1.1: BWS before chunk-ext - but ONLY then chunk_size, *chunk_ext = line.split(b";", 1) if chunk_ext: + # RFC 9112: chunk-ext must not contain bare CR + if b'\r' in chunk_ext[0]: + raise InvalidChunkExtension("bare CR not allowed") chunk_size = chunk_size.rstrip(b" \t") if any(n not in b"0123456789abcdefABCDEF" for n in chunk_size): raise InvalidChunkSize(chunk_size) diff --git a/gunicorn/http/errors.py b/gunicorn/http/errors.py index f3ba534e..f6c7d579 100644 --- a/gunicorn/http/errors.py +++ b/gunicorn/http/errors.py @@ -113,6 +113,16 @@ class ChunkMissingTerminator(IOError): return "Invalid chunk terminator is not '\\r\\n': %r" % self.term +class InvalidChunkExtension(IOError): + """Invalid chunk extension per RFC 9112.""" + + def __init__(self, reason): + self.reason = reason + + def __str__(self): + return "Invalid chunk extension: %s" % self.reason + + class LimitRequestLine(ParseException): def __init__(self, size, max_size=None): self.size = size