that doesn't match the data they send.
Split HTTPMessageException into HTTPInputException and HTTPOutputException.
from tornado import gen
from tornado import httputil
from tornado import iostream
-from tornado.log import gen_log
+from tornado.log import gen_log, app_log
from tornado import stack_context
from tornado.util import GzipDecompressor
self._clear_request_state()
self.stream.set_close_callback(self._on_connection_close)
self._finish_future = None
- self._version = None
+ self._request_start_line = None
self._chunking = None
+ self._expected_content_remaining = None
# True if we have read HTTP headers but have not yet read the
# corresponding body.
self._reading = False
except iostream.StreamClosedError:
self.close()
return
+ except Exception:
+ # TODO: this is probably too broad; it would be better to
+ # wrap all delegate calls in something that writes to app_log,
+ # and then errors that reach this point can be gen_log.
+ app_log.error("Uncaught exception", exc_info=True)
+ self.close()
+ return
if not ret:
return
else:
start_line = httputil.parse_request_start_line(start_line)
# It's kind of ugly to set this here, but we need it in
- # write_header() so we know whether we can chunk the response.
- self._version = start_line.version
+ # write_header().
+ self._request_start_line = start_line
self._disconnect_on_finish = not self._can_keep_alive(
start_line, headers)
yield self._finish_future
if self.stream is None:
raise gen.Return(False)
- except httputil.HTTPMessageException as e:
+ except httputil.HTTPInputException as e:
gen_log.info("Malformed HTTP message from %r: %s",
self.address, e)
self.close()
else:
self._chunking = (
has_body and
- # TODO: should this use self._version or start_line.version?
- self._version == 'HTTP/1.1' and
+ # TODO: should this use
+ # self._request_start_line.version or
+ # start_line.version?
+ self._request_start_line.version == 'HTTP/1.1' and
# 304 responses have no body (not even a zero-length body), and so
# should not have either Content-Length or Transfer-Encoding.
# headers.
'Transfer-Encoding' not in headers)
if self._chunking:
headers['Transfer-Encoding'] = 'chunked'
+ if (not self.is_client and
+ (self._request_start_line.method == 'HEAD' or
+ start_line.code == 304)):
+ self._expected_content_remaining = 0
+ elif 'Content-Length' in headers:
+ self._expected_content_remaining = int(headers['Content-Length'])
+ else:
+ self._expected_content_remaining = None
lines = [utf8("%s %s %s" % start_line)]
lines.extend([utf8(n) + b": " + utf8(v) for n, v in headers.get_all()])
for line in lines:
self.stream.write(data, self._on_write_complete)
def _format_chunk(self, chunk):
+ if self._expected_content_remaining is not None:
+ self._expected_content_remaining -= len(chunk)
+ if self._expected_content_remaining < 0:
+ # Close the stream now to stop further framing errors.
+ self.stream.close()
+ raise httputil.HTTPOutputException(
+ "Tried to write more data than Content-Length")
if self._chunking and chunk:
# Don't write out empty chunks because that means END-OF-STREAM
# with chunked encoding
def finish(self):
"""Finishes the request."""
+ if (self._expected_content_remaining is not None and
+ self._expected_content_remaining != 0):
+ self.stream.close()
+ raise httputil.HTTPOutputException(
+ "Tried to write %d bytes less than Content-Length" %
+ self._expected_content_remaining)
if self._chunking:
if not self.stream.closed():
self.stream.write(b"0\r\n\r\n", self._on_write_complete)
headers = httputil.HTTPHeaders.parse(data[eol:])
except ValueError:
# probably form split() if there was no ':' in the line
- raise httputil.HTTPMessageException("Malformed HTTP headers: %r" %
- data[eol:100])
+ raise httputil.HTTPInputException("Malformed HTTP headers: %r" %
+ data[eol:100])
return start_line, headers
def _read_body(self, headers):
if content_length:
content_length = int(content_length)
if content_length > self.stream.max_buffer_size:
- raise httputil.HTTPMessageException("Content-Length too long")
+ raise httputil.HTTPInputException("Content-Length too long")
return self._read_fixed_body(content_length)
if headers.get("Transfer-Encoding") == "chunked":
return self._read_chunked_body()
self.__class__.__name__, args, dict(self.headers))
-class HTTPMessageException(Exception):
- """Exception class for malformed HTTP requests or responses."""
+class HTTPInputException(Exception):
+ """Exception class for malformed HTTP requests or responses
+ from remote sources.
+ """
+ pass
+
+
+class HTTPOutputException(Exception):
+ """Exception class for errors in HTTP output."""
pass
try:
method, path, version = line.split(" ")
except ValueError:
- raise HTTPMessageException("Malformed HTTP request line")
+ raise HTTPInputException("Malformed HTTP request line")
if not version.startswith("HTTP/"):
- raise HTTPMessageException(
+ raise HTTPInputException(
"Malformed HTTP version in HTTP Request-Line: %r" % version)
return RequestStartLine(method, path, version)
class NoContentHandler(RequestHandler):
def get(self):
if self.get_argument("error", None):
- self.set_header("Content-Length", "7")
+ self.set_header("Content-Length", "5")
+ self.write("hello")
self.set_status(204)
response = self.wait()
self.assertEqual(response.body, b"Hello world!")
- def test_multiple_content_length_accepted(self):
+ def xtest_multiple_content_length_accepted(self):
response = self.fetch("/content_length?value=2,2")
self.assertEqual(response.body, b"ok")
response = self.fetch("/content_length?value=2,%202,2")
dict(methods=['prepare', 'data_received',
'data_received', 'data_received',
'post']))
+
+
+@wsgi_safe
+class IncorrectContentLengthTest(SimpleHandlerTestCase):
+ def get_handlers(self):
+ test = self
+ self.server_error = None
+
+ # Manually set a content-length that doesn't match the actual content.
+ class TooHigh(RequestHandler):
+ def get(self):
+ self.set_header("Content-Length", "42")
+ try:
+ self.finish("ok")
+ except Exception as e:
+ test.server_error = e
+ raise
+
+ class TooLow(RequestHandler):
+ def get(self):
+ self.set_header("Content-Length", "2")
+ try:
+ self.finish("hello")
+ except Exception as e:
+ test.server_error = e
+
+ return [('/high', TooHigh),
+ ('/low', TooLow)]
+
+ def test_content_length_too_high(self):
+ # When the content-length is too high, the connection is simply
+ # closed without completing the response. An error is logged on
+ # the server.
+ with ExpectLog(app_log, "Uncaught exception"):
+ with ExpectLog(gen_log,
+ "Cannot send error response after headers written"):
+ response = self.fetch("/high")
+ self.assertEqual(response.code, 599)
+ self.assertEqual(str(self.server_error),
+ "Tried to write 40 bytes less than Content-Length")
+
+ def test_content_length_too_low(self):
+ # When the content-length is too low, the connection is closed
+ # without writing the last chunk, so the client never sees the request
+ # complete (which would be a framing error).
+ with ExpectLog(app_log, "Uncaught exception"):
+ with ExpectLog(gen_log,
+ "Cannot send error response after headers written"):
+ response = self.fetch("/low")
+ self.assertEqual(response.code, 599)
+ self.assertEqual(str(self.server_error),
+ "Tried to write more data than Content-Length")
class _WSGIConnection(object):
- def __init__(self, start_response):
+ def __init__(self, method, start_response):
+ self.method = method
self.start_response = start_response
self._write_buffer = []
self._finished = False
+ self._expected_content_remaining = None
+ self._error = None
def set_close_callback(self, callback):
# WSGI has no facility for detecting a closed connection mid-request,
pass
def write_headers(self, start_line, headers, chunk=None, callback=None):
+ if self.method == 'HEAD':
+ self._expected_content_remaining = 0
+ elif 'Content-Length' in headers:
+ self._expected_content_remaining = int(headers['Content-Length'])
+ else:
+ self._expected_content_remaining = None
self.start_response(
'%s %s' % (start_line.code, start_line.reason),
[(native_str(k), native_str(v)) for (k, v) in headers.get_all()])
callback()
def write(self, chunk, callback=None):
+ if self._expected_content_remaining is not None:
+ self._expected_content_remaining -= len(chunk)
+ if self._expected_content_remaining < 0:
+ self._error = httputil.HTTPOutputException(
+ "Tried to write more data than Content-Length")
+ raise self._error
self._write_buffer.append(chunk)
if callback is not None:
callback()
def finish(self):
+ if (self._expected_content_remaining is not None and
+ self._expected_content_remaining != 0):
+ self._error = httputil.HTTPOutputException(
+ "Tried to write %d bytes less than Content-Length" %
+ self._expected_content_remaining)
+ raise self._error
self._finished = True
host = environ["HTTP_HOST"]
else:
host = environ["SERVER_NAME"]
- connection = _WSGIConnection(start_response)
+ connection = _WSGIConnection(method, start_response)
request = httputil.HTTPServerRequest(
method, uri, "HTTP/1.1",
headers=headers, body=body, remote_ip=remote_ip, protocol=protocol,
host=host, connection=connection)
request._parse_body()
self.application(request)
+ if connection._error:
+ raise connection._error
if not connection._finished:
raise Exception("request did not finish synchronously")
return connection._write_buffer