Skip to content

Commit e4d6984

Browse files
authored
Merge pull request #3307 from bdarnell/branch6.3
Version 6.3.3
2 parents e3aa6c5 + 6a9e6fb commit e4d6984

File tree

6 files changed

+134
-21
lines changed

6 files changed

+134
-21
lines changed

.github/workflows/test.yml

+3-2
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,9 @@ jobs:
5151
tox_env: py311-full
5252
- python: '3.11.0'
5353
tox_env: py311-full
54-
- python: '3.12.0-alpha - 3.12'
55-
tox_env: py312-full
54+
# py312 testing is disabled in branch6.3; full support is coming in tornado 6.4
55+
#- python: '3.12.0-alpha - 3.12'
56+
# tox_env: py312-full
5657
- python: 'pypy-3.8'
5758
# Pypy is a lot slower due to jit warmup costs, so don't run the
5859
# "full" test config there.

docs/releases.rst

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Release notes
44
.. toctree::
55
:maxdepth: 2
66

7+
releases/v6.3.3
78
releases/v6.3.2
89
releases/v6.3.1
910
releases/v6.3.0

docs/releases/v6.3.3.rst

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
What's new in Tornado 6.3.3
2+
===========================
3+
4+
Aug 11, 2023
5+
------------
6+
7+
Security improvements
8+
~~~~~~~~~~~~~~~~~~~~~
9+
10+
- The ``Content-Length`` header and ``chunked`` ``Transfer-Encoding`` sizes are now parsed
11+
more strictly (according to the relevant RFCs) to avoid potential request-smuggling
12+
vulnerabilities when deployed behind certain proxies.

tornado/__init__.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@
2222
# is zero for an official release, positive for a development branch,
2323
# or negative for a release candidate or beta (after the base version
2424
# number has been incremented)
25-
version = "6.3.2"
26-
version_info = (6, 3, 2, 0)
25+
version = "6.3.3"
26+
version_info = (6, 3, 3, 0)
2727

2828
import importlib
2929
import typing

tornado/http1connection.py

+24-3
Original file line numberDiff line numberDiff line change
@@ -442,7 +442,7 @@ def write_headers(
442442
):
443443
self._expected_content_remaining = 0
444444
elif "Content-Length" in headers:
445-
self._expected_content_remaining = int(headers["Content-Length"])
445+
self._expected_content_remaining = parse_int(headers["Content-Length"])
446446
else:
447447
self._expected_content_remaining = None
448448
# TODO: headers are supposed to be of type str, but we still have some
@@ -618,7 +618,7 @@ def _read_body(
618618
headers["Content-Length"] = pieces[0]
619619

620620
try:
621-
content_length = int(headers["Content-Length"]) # type: Optional[int]
621+
content_length: Optional[int] = parse_int(headers["Content-Length"])
622622
except ValueError:
623623
# Handles non-integer Content-Length value.
624624
raise httputil.HTTPInputError(
@@ -668,7 +668,10 @@ async def _read_chunked_body(self, delegate: httputil.HTTPMessageDelegate) -> No
668668
total_size = 0
669669
while True:
670670
chunk_len_str = await self.stream.read_until(b"\r\n", max_bytes=64)
671-
chunk_len = int(chunk_len_str.strip(), 16)
671+
try:
672+
chunk_len = parse_hex_int(native_str(chunk_len_str[:-2]))
673+
except ValueError:
674+
raise httputil.HTTPInputError("invalid chunk size")
672675
if chunk_len == 0:
673676
crlf = await self.stream.read_bytes(2)
674677
if crlf != b"\r\n":
@@ -842,3 +845,21 @@ async def _server_request_loop(
842845
await asyncio.sleep(0)
843846
finally:
844847
delegate.on_close(self)
848+
849+
850+
DIGITS = re.compile(r"[0-9]+")
851+
HEXDIGITS = re.compile(r"[0-9a-fA-F]+")
852+
853+
854+
def parse_int(s: str) -> int:
855+
"""Parse a non-negative integer from a string."""
856+
if DIGITS.fullmatch(s) is None:
857+
raise ValueError("not an integer: %r" % s)
858+
return int(s)
859+
860+
861+
def parse_hex_int(s: str) -> int:
862+
"""Parse a non-negative hexadecimal integer from a string."""
863+
if HEXDIGITS.fullmatch(s) is None:
864+
raise ValueError("not a hexadecimal integer: %r" % s)
865+
return int(s, 16)

tornado/test/httpserver_test.py

+92-14
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
)
1919
from tornado.iostream import IOStream
2020
from tornado.locks import Event
21-
from tornado.log import gen_log
21+
from tornado.log import gen_log, app_log
2222
from tornado.netutil import ssl_options_to_context
2323
from tornado.simple_httpclient import SimpleAsyncHTTPClient
2424
from tornado.testing import (
@@ -41,6 +41,7 @@
4141
import ssl
4242
import sys
4343
import tempfile
44+
import textwrap
4445
import unittest
4546
import urllib.parse
4647
from io import BytesIO
@@ -118,7 +119,7 @@ class SSLTestMixin(object):
118119
def get_ssl_options(self):
119120
return dict(
120121
ssl_version=self.get_ssl_version(),
121-
**AsyncHTTPSTestCase.default_ssl_options()
122+
**AsyncHTTPSTestCase.default_ssl_options(),
122123
)
123124

124125
def get_ssl_version(self):
@@ -558,23 +559,60 @@ def test_chunked_request_uppercase(self):
558559
)
559560
self.assertEqual(json_decode(response), {"foo": ["bar"]})
560561

561-
@gen_test
562-
def test_invalid_content_length(self):
563-
with ExpectLog(
564-
gen_log, ".*Only integer Content-Length is allowed", level=logging.INFO
565-
):
566-
self.stream.write(
567-
b"""\
562+
def test_chunked_request_body_invalid_size(self):
563+
# Only hex digits are allowed in chunk sizes. Python's int() function
564+
# also accepts underscores, so make sure we reject them here.
565+
self.stream.write(
566+
b"""\
568567
POST /echo HTTP/1.1
569-
Content-Length: foo
568+
Transfer-Encoding: chunked
570569
571-
bar
570+
1_a
571+
1234567890abcdef1234567890
572+
0
572573
573574
""".replace(
574-
b"\n", b"\r\n"
575-
)
575+
b"\n", b"\r\n"
576+
)
577+
)
578+
with ExpectLog(gen_log, ".*invalid chunk size", level=logging.INFO):
579+
start_line, headers, response = self.io_loop.run_sync(
580+
lambda: read_stream_body(self.stream)
576581
)
577-
yield self.stream.read_until_close()
582+
self.assertEqual(400, start_line.code)
583+
584+
@gen_test
585+
def test_invalid_content_length(self):
586+
# HTTP only allows decimal digits in content-length. Make sure we don't
587+
# accept anything else, with special attention to things accepted by the
588+
# python int() function (leading plus signs and internal underscores).
589+
test_cases = [
590+
("alphabetic", "foo"),
591+
("leading plus", "+10"),
592+
("internal underscore", "1_0"),
593+
]
594+
for name, value in test_cases:
595+
with self.subTest(name=name), closing(IOStream(socket.socket())) as stream:
596+
with ExpectLog(
597+
gen_log,
598+
".*Only integer Content-Length is allowed",
599+
level=logging.INFO,
600+
):
601+
yield stream.connect(("127.0.0.1", self.get_http_port()))
602+
stream.write(
603+
utf8(
604+
textwrap.dedent(
605+
f"""\
606+
POST /echo HTTP/1.1
607+
Content-Length: {value}
608+
Connection: close
609+
610+
1234567890
611+
"""
612+
).replace("\n", "\r\n")
613+
)
614+
)
615+
yield stream.read_until_close()
578616

579617

580618
class XHeaderTest(HandlerBaseTestCase):
@@ -1123,6 +1161,46 @@ def body_producer(write):
11231161
)
11241162

11251163

1164+
class InvalidOutputContentLengthTest(AsyncHTTPTestCase):
1165+
class MessageDelegate(HTTPMessageDelegate):
1166+
def __init__(self, connection):
1167+
self.connection = connection
1168+
1169+
def headers_received(self, start_line, headers):
1170+
content_lengths = {
1171+
"normal": "10",
1172+
"alphabetic": "foo",
1173+
"leading plus": "+10",
1174+
"underscore": "1_0",
1175+
}
1176+
self.connection.write_headers(
1177+
ResponseStartLine("HTTP/1.1", 200, "OK"),
1178+
HTTPHeaders({"Content-Length": content_lengths[headers["x-test"]]}),
1179+
)
1180+
self.connection.write(b"1234567890")
1181+
self.connection.finish()
1182+
1183+
def get_app(self):
1184+
class App(HTTPServerConnectionDelegate):
1185+
def start_request(self, server_conn, request_conn):
1186+
return InvalidOutputContentLengthTest.MessageDelegate(request_conn)
1187+
1188+
return App()
1189+
1190+
def test_invalid_output_content_length(self):
1191+
with self.subTest("normal"):
1192+
response = self.fetch("/", method="GET", headers={"x-test": "normal"})
1193+
response.rethrow()
1194+
self.assertEqual(response.body, b"1234567890")
1195+
for test in ["alphabetic", "leading plus", "underscore"]:
1196+
with self.subTest(test):
1197+
# This log matching could be tighter but I think I'm already
1198+
# over-testing here.
1199+
with ExpectLog(app_log, "Uncaught exception"):
1200+
with self.assertRaises(HTTPError):
1201+
self.fetch("/", method="GET", headers={"x-test": test})
1202+
1203+
11261204
class MaxHeaderSizeTest(AsyncHTTPTestCase):
11271205
def get_app(self):
11281206
return Application([("/", HelloWorldRequestHandler)])

0 commit comments

Comments
 (0)