From 9b2a3b6e28be6b3818bba3de6676c5a7c4aea966 Mon Sep 17 00:00:00 2001 From: Apoorv Darshan Date: Thu, 9 Jul 2026 22:08:08 +0530 Subject: [PATCH] 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`