Skip to content

Commit b0ffc58

Browse files
committed
curl_httpclient,http1connection: Prohibit CR and LF in headers
libcurl does not check for CR and LF in headers, making this the application's responsibility. However, Tornado's other HTTP interfaces check for linefeeds so we should do the same here so that switching between the simple and curl http clients does not introduce header injection vulnerabilties. http1connection previously checked only for LF in headers (alone or in a CRLF pair). It now prohibits bare CR as well, following the requirement in RFC 9112.
1 parent 0efa9a4 commit b0ffc58

File tree

3 files changed

+32
-10
lines changed

3 files changed

+32
-10
lines changed

tornado/curl_httpclient.py

+12-8
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import functools
2020
import logging
2121
import pycurl
22+
import re
2223
import threading
2324
import time
2425
from io import BytesIO
@@ -44,6 +45,8 @@
4445

4546
curl_log = logging.getLogger("tornado.curl_httpclient")
4647

48+
CR_OR_LF_RE = re.compile(b"\r|\n")
49+
4750

4851
class CurlAsyncHTTPClient(AsyncHTTPClient):
4952
def initialize( # type: ignore
@@ -347,14 +350,15 @@ def _curl_setup_request(
347350
if "Pragma" not in request.headers:
348351
request.headers["Pragma"] = ""
349352

350-
curl.setopt(
351-
pycurl.HTTPHEADER,
352-
[
353-
b"%s: %s"
354-
% (native_str(k).encode("ASCII"), native_str(v).encode("ISO8859-1"))
355-
for k, v in request.headers.get_all()
356-
],
357-
)
353+
encoded_headers = [
354+
b"%s: %s"
355+
% (native_str(k).encode("ASCII"), native_str(v).encode("ISO8859-1"))
356+
for k, v in request.headers.get_all()
357+
]
358+
for line in encoded_headers:
359+
if CR_OR_LF_RE.search(line):
360+
raise ValueError("Illegal characters in header (CR or LF): %r" % line)
361+
curl.setopt(pycurl.HTTPHEADER, encoded_headers)
358362

359363
curl.setopt(
360364
pycurl.HEADERFUNCTION,

tornado/http1connection.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838

3939
from typing import cast, Optional, Type, Awaitable, Callable, Union, Tuple
4040

41+
CR_OR_LF_RE = re.compile(b"\r|\n")
42+
4143

4244
class _QuietException(Exception):
4345
def __init__(self) -> None:
@@ -453,8 +455,8 @@ def write_headers(
453455
)
454456
lines.extend(line.encode("latin1") for line in header_lines)
455457
for line in lines:
456-
if b"\n" in line:
457-
raise ValueError("Newline in header: " + repr(line))
458+
if CR_OR_LF_RE.search(line):
459+
raise ValueError("Illegal characters (CR or LF) in header: %r" % line)
458460
future = None
459461
if self.stream.closed():
460462
future = self._write_future = Future()

tornado/test/httpclient_test.py

+16
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,22 @@ def test_error_after_cancel(self):
725725
if el.logged_stack:
726726
break
727727

728+
def test_header_crlf(self):
729+
# Ensure that the client doesn't allow CRLF injection in headers. RFC 9112 section 2.2
730+
# prohibits a bare CR specifically and "a recipient MAY recognize a single LF as a line
731+
# terminator" so we check each character separately as well as the (redundant) CRLF pair.
732+
for header, name in [
733+
("foo\rbar:", "cr"),
734+
("foo\nbar:", "lf"),
735+
("foo\r\nbar:", "crlf"),
736+
]:
737+
with self.subTest(name=name, position="value"):
738+
with self.assertRaises(ValueError):
739+
self.fetch("/hello", headers={"foo": header})
740+
with self.subTest(name=name, position="key"):
741+
with self.assertRaises(ValueError):
742+
self.fetch("/hello", headers={header: "foo"})
743+
728744

729745
class RequestProxyTest(unittest.TestCase):
730746
def test_request_set(self):

0 commit comments

Comments
 (0)