Skip to content

Commit ca54b0d

Browse files
authored
Merge pull request #3552 from bdarnell/http-reason
web: Harden against invalid HTTP reason phrases
2 parents 7d869bf + f3b99cd commit ca54b0d

File tree

2 files changed

+33
-7
lines changed

2 files changed

+33
-7
lines changed

tornado/test/web_test.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1769,7 +1769,7 @@ class StatusReasonTest(SimpleHandlerTestCase):
17691769
class Handler(RequestHandler):
17701770
def get(self):
17711771
reason = self.request.arguments.get("reason", [])
1772-
self.set_status(
1772+
raise HTTPError(
17731773
int(self.get_argument("code")),
17741774
reason=to_unicode(reason[0]) if reason else None,
17751775
)
@@ -1792,6 +1792,19 @@ def test_status(self):
17921792
self.assertEqual(response.code, 682)
17931793
self.assertEqual(response.reason, "Unknown")
17941794

1795+
def test_header_injection(self):
1796+
response = self.fetch("/?code=200&reason=OK%0D%0AX-Injection:injected")
1797+
self.assertEqual(response.code, 200)
1798+
self.assertEqual(response.reason, "Unknown")
1799+
self.assertNotIn("X-Injection", response.headers)
1800+
1801+
def test_reason_xss(self):
1802+
response = self.fetch("/?code=400&reason=<script>alert(1)</script>")
1803+
self.assertEqual(response.code, 400)
1804+
self.assertEqual(response.reason, "Unknown")
1805+
self.assertNotIn(b"script", response.body)
1806+
self.assertIn(b"Unknown", response.body)
1807+
17951808

17961809
class DateHeaderTest(SimpleHandlerTestCase):
17971810
class Handler(RequestHandler):

tornado/web.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -359,8 +359,10 @@ def set_status(self, status_code: int, reason: Optional[str] = None) -> None:
359359
360360
:arg int status_code: Response status code.
361361
:arg str reason: Human-readable reason phrase describing the status
362-
code. If ``None``, it will be filled in from
363-
`http.client.responses` or "Unknown".
362+
code (for example, the "Not Found" in ``HTTP/1.1 404 Not Found``).
363+
Normally determined automatically from `http.client.responses`; this
364+
argument should only be used if you need to use a non-standard
365+
status code.
364366
365367
.. versionchanged:: 5.0
366368
@@ -369,6 +371,14 @@ def set_status(self, status_code: int, reason: Optional[str] = None) -> None:
369371
"""
370372
self._status_code = status_code
371373
if reason is not None:
374+
if "<" in reason or not httputil._ABNF.reason_phrase.fullmatch(reason):
375+
# Logically this would be better as an exception, but this method
376+
# is called on error-handling paths that would need some refactoring
377+
# to tolerate internal errors cleanly.
378+
#
379+
# The check for "<" is a defense-in-depth against XSS attacks (we also
380+
# escape the reason when rendering error pages).
381+
reason = "Unknown"
372382
self._reason = escape.native_str(reason)
373383
else:
374384
self._reason = httputil.responses.get(status_code, "Unknown")
@@ -1345,7 +1355,8 @@ def send_error(self, status_code: int = 500, **kwargs: Any) -> None:
13451355
reason = exception.reason
13461356
self.set_status(status_code, reason=reason)
13471357
try:
1348-
self.write_error(status_code, **kwargs)
1358+
if status_code != 304:
1359+
self.write_error(status_code, **kwargs)
13491360
except Exception:
13501361
app_log.error("Uncaught exception in write_error", exc_info=True)
13511362
if not self._finished:
@@ -1373,7 +1384,7 @@ def write_error(self, status_code: int, **kwargs: Any) -> None:
13731384
self.finish(
13741385
"<html><title>%(code)d: %(message)s</title>"
13751386
"<body>%(code)d: %(message)s</body></html>"
1376-
% {"code": status_code, "message": self._reason}
1387+
% {"code": status_code, "message": escape.xhtml_escape(self._reason)}
13771388
)
13781389

13791390
@property
@@ -2520,9 +2531,11 @@ class HTTPError(Exception):
25202531
mode). May contain ``%s``-style placeholders, which will be filled
25212532
in with remaining positional parameters.
25222533
:arg str reason: Keyword-only argument. The HTTP "reason" phrase
2523-
to pass in the status line along with ``status_code``. Normally
2534+
to pass in the status line along with ``status_code`` (for example,
2535+
the "Not Found" in ``HTTP/1.1 404 Not Found``). Normally
25242536
determined automatically from ``status_code``, but can be used
2525-
to use a non-standard numeric code.
2537+
to use a non-standard numeric code. This is not a general-purpose
2538+
error message.
25262539
"""
25272540

25282541
def __init__(

0 commit comments

Comments
 (0)