From 381aaef8fe9771c5c21c93e750cb2d85ff7a0e02 Mon Sep 17 00:00:00 2001 From: Elvis Pranskevichus Date: Wed, 16 Oct 2024 10:33:18 -0700 Subject: [PATCH] Bump bundled llhttp to 9.2.1 CVE-2024-27982 Expose leniency flags via the new `set_dangerous_leniencies` parser method if somebody needs to opt into the old vulnerable behavior. Fixes: #111 --- httptools/parser/cparser.pxd | 11 +++++++ httptools/parser/parser.pyx | 49 ++++++++++++++++++++++++++++- tests/test_parser.py | 61 +++++++++++++++++++++++++++++++++++- vendor/llhttp | 2 +- 4 files changed, 120 insertions(+), 3 deletions(-) diff --git a/httptools/parser/cparser.pxd b/httptools/parser/cparser.pxd index 617f0c1..3281864 100644 --- a/httptools/parser/cparser.pxd +++ b/httptools/parser/cparser.pxd @@ -154,3 +154,14 @@ cdef extern from "llhttp.h": const char* llhttp_method_name(llhttp_method_t method) void llhttp_set_error_reason(llhttp_t* parser, const char* reason); + + void llhttp_set_lenient_headers(llhttp_t* parser, bint enabled); + void llhttp_set_lenient_chunked_length(llhttp_t* parser, bint enabled); + void llhttp_set_lenient_keep_alive(llhttp_t* parser, bint enabled); + void llhttp_set_lenient_transfer_encoding(llhttp_t* parser, bint enabled); + void llhttp_set_lenient_version(llhttp_t* parser, bint enabled); + void llhttp_set_lenient_data_after_close(llhttp_t* parser, bint enabled); + void llhttp_set_lenient_optional_lf_after_cr(llhttp_t* parser, bint enabled); + void llhttp_set_lenient_optional_cr_before_lf(llhttp_t* parser, bint enabled); + void llhttp_set_lenient_optional_crlf_after_chunk(llhttp_t* parser, bint enabled); + void llhttp_set_lenient_spaces_after_chunk_size(llhttp_t* parser, bint enabled); diff --git a/httptools/parser/parser.pyx b/httptools/parser/parser.pyx index 6877aa1..2fa5026 100644 --- a/httptools/parser/parser.pyx +++ b/httptools/parser/parser.pyx @@ -1,6 +1,8 @@ #cython: language_level=3 from __future__ import print_function +from typing import Optional + from cpython.mem cimport PyMem_Malloc, PyMem_Free from cpython cimport PyObject_GetBuffer, PyBuffer_Release, PyBUF_SIMPLE, \ Py_buffer, PyBytes_AsString @@ -144,6 +146,51 @@ cdef class HttpParser: ### Public API ### + def set_dangerous_leniencies( + self, + lenient_headers: Optional[bool] = None, + lenient_chunked_length: Optional[bool] = None, + lenient_keep_alive: Optional[bool] = None, + lenient_transfer_encoding: Optional[bool] = None, + lenient_version: Optional[bool] = None, + lenient_data_after_close: Optional[bool] = None, + lenient_optional_lf_after_cr: Optional[bool] = None, + lenient_optional_cr_before_lf: Optional[bool] = None, + lenient_optional_crlf_after_chunk: Optional[bool] = None, + lenient_spaces_after_chunk_size: Optional[bool] = None, + ): + cdef cparser.llhttp_t* parser = self._cparser + if lenient_headers is not None: + cparser.llhttp_set_lenient_headers( + parser, lenient_headers) + if lenient_chunked_length is not None: + cparser.llhttp_set_lenient_chunked_length( + parser, lenient_chunked_length) + if lenient_keep_alive is not None: + cparser.llhttp_set_lenient_keep_alive( + parser, lenient_keep_alive) + if lenient_transfer_encoding is not None: + cparser.llhttp_set_lenient_transfer_encoding( + parser, lenient_transfer_encoding) + if lenient_version is not None: + cparser.llhttp_set_lenient_version( + parser, lenient_version) + if lenient_data_after_close is not None: + cparser.llhttp_set_lenient_data_after_close( + parser, lenient_data_after_close) + if lenient_optional_lf_after_cr is not None: + cparser.llhttp_set_lenient_optional_lf_after_cr( + parser, lenient_optional_lf_after_cr) + if lenient_optional_cr_before_lf is not None: + cparser.llhttp_set_lenient_optional_cr_before_lf( + parser, lenient_optional_cr_before_lf) + if lenient_optional_crlf_after_chunk is not None: + cparser.llhttp_set_lenient_optional_crlf_after_chunk( + parser, lenient_optional_crlf_after_chunk) + if lenient_spaces_after_chunk_size is not None: + cparser.llhttp_set_lenient_spaces_after_chunk_size( + parser, lenient_spaces_after_chunk_size) + def get_http_version(self): cdef cparser.llhttp_t* parser = self._cparser return '{}.{}'.format(parser.http_major, parser.http_minor) @@ -161,7 +208,7 @@ cdef class HttpParser: cparser.llhttp_errno_t err Py_buffer *buf bint owning_buf = False - char* err_pos + const char* err_pos if PyMemoryView_Check(data): buf = PyMemoryView_GET_BUFFER(data) diff --git a/tests/test_parser.py b/tests/test_parser.py index 7535e1f..86584c3 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -6,6 +6,18 @@ RESPONSE1_HEAD = b'''HTTP/1.1 200 OK Date: Mon, 23 May 2005 22:38:34 GMT +Server: Apache/1.3.3.7 (Unix) (Red-Hat/Linux) +Last-Modified: Wed, 08 Jan 2003 23:11:55 GMT +ETag: "3f80f-1b6-3e1cb03b" +Content-Type: text/html; charset=UTF-8 +Content-Length: 130 +Accept-Ranges: bytes +Connection: close + +'''.replace(b'\n', b'\r\n') + +RESPONSE1_SPACES_IN_HEAD = b'''HTTP/1.1 200 OK +Date: Mon, 23 May 2005 22:38:34 GMT Server: Apache/1.3.3.7 (Unix) (Red-Hat/Linux) Last-Modified: Wed, 08 Jan 2003 23:11:55 GMT @@ -89,7 +101,7 @@ def test_parser_response_1(self): self.assertEqual(len(headers), 8) self.assertEqual(headers.get(b'Connection'), b'close') self.assertEqual(headers.get(b'Content-Type'), - b'text/html; charset=UTF-8') + b'text/html; charset=UTF-8') self.assertFalse(m.on_body.called) p.feed_data(bytearray(RESPONSE1_BODY)) @@ -109,6 +121,53 @@ def test_parser_response_1b(self): 'Expected HTTP/'): p.feed_data(b'12123123') + def test_parser_response_leninent_headers_1(self): + m = mock.Mock() + + headers = {} + m.on_header.side_effect = headers.__setitem__ + + p = httptools.HttpResponseParser(m) + + with self.assertRaisesRegex( + httptools.HttpParserError, + "whitespace after header value", + ): + p.feed_data(memoryview(RESPONSE1_SPACES_IN_HEAD)) + + def test_parser_response_leninent_headers_2(self): + m = mock.Mock() + + headers = {} + m.on_header.side_effect = headers.__setitem__ + + p = httptools.HttpResponseParser(m) + + p.set_dangerous_leniencies(lenient_headers=True) + p.feed_data(memoryview(RESPONSE1_SPACES_IN_HEAD)) + + self.assertEqual(p.get_http_version(), '1.1') + self.assertEqual(p.get_status_code(), 200) + + m.on_status.assert_called_once_with(b'OK') + + m.on_headers_complete.assert_called_once_with() + self.assertEqual(m.on_header.call_count, 8) + self.assertEqual(len(headers), 8) + self.assertEqual(headers.get(b'Connection'), b'close') + self.assertEqual(headers.get(b'Content-Type'), + b'text/html; charset=UTF-8') + + self.assertFalse(m.on_body.called) + p.feed_data(bytearray(RESPONSE1_BODY)) + m.on_body.assert_called_once_with(RESPONSE1_BODY) + + m.on_message_complete.assert_called_once_with() + + self.assertFalse(m.on_url.called) + self.assertFalse(m.on_chunk_header.called) + self.assertFalse(m.on_chunk_complete.called) + def test_parser_response_2(self): with self.assertRaisesRegex(TypeError, 'a bytes-like object'): httptools.HttpResponseParser(None).feed_data('') diff --git a/vendor/llhttp b/vendor/llhttp index caed04d..610a87d 160000 --- a/vendor/llhttp +++ b/vendor/llhttp @@ -1 +1 @@ -Subproject commit caed04d6c1251e54c642bddfc7d0330af234f0d3 +Subproject commit 610a87d755f6bae466cd871c2ba97574ccac5483