From 9b2a3b6e28be6b3818bba3de6676c5a7c4aea966 Mon Sep 17 00:00:00 2001 From: Apoorv Darshan Date: Thu, 9 Jul 2026 22:08:08 +0530 Subject: [PATCH 1/2] Reject non-numeric Content-Length header values `int()` silently accepts strings that are not valid `1*DIGIT` values, such as `1_0` (a digit-separating underscore, `int('1_0') == 10`), `+10` (a leading sign) and values with surrounding whitespace. None of these are permitted by the `Content-Length` grammar in RFC 9110 section 8.6, yet cheroot parsed them and used the resulting length, so a request with `Content-Length: 1_0` was accepted instead of being rejected. Validate the raw header value against `[0-9]+` before parsing it with `int()` and respond with `400 Bad Request` when it does not match, reusing the existing malformed-Content-Length response path. Fixes #738 --- cheroot/server.py | 16 +++++++++++++--- cheroot/test/test_conn.py | 17 ++++++++++++++--- docs/changelog-fragments.d/738.bugfix.rst | 6 ++++++ 3 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 docs/changelog-fragments.d/738.bugfix.rst diff --git a/cheroot/server.py b/cheroot/server.py index 284cf17c72..a8ed265784 100644 --- a/cheroot/server.py +++ b/cheroot/server.py @@ -163,6 +163,13 @@ QUOTED_SLASH = b'%2F' QUOTED_SLASH_REGEX = re.compile(b''.join((b'(?i)', QUOTED_SLASH))) +# ``Content-Length`` is defined as ``1*DIGIT`` by +# https://datatracker.ietf.org/doc/html/rfc9110#section-8.6. ``int()`` +# additionally tolerates surrounding whitespace, a leading sign and +# digit-separating underscores (e.g. ``1_0`` -> ``10``), none of which are +# valid, so the raw value has to be validated before it is parsed. +NUMERIC_REGEX = re.compile(rb'[0-9]+') + _STOPPING_FOR_INTERRUPT = Exception() # sentinel used during shutdown @@ -1008,14 +1015,17 @@ def read_request_headers(self): # noqa: C901 # FIXME mrbs = self.server.max_request_body_size - try: - cl = int(self.inheaders.get(b'Content-Length', 0)) - except ValueError: + raw_content_length = self.inheaders.get(b'Content-Length', b'0') + # ``int()`` silently accepts values that are not valid ``1*DIGIT`` + # strings (e.g. ``b'1_0'``, ``b'+10'`` or surrounding whitespace), so + # the raw value has to be checked against the grammar first. + if NUMERIC_REGEX.fullmatch(raw_content_length) is None: self.simple_response( '400 Bad Request', 'Malformed Content-Length Header.', ) return False + cl = int(raw_content_length) if mrbs and cl > mrbs: self.simple_response( diff --git a/cheroot/test/test_conn.py b/cheroot/test/test_conn.py index ff819b2aa9..9ca5954ce3 100644 --- a/cheroot/test/test_conn.py +++ b/cheroot/test/test_conn.py @@ -1437,13 +1437,24 @@ def test_Content_Length_in(test_client): conn.close() -def test_Content_Length_not_int(test_client): - """Test that malicious Content-Length header returns 400.""" +@pytest.mark.parametrize( + 'content_length_value', + ( + 'not-an-integer', + # `int()` accepts these, but they are not valid `1*DIGIT` values + # per :rfc:`9110#section-8.6`, so they must be rejected (#738). + '1_0', # a digit-separating underscore + '+10', # a leading sign + '0x10', # a hexadecimal literal + ), +) +def test_Content_Length_not_int(test_client, content_length_value): + """Test that a malformed Content-Length header returns 400.""" status_line, _actual_headers, actual_resp_body = test_client.post( '/upload', headers=[ ('Content-Type', 'text/plain'), - ('Content-Length', 'not-an-integer'), + ('Content-Length', content_length_value), ], ) actual_status = int(status_line[:3]) diff --git a/docs/changelog-fragments.d/738.bugfix.rst b/docs/changelog-fragments.d/738.bugfix.rst new file mode 100644 index 0000000000..0f85fb2b82 --- /dev/null +++ b/docs/changelog-fragments.d/738.bugfix.rst @@ -0,0 +1,6 @@ +Started rejecting ``Content-Length`` request header values that are not +valid ``1*DIGIT`` strings with a ``400 Bad Request`` response. Previously, +values such as ``1_0`` (a digit-separating underscore) or ``+10`` (a leading +sign) were silently accepted by :py:func:`int` and parsed as ``10``. + +-- by :user:`apoorvdarshan` From 49b1054f08f114e13cfea02aeb26c42e8754d31d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 22 Sep 2026 05:08:47 +0000 Subject: [PATCH 2/2] Fix CI failures for Content-Length validation PR Replace the unresolved :py:func:`int` changelog role with :py:class:`int` so Sphinx nitpicky docs builds, linkcheck, and spellcheck stop treating the missing function target as an error. Accept both OpenSSL 3.1+ HTTPS-over-HTTP error strings in test_https_over_http_error so Python 3.14 on macOS Intel matches the already-merged main-branch assertion. Ignore pyOpenSSL's Context.set_passwd_cb deprecation so Python 3.15 jobs are not failed by filterwarnings=error. Co-authored-by: Apoorv Darshan --- cheroot/test/test_ssl.py | 20 ++++++++++++-------- docs/changelog-fragments.d/738.bugfix.rst | 2 +- pytest.ini | 4 ++++ 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/cheroot/test/test_ssl.py b/cheroot/test/test_ssl.py index 1859e6bc28..b83a81851c 100644 --- a/cheroot/test/test_ssl.py +++ b/cheroot/test/test_ssl.py @@ -697,14 +697,18 @@ def test_https_over_http_error(http_server, ip_addr): http.client.HTTPSConnection( f'{interface}:{port}', ).request('GET', '/') - expected_substring = ( - 'record layer failure' - if IS_ABOVE_OPENSSL31 - else 'wrong version number' - if IS_ABOVE_OPENSSL10 - else 'unknown protocol' - ) - assert expected_substring in ssl_err.value.args[-1] + underlying_error_string = str(ssl_err.value) + if IS_ABOVE_OPENSSL31: + # 'record layer failure' is typical, but Windows and macOS + # yield 'wrong version number' instead. + assert ( + 'record layer failure' in underlying_error_string + or 'wrong version number' in underlying_error_string + ) + elif IS_ABOVE_OPENSSL10: + assert 'wrong version number' in underlying_error_string + else: # pragma: no cover + assert 'unknown protocol' in underlying_error_string def test_http_over_https_no_data(mocker): diff --git a/docs/changelog-fragments.d/738.bugfix.rst b/docs/changelog-fragments.d/738.bugfix.rst index 0f85fb2b82..875b2815d3 100644 --- a/docs/changelog-fragments.d/738.bugfix.rst +++ b/docs/changelog-fragments.d/738.bugfix.rst @@ -1,6 +1,6 @@ Started rejecting ``Content-Length`` request header values that are not valid ``1*DIGIT`` strings with a ``400 Bad Request`` response. Previously, values such as ``1_0`` (a digit-separating underscore) or ``+10`` (a leading -sign) were silently accepted by :py:func:`int` and parsed as ``10``. +sign) were silently accepted by :py:class:`int` and parsed as ``10``. -- by :user:`apoorvdarshan` diff --git a/pytest.ini b/pytest.ini index 8df4cb2ffb..feeec80064 100644 --- a/pytest.ini +++ b/pytest.ini @@ -64,6 +64,10 @@ filterwarnings = # Python 3.14 version of the above ignore:Exception ignored while calling deallocator