Skip to content

Commit

Permalink
wsgi: Don't break HTTP framing during 100-continue handling
Browse files Browse the repository at this point in the history
Expect: 100-continue is a funny beast -- the client sends it to indicate
that it's willing to wait for an early error, but

- the client has no guarantee that the server supports 100 Continue,
- the server gets no indication of how long the client's willing to wait
  for the go/no-go response, and
- even if it did, the server has no way of knowing that the response it
  *emitted* within that time was actually *received* within that time
- so the client may have started sending the body regardless of what the
  server's done.

As a result, the server only has two options when it *does not* send the
100 Continue response:

- close the socket
- read and discard the request body

Previously, we did neither of these things; as a result, a request body
could be interpreted as a new request. Now, close out the connection,
including sending a `Connection: close` header when practical.
  • Loading branch information
tipabu committed Sep 10, 2021
1 parent 0100950 commit bded9f3
Show file tree
Hide file tree
Showing 2 changed files with 105 additions and 10 deletions.
42 changes: 32 additions & 10 deletions eventlet/wsgi.py
Expand Up @@ -134,8 +134,12 @@ def send_hundred_continue_response(self):
# Reinitialize chunk_length (expect more data)
self.chunk_length = -1

@property
def should_send_hundred_continue(self):
return self.wfile is not None and not self.is_hundred_continue_response_sent

def _do_read(self, reader, length=None):
if self.wfile is not None and not self.is_hundred_continue_response_sent:
if self.should_send_hundred_continue:
# 100 Continue response
self.send_hundred_continue_response()
self.is_hundred_continue_response_sent = True
Expand All @@ -152,7 +156,7 @@ def _do_read(self, reader, length=None):
return read

def _chunked_read(self, rfile, length=None, use_readline=False):
if self.wfile is not None and not self.is_hundred_continue_response_sent:
if self.should_send_hundred_continue:
# 100 Continue response
self.send_hundred_continue_response()
self.is_hundred_continue_response_sent = True
Expand Down Expand Up @@ -464,9 +468,11 @@ def handle_one_response(self):
start = time.time()
headers_set = []
headers_sent = []
# Grab the request input now; app may try to replace it in the environ
request_input = self.environ['eventlet.input']
# Push the headers-sent state into the Input so it won't send a
# 100 Continue response if we've already started a response.
self.environ['wsgi.input'].headers_sent = headers_sent
request_input.headers_sent = headers_sent

wfile = self.wfile
result = None
Expand Down Expand Up @@ -563,10 +569,15 @@ def cap(x):
result = self.application(self.environ, start_response)

# Set content-length if possible
if headers_set and \
not headers_sent and hasattr(result, '__len__') and \
'Content-Length' not in [h for h, _v in headers_set[1]]:
headers_set[1].append(('Content-Length', str(sum(map(len, result)))))
if headers_set and not headers_sent and hasattr(result, '__len__'):
# We've got a complete final response
if 'Content-Length' not in [h for h, _v in headers_set[1]]:
headers_set[1].append(('Content-Length', str(sum(map(len, result)))))
if request_input.should_send_hundred_continue:
# We've got a complete final response, and never sent a 100 Continue.
# There's no chance we'll need to read the body as we stream out the
# response, so we can be nice and send a Connection: close header.
self.close_connection = 1

towrite = []
towrite_size = 0
Expand Down Expand Up @@ -607,11 +618,22 @@ def cap(x):
finally:
if hasattr(result, 'close'):
result.close()
request_input = self.environ['eventlet.input']
if request_input.should_send_hundred_continue:
# We just sent the final response, no 100 Continue. Client may or
# may not have started to send a body, and if we keep the connection
# open we've seen clients either
# * send a body, then start a new request
# * skip the body and go straight to a new request
# Looks like the most broadly compatible option is to close the
# connection and let the client retry.
# https://curl.se/mail/lib-2004-08/0002.html
# Note that we likely *won't* send a Connection: close header at this point
self.close_connection = 1

if (request_input.chunked_input or
request_input.position < (request_input.content_length or 0)):
# Read and discard body if there was no pending 100-continue
if not request_input.wfile and self.close_connection == 0:
# Read and discard body if connection is going to be reused
if self.close_connection == 0:
try:
request_input.discard()
except ChunkReadError as e:
Expand Down
73 changes: 73 additions & 0 deletions tests/wsgi_test.py
Expand Up @@ -608,6 +608,54 @@ def test_018_http_10_keepalive(self):
self.assertEqual('keep-alive', result2.headers_lower['connection'])
sock.close()

def test_018b_http_10_keepalive_framing(self):
# verify that if an http/1.0 client sends connection: keep-alive
# that we don't mangle the request framing if the app doesn't read the request
def app(environ, start_response):
resp_body = {
'/1': b'first response',
'/2': b'second response',
'/3': b'third response',
}.get(environ['PATH_INFO'])
if resp_body is None:
resp_body = 'Unexpected path: ' + environ['PATH_INFO']
if six.PY3:
resp_body = resp_body.encode('latin1')
# Never look at wsgi.input!
start_response('200 OK', [('Content-type', 'text/plain')])
return [resp_body]

self.site.application = app
sock = eventlet.connect(self.server_addr)
req_body = b'GET /tricksy HTTP/1.1\r\n'
body_len = str(len(req_body)).encode('ascii')

sock.sendall(b'PUT /1 HTTP/1.0\r\nHost: localhost\r\nConnection: keep-alive\r\n'
b'Content-Length: ' + body_len + b'\r\n\r\n' + req_body)
result1 = read_http(sock)
self.assertEqual(b'first response', result1.body)

sock.sendall(b'PUT /2 HTTP/1.0\r\nHost: localhost\r\nConnection: keep-alive\r\n'
b'Content-Length: ' + body_len + b'\r\nExpect: 100-continue\r\n\r\n')
# Client may have a short timeout waiting on that 100 Continue
# and basically immediately send its body
sock.sendall(req_body)
result2 = read_http(sock)
self.assertEqual(b'second response', result2.body)

sock.sendall(b'PUT /3 HTTP/1.0\r\nHost: localhost\r\nConnection: close\r\n\r\n')
with self.assertRaises(ConnectionClosed):
read_http(sock)
sock.close()

# retry
sock = eventlet.connect(self.server_addr)
sock.sendall(b'PUT /3 HTTP/1.0\r\nHost: localhost\r\nConnection: close\r\n\r\n')
result3 = read_http(sock)
self.assertEqual(b'third response', result3.body)

sock.close()

def test_019_fieldstorage_compat(self):
def use_fieldstorage(environ, start_response):
cgi.FieldStorage(fp=environ['wsgi.input'], environ=environ)
Expand Down Expand Up @@ -756,9 +804,23 @@ def wsgi_app(environ, start_response):
b'Expect: 100-continue\r\n\r\n')
fd.flush()
result = read_http(sock)
# No "100 Continue" -- straight to final response
self.assertEqual(result.status, 'HTTP/1.1 417 Expectation Failed')
self.assertEqual(result.body, b'failure')
self.assertEqual(result.headers_original.get('Connection'), 'close')
# Client may still try to send the body
fd.write(b'x' * 25)
fd.flush()
# But if they keep using this socket, it's going to close on them eventually
fd.write(b'x' * 25)
with self.assertRaises(OSError) as caught:
fd.flush()
self.assertEqual(caught.exception.errno, errno.EPIPE)
sock.close()

sock = eventlet.connect(self.server_addr)
fd = sock.makefile('rwb')
# If we send the "100 Continue", we can pipeline requests through the one connection
for expect_value in ('100-continue', '100-Continue'):
fd.write(
'PUT / HTTP/1.1\r\nHost: localhost\r\nContent-length: 7\r\n'
Expand All @@ -767,6 +829,8 @@ def wsgi_app(environ, start_response):
header_lines = []
while True:
line = fd.readline()
if not line:
raise ConnectionClosed
if line == b'\r\n':
break
else:
Expand All @@ -775,6 +839,8 @@ def wsgi_app(environ, start_response):
header_lines = []
while True:
line = fd.readline()
if not line:
raise ConnectionClosed
if line == b'\r\n':
break
else:
Expand Down Expand Up @@ -806,6 +872,13 @@ def wsgi_app(environ, start_response):
result = read_http(sock)
self.assertEqual(result.status, 'HTTP/1.1 417 Expectation Failed')
self.assertEqual(result.body, b'failure')
# At this point, the client needs to either kill the connection or send the bytes
# because even though the server sent the response without reading the body,
# it has no way of knowing whether the client already started sending or not
sock.close()
sock = eventlet.connect(self.server_addr)
fd = sock.makefile('rwb')

fd.write(
b'PUT / HTTP/1.1\r\nHost: localhost\r\nContent-length: 7\r\n'
b'Expect: 100-continue\r\n\r\ntesting')
Expand Down

0 comments on commit bded9f3

Please sign in to comment.