406 lines
13 KiB
Python
406 lines
13 KiB
Python
|
"""Tests for managing HTTP issues (malformed requests, etc)."""
|
||
|
# -*- coding: utf-8 -*-
|
||
|
# vim: set fileencoding=utf-8 :
|
||
|
|
||
|
from __future__ import absolute_import, division, print_function
|
||
|
__metaclass__ = type
|
||
|
|
||
|
import errno
|
||
|
import socket
|
||
|
|
||
|
import pytest
|
||
|
import six
|
||
|
from six.moves import urllib
|
||
|
|
||
|
from cheroot.test import helper
|
||
|
|
||
|
|
||
|
HTTP_BAD_REQUEST = 400
|
||
|
HTTP_LENGTH_REQUIRED = 411
|
||
|
HTTP_NOT_FOUND = 404
|
||
|
HTTP_OK = 200
|
||
|
HTTP_VERSION_NOT_SUPPORTED = 505
|
||
|
|
||
|
|
||
|
class HelloController(helper.Controller):
|
||
|
"""Controller for serving WSGI apps."""
|
||
|
|
||
|
def hello(req, resp):
|
||
|
"""Render Hello world."""
|
||
|
return 'Hello world!'
|
||
|
|
||
|
def body_required(req, resp):
|
||
|
"""Render Hello world or set 411."""
|
||
|
if req.environ.get('Content-Length', None) is None:
|
||
|
resp.status = '411 Length Required'
|
||
|
return
|
||
|
return 'Hello world!'
|
||
|
|
||
|
def query_string(req, resp):
|
||
|
"""Render QUERY_STRING value."""
|
||
|
return req.environ.get('QUERY_STRING', '')
|
||
|
|
||
|
def asterisk(req, resp):
|
||
|
"""Render request method value."""
|
||
|
method = req.environ.get('REQUEST_METHOD', 'NO METHOD FOUND')
|
||
|
tmpl = 'Got asterisk URI path with {method} method'
|
||
|
return tmpl.format(**locals())
|
||
|
|
||
|
def _munge(string):
|
||
|
"""Encode PATH_INFO correctly depending on Python version.
|
||
|
|
||
|
WSGI 1.0 is a mess around unicode. Create endpoints
|
||
|
that match the PATH_INFO that it produces.
|
||
|
"""
|
||
|
if six.PY3:
|
||
|
return string.encode('utf-8').decode('latin-1')
|
||
|
return string
|
||
|
|
||
|
handlers = {
|
||
|
'/hello': hello,
|
||
|
'/no_body': hello,
|
||
|
'/body_required': body_required,
|
||
|
'/query_string': query_string,
|
||
|
_munge('/привіт'): hello,
|
||
|
_munge('/Юххууу'): hello,
|
||
|
'/\xa0Ðblah key 0 900 4 data': hello,
|
||
|
'/*': asterisk,
|
||
|
}
|
||
|
|
||
|
|
||
|
def _get_http_response(connection, method='GET'):
|
||
|
c = connection
|
||
|
kwargs = {'strict': c.strict} if hasattr(c, 'strict') else {}
|
||
|
# Python 3.2 removed the 'strict' feature, saying:
|
||
|
# "http.client now always assumes HTTP/1.x compliant servers."
|
||
|
return c.response_class(c.sock, method=method, **kwargs)
|
||
|
|
||
|
|
||
|
@pytest.fixture
|
||
|
def testing_server(wsgi_server_client):
|
||
|
"""Attach a WSGI app to the given server and pre-configure it."""
|
||
|
wsgi_server = wsgi_server_client.server_instance
|
||
|
wsgi_server.wsgi_app = HelloController()
|
||
|
wsgi_server.max_request_body_size = 30000000
|
||
|
wsgi_server.server_client = wsgi_server_client
|
||
|
return wsgi_server
|
||
|
|
||
|
|
||
|
@pytest.fixture
|
||
|
def test_client(testing_server):
|
||
|
"""Get and return a test client out of the given server."""
|
||
|
return testing_server.server_client
|
||
|
|
||
|
|
||
|
def test_http_connect_request(test_client):
|
||
|
"""Check that CONNECT query results in Method Not Allowed status."""
|
||
|
status_line = test_client.connect('/anything')[0]
|
||
|
actual_status = int(status_line[:3])
|
||
|
assert actual_status == 405
|
||
|
|
||
|
|
||
|
def test_normal_request(test_client):
|
||
|
"""Check that normal GET query succeeds."""
|
||
|
status_line, _, actual_resp_body = test_client.get('/hello')
|
||
|
actual_status = int(status_line[:3])
|
||
|
assert actual_status == HTTP_OK
|
||
|
assert actual_resp_body == b'Hello world!'
|
||
|
|
||
|
|
||
|
def test_query_string_request(test_client):
|
||
|
"""Check that GET param is parsed well."""
|
||
|
status_line, _, actual_resp_body = test_client.get(
|
||
|
'/query_string?test=True'
|
||
|
)
|
||
|
actual_status = int(status_line[:3])
|
||
|
assert actual_status == HTTP_OK
|
||
|
assert actual_resp_body == b'test=True'
|
||
|
|
||
|
|
||
|
@pytest.mark.parametrize(
|
||
|
'uri',
|
||
|
(
|
||
|
'/hello', # plain
|
||
|
'/query_string?test=True', # query
|
||
|
'/{0}?{1}={2}'.format( # quoted unicode
|
||
|
*map(urllib.parse.quote, ('Юххууу', 'ї', 'йо'))
|
||
|
),
|
||
|
)
|
||
|
)
|
||
|
def test_parse_acceptable_uri(test_client, uri):
|
||
|
"""Check that server responds with OK to valid GET queries."""
|
||
|
status_line = test_client.get(uri)[0]
|
||
|
actual_status = int(status_line[:3])
|
||
|
assert actual_status == HTTP_OK
|
||
|
|
||
|
|
||
|
@pytest.mark.xfail(six.PY2, reason='Fails on Python 2')
|
||
|
def test_parse_uri_unsafe_uri(test_client):
|
||
|
"""Test that malicious URI does not allow HTTP injection.
|
||
|
|
||
|
This effectively checks that sending GET request with URL
|
||
|
|
||
|
/%A0%D0blah%20key%200%20900%204%20data
|
||
|
|
||
|
is not converted into
|
||
|
|
||
|
GET /
|
||
|
blah key 0 900 4 data
|
||
|
HTTP/1.1
|
||
|
|
||
|
which would be a security issue otherwise.
|
||
|
"""
|
||
|
c = test_client.get_connection()
|
||
|
resource = '/\xa0Ðblah key 0 900 4 data'.encode('latin-1')
|
||
|
quoted = urllib.parse.quote(resource)
|
||
|
assert quoted == '/%A0%D0blah%20key%200%20900%204%20data'
|
||
|
request = 'GET {quoted} HTTP/1.1'.format(**locals())
|
||
|
c._output(request.encode('utf-8'))
|
||
|
c._send_output()
|
||
|
response = _get_http_response(c, method='GET')
|
||
|
response.begin()
|
||
|
assert response.status == HTTP_OK
|
||
|
assert response.fp.read(12) == b'Hello world!'
|
||
|
c.close()
|
||
|
|
||
|
|
||
|
def test_parse_uri_invalid_uri(test_client):
|
||
|
"""Check that server responds with Bad Request to invalid GET queries.
|
||
|
|
||
|
Invalid request line test case: it should only contain US-ASCII.
|
||
|
"""
|
||
|
c = test_client.get_connection()
|
||
|
c._output(u'GET /йопта! HTTP/1.1'.encode('utf-8'))
|
||
|
c._send_output()
|
||
|
response = _get_http_response(c, method='GET')
|
||
|
response.begin()
|
||
|
assert response.status == HTTP_BAD_REQUEST
|
||
|
assert response.fp.read(21) == b'Malformed Request-URI'
|
||
|
c.close()
|
||
|
|
||
|
|
||
|
@pytest.mark.parametrize(
|
||
|
'uri',
|
||
|
(
|
||
|
'hello', # ascii
|
||
|
'привіт', # non-ascii
|
||
|
)
|
||
|
)
|
||
|
def test_parse_no_leading_slash_invalid(test_client, uri):
|
||
|
"""Check that server responds with Bad Request to invalid GET queries.
|
||
|
|
||
|
Invalid request line test case: it should have leading slash (be absolute).
|
||
|
"""
|
||
|
status_line, _, actual_resp_body = test_client.get(
|
||
|
urllib.parse.quote(uri)
|
||
|
)
|
||
|
actual_status = int(status_line[:3])
|
||
|
assert actual_status == HTTP_BAD_REQUEST
|
||
|
assert b'starting with a slash' in actual_resp_body
|
||
|
|
||
|
|
||
|
def test_parse_uri_absolute_uri(test_client):
|
||
|
"""Check that server responds with Bad Request to Absolute URI.
|
||
|
|
||
|
Only proxy servers should allow this.
|
||
|
"""
|
||
|
status_line, _, actual_resp_body = test_client.get('http://google.com/')
|
||
|
actual_status = int(status_line[:3])
|
||
|
assert actual_status == HTTP_BAD_REQUEST
|
||
|
expected_body = b'Absolute URI not allowed if server is not a proxy.'
|
||
|
assert actual_resp_body == expected_body
|
||
|
|
||
|
|
||
|
def test_parse_uri_asterisk_uri(test_client):
|
||
|
"""Check that server responds with OK to OPTIONS with "*" Absolute URI."""
|
||
|
status_line, _, actual_resp_body = test_client.options('*')
|
||
|
actual_status = int(status_line[:3])
|
||
|
assert actual_status == HTTP_OK
|
||
|
expected_body = b'Got asterisk URI path with OPTIONS method'
|
||
|
assert actual_resp_body == expected_body
|
||
|
|
||
|
|
||
|
def test_parse_uri_fragment_uri(test_client):
|
||
|
"""Check that server responds with Bad Request to URI with fragment."""
|
||
|
status_line, _, actual_resp_body = test_client.get(
|
||
|
'/hello?test=something#fake',
|
||
|
)
|
||
|
actual_status = int(status_line[:3])
|
||
|
assert actual_status == HTTP_BAD_REQUEST
|
||
|
expected_body = b'Illegal #fragment in Request-URI.'
|
||
|
assert actual_resp_body == expected_body
|
||
|
|
||
|
|
||
|
def test_no_content_length(test_client):
|
||
|
"""Test POST query with an empty body being successful."""
|
||
|
# "The presence of a message-body in a request is signaled by the
|
||
|
# inclusion of a Content-Length or Transfer-Encoding header field in
|
||
|
# the request's message-headers."
|
||
|
#
|
||
|
# Send a message with neither header and no body.
|
||
|
c = test_client.get_connection()
|
||
|
c.request('POST', '/no_body')
|
||
|
response = c.getresponse()
|
||
|
actual_resp_body = response.fp.read()
|
||
|
actual_status = response.status
|
||
|
assert actual_status == HTTP_OK
|
||
|
assert actual_resp_body == b'Hello world!'
|
||
|
|
||
|
|
||
|
def test_content_length_required(test_client):
|
||
|
"""Test POST query with body failing because of missing Content-Length."""
|
||
|
# Now send a message that has no Content-Length, but does send a body.
|
||
|
# Verify that CP times out the socket and responds
|
||
|
# with 411 Length Required.
|
||
|
|
||
|
c = test_client.get_connection()
|
||
|
c.request('POST', '/body_required')
|
||
|
response = c.getresponse()
|
||
|
response.fp.read()
|
||
|
|
||
|
actual_status = response.status
|
||
|
assert actual_status == HTTP_LENGTH_REQUIRED
|
||
|
|
||
|
|
||
|
@pytest.mark.parametrize(
|
||
|
'request_line,status_code,expected_body',
|
||
|
(
|
||
|
(b'GET /', # missing proto
|
||
|
HTTP_BAD_REQUEST, b'Malformed Request-Line'),
|
||
|
(b'GET / HTTPS/1.1', # invalid proto
|
||
|
HTTP_BAD_REQUEST, b'Malformed Request-Line: bad protocol'),
|
||
|
(b'GET / HTTP/2.15', # invalid ver
|
||
|
HTTP_VERSION_NOT_SUPPORTED, b'Cannot fulfill request'),
|
||
|
)
|
||
|
)
|
||
|
def test_malformed_request_line(
|
||
|
test_client, request_line,
|
||
|
status_code, expected_body
|
||
|
):
|
||
|
"""Test missing or invalid HTTP version in Request-Line."""
|
||
|
c = test_client.get_connection()
|
||
|
c._output(request_line)
|
||
|
c._send_output()
|
||
|
response = _get_http_response(c, method='GET')
|
||
|
response.begin()
|
||
|
assert response.status == status_code
|
||
|
assert response.fp.read(len(expected_body)) == expected_body
|
||
|
c.close()
|
||
|
|
||
|
|
||
|
def test_malformed_http_method(test_client):
|
||
|
"""Test non-uppercase HTTP method."""
|
||
|
c = test_client.get_connection()
|
||
|
c.putrequest('GeT', '/malformed_method_case')
|
||
|
c.putheader('Content-Type', 'text/plain')
|
||
|
c.endheaders()
|
||
|
|
||
|
response = c.getresponse()
|
||
|
actual_status = response.status
|
||
|
assert actual_status == HTTP_BAD_REQUEST
|
||
|
actual_resp_body = response.fp.read(21)
|
||
|
assert actual_resp_body == b'Malformed method name'
|
||
|
|
||
|
|
||
|
def test_malformed_header(test_client):
|
||
|
"""Check that broken HTTP header results in Bad Request."""
|
||
|
c = test_client.get_connection()
|
||
|
c.putrequest('GET', '/')
|
||
|
c.putheader('Content-Type', 'text/plain')
|
||
|
# See https://www.bitbucket.org/cherrypy/cherrypy/issue/941
|
||
|
c._output(b'Re, 1.2.3.4#015#012')
|
||
|
c.endheaders()
|
||
|
|
||
|
response = c.getresponse()
|
||
|
actual_status = response.status
|
||
|
assert actual_status == HTTP_BAD_REQUEST
|
||
|
actual_resp_body = response.fp.read(20)
|
||
|
assert actual_resp_body == b'Illegal header line.'
|
||
|
|
||
|
|
||
|
def test_request_line_split_issue_1220(test_client):
|
||
|
"""Check that HTTP request line of exactly 256 chars length is OK."""
|
||
|
Request_URI = (
|
||
|
'/hello?'
|
||
|
'intervenant-entreprise-evenement_classaction='
|
||
|
'evenement-mailremerciements'
|
||
|
'&_path=intervenant-entreprise-evenement'
|
||
|
'&intervenant-entreprise-evenement_action-id=19404'
|
||
|
'&intervenant-entreprise-evenement_id=19404'
|
||
|
'&intervenant-entreprise_id=28092'
|
||
|
)
|
||
|
assert len('GET %s HTTP/1.1\r\n' % Request_URI) == 256
|
||
|
|
||
|
actual_resp_body = test_client.get(Request_URI)[2]
|
||
|
assert actual_resp_body == b'Hello world!'
|
||
|
|
||
|
|
||
|
def test_garbage_in(test_client):
|
||
|
"""Test that server sends an error for garbage received over TCP."""
|
||
|
# Connect without SSL regardless of server.scheme
|
||
|
|
||
|
c = test_client.get_connection()
|
||
|
c._output(b'gjkgjklsgjklsgjkljklsg')
|
||
|
c._send_output()
|
||
|
response = c.response_class(c.sock, method='GET')
|
||
|
try:
|
||
|
response.begin()
|
||
|
actual_status = response.status
|
||
|
assert actual_status == HTTP_BAD_REQUEST
|
||
|
actual_resp_body = response.fp.read(22)
|
||
|
assert actual_resp_body == b'Malformed Request-Line'
|
||
|
c.close()
|
||
|
except socket.error as ex:
|
||
|
# "Connection reset by peer" is also acceptable.
|
||
|
if ex.errno != errno.ECONNRESET:
|
||
|
raise
|
||
|
|
||
|
|
||
|
class CloseController:
|
||
|
"""Controller for testing the close callback."""
|
||
|
|
||
|
def __call__(self, environ, start_response):
|
||
|
"""Get the req to know header sent status."""
|
||
|
self.req = start_response.__self__.req
|
||
|
resp = CloseResponse(self.close)
|
||
|
start_response(resp.status, resp.headers.items())
|
||
|
return resp
|
||
|
|
||
|
def close(self):
|
||
|
"""Close, writing hello."""
|
||
|
self.req.write(b'hello')
|
||
|
|
||
|
|
||
|
class CloseResponse:
|
||
|
"""Dummy empty response to trigger the no body status."""
|
||
|
|
||
|
def __init__(self, close):
|
||
|
"""Use some defaults to ensure we have a header."""
|
||
|
self.status = '200 OK'
|
||
|
self.headers = {'Content-Type': 'text/html'}
|
||
|
self.close = close
|
||
|
|
||
|
def __getitem__(self, index):
|
||
|
"""Ensure we don't have a body."""
|
||
|
raise IndexError()
|
||
|
|
||
|
def output(self):
|
||
|
"""Return self to hook the close method."""
|
||
|
return self
|
||
|
|
||
|
|
||
|
@pytest.fixture
|
||
|
def testing_server_close(wsgi_server_client):
|
||
|
"""Attach a WSGI app to the given server and pre-configure it."""
|
||
|
wsgi_server = wsgi_server_client.server_instance
|
||
|
wsgi_server.wsgi_app = CloseController()
|
||
|
wsgi_server.max_request_body_size = 30000000
|
||
|
wsgi_server.server_client = wsgi_server_client
|
||
|
return wsgi_server
|
||
|
|
||
|
|
||
|
def test_send_header_before_closing(testing_server_close):
|
||
|
"""Test we are actually sending the headers before calling 'close'."""
|
||
|
_, _, resp_body = testing_server_close.server_client.get('/')
|
||
|
assert resp_body == b'hello'
|