From b51d4e9a35692bc78264b56c14556654c07200cd Mon Sep 17 00:00:00 2001 From: Kurt Griffiths Date: Mon, 17 Jul 2017 15:43:06 -0600 Subject: [PATCH] feat(Request): Add support for several proxy "forwarded" headers (#1083) Add support for some "forwarded" headers, included several new attributes and a reworking of some of the existing code to better facilitate sharing and performance. Also clean up a couple tiny nits in the docstrings for the sake of consistency. --- CHANGES.rst | 21 + docs/api/request_and_response.rst | 3 + docs/changes/1.3.0.rst | 20 + docs/changes/index.rst | 1 + falcon/__init__.py | 2 +- falcon/media/base.py | 4 +- falcon/request.py | 375 ++++++++++++++---- falcon/testing/helpers.py | 6 + ..._route.py => test_request_access_route.py} | 0 ...test_req_vars.py => test_request_attrs.py} | 52 ++- tests/test_request_forwarded.py | 122 ++++++ tox.ini | 12 +- 12 files changed, 535 insertions(+), 83 deletions(-) create mode 100644 docs/changes/1.3.0.rst rename tests/{test_access_route.py => test_request_access_route.py} (100%) rename tests/{test_req_vars.py => test_request_attrs.py} (95%) create mode 100644 tests/test_request_forwarded.py diff --git a/CHANGES.rst b/CHANGES.rst index 3173118..6389d74 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,24 @@ +1.3.0 +===== + +Breaking Changes +---------------- + +(None) + +New & Improved +-------------- + +- A number of properties were added to ``falcon.Request`` to + expose information added by proxies in front of the application + server. These include the `forwarded`, `forwarded_uri`, + `forwarded_scheme`, `forwarded_host`, and `forwarded_prefix` + properties. The `prefix` attribute was also added as part of this + work. + +Fixed +----- + 1.2.0 ===== diff --git a/docs/api/request_and_response.rst b/docs/api/request_and_response.rst index 33b017e..5143d95 100644 --- a/docs/api/request_and_response.rst +++ b/docs/api/request_and_response.rst @@ -23,6 +23,9 @@ Request .. autoclass:: falcon.Request :members: +.. autoclass:: falcon.Forwarded + :members: + Response -------- diff --git a/docs/changes/1.3.0.rst b/docs/changes/1.3.0.rst new file mode 100644 index 0000000..5b14560 --- /dev/null +++ b/docs/changes/1.3.0.rst @@ -0,0 +1,20 @@ +Changelog for Falcon 1.3.0 +========================== + +Breaking Changes +---------------- + +(None) + +New & Improved +-------------- + +- A number of attributes were added to :class:`falcon.Request` to + expose information added by proxies in front of the application + server. These include the `forwarded`, `forwarded_uri`, + `forwarded_scheme`, `forwarded_host`, and `forwarded_prefix` + attribute. The `prefix` attribute was also added as part of this + work. + +Fixed +----- diff --git a/docs/changes/index.rst b/docs/changes/index.rst index a01a194..eb517e6 100644 --- a/docs/changes/index.rst +++ b/docs/changes/index.rst @@ -3,6 +3,7 @@ Changelogs .. toctree:: + 1.3.0 <1.3.0> 1.2.0 <1.2.0> 1.1.0 <1.1.0> 1.0.0 <1.0.0> diff --git a/falcon/__init__.py b/falcon/__init__.py index 8a8aafe..c98ddc8 100644 --- a/falcon/__init__.py +++ b/falcon/__init__.py @@ -54,5 +54,5 @@ import falcon.uri # NOQA from falcon.util import * # NOQA from falcon.hooks import before, after # NOQA -from falcon.request import Request, RequestOptions # NOQA +from falcon.request import Request, RequestOptions, Forwarded # NOQA from falcon.response import Response, ResponseOptions # NOQA diff --git a/falcon/media/base.py b/falcon/media/base.py index 7693729..3592f44 100644 --- a/falcon/media/base.py +++ b/falcon/media/base.py @@ -7,7 +7,7 @@ import six class BaseHandler(object): """Abstract Base Class for an internet media type handler""" - @abc.abstractmethod + @abc.abstractmethod # pragma: no cover def serialize(self, obj): """Serialize the media object on a :any:`falcon.Response` @@ -18,7 +18,7 @@ class BaseHandler(object): bytes: The resulting serialized bytes from the input object. """ - @abc.abstractmethod + @abc.abstractmethod # pragma: no cover def deserialize(self, raw): """Deserialize the :any:`falcon.Request` body. diff --git a/falcon/request.py b/falcon/request.py index 40ed7e4..cc8fde7 100644 --- a/falcon/request.py +++ b/falcon/request.py @@ -74,12 +74,59 @@ class Request(object): options (dict): Set of global options passed from the API handler. Attributes: - scheme (str): Either 'http' or 'https'. + scheme (str): URL scheme used for the request. Either 'http' or + 'https'. + + Note: + If the request was proxied, the scheme may not + match what was originally requested by the client. + :py:attr:`forwarded_scheme` can be used, instead, + to handle such cases. + + forwarded_scheme (str): Original URL scheme requested by the + user agent, if the request was proxied. Typical values are + 'http' or 'https'. + + The following request headers are checked, in order of + preference, to determine the forwarded scheme: + + - ``Forwarded`` + - ``X-Forwarded-For`` + + If none of these headers are available, or if the + Forwarded header is available but does not contain a + "proto" parameter in the first hop, the value of + :attr:`scheme` is returned instead. + + (See also: RFC 7239, Section 1) + protocol (str): Deprecated alias for `scheme`. Will be removed in a future release. method (str): HTTP method requested (e.g., 'GET', 'POST', etc.) - host (str): Hostname requested by the client - port (int): Port used for the request. If the request URL does + host (str): Host request header field + forwarded_host (str): Original host request header as received + by the first proxy in front of the application server. + + The following request headers are checked, in order of + preference, to determine the forwarded scheme: + + - ``Forwarded`` + - ``X-Forwarded-Host`` + + If none of the above headers are available, or if the + Forwarded header is available but the "host" + parameter is not included in the first hop, the value of + :attr:`host` is returned instead. + + Note: + Reverse proxies are often configured to set the Host + header directly to the one that was originally + requested by the user agent; in that case, using + :attr:`host` is sufficient. + + (See also: RFC 7239, Section 4) + + port (int): Port used for the request. If the request URI does not specify a port, the default one for the given schema is returned (80 for HTTP and 443 for HTTPS). netloc (str): Returns the 'host:port' portion of the request @@ -94,9 +141,15 @@ class Request(object): for `subdomain` is undefined. env (dict): Reference to the WSGI environ ``dict`` passed in from the - server. See also PEP-3333. - app (str): Name of the WSGI app (if using WSGI's notion of virtual - hosting). + server. (See also PEP-3333.) + app (str): The initial portion of the request URI's path that + corresponds to the application object, so that the + application knows its virtual "location". This may be an + empty string, if the application corresponds to the "root" + of the server. + + (Corresponds to the "SCRIPT_NAME" environ variable defined + by PEP-3333.) access_route(list): IP address of the original client, as well as any known addresses of proxies fronting the WSGI server. @@ -152,15 +205,26 @@ class Request(object): the current Request instance. Therefore the first argument is the Request instance itself (self). uri (str): The fully-qualified URI for the request. - url (str): alias for `uri`. - relative_uri (str): The path + query string portion of the full URI. - path (str): Path portion of the request URL (not including query + url (str): Alias for `uri`. + forwarded_uri (str): Original URI for proxied requests. Uses + :attr:`forwarded_scheme` and :attr:`forwarded_host` in + order to reconstruct the original URI requested by the user + agent. + relative_uri (str): The path and query string portion of the + request URI, omitting the scheme and host. + prefix (str): The prefix of the request URI, including scheme, + host, and WSGI app (if any). + forwarded_prefix (str): The prefix of the original URI for + proxied requests. Uses :attr:`forwarded_scheme` and + :attr:`forwarded_host` in order to reconstruct the + original URI. + path (str): Path portion of the request URI (not including query string). Note: `req.path` may be set to a new value by a `process_request()` middleware method in order to influence routing. - query_string (str): Query string portion of the request URL, without + query_string (str): Query string portion of the request URI, without the preceding '?' character. uri_template (str): The template for the route that was matched for this request. May be ``None`` if the request has not yet been @@ -168,7 +232,11 @@ class Request(object): methods. May also be ``None`` if your app uses a custom routing engine and the engine does not provide the URI template when resolving a route. + forwarded (list): Value of the Forwarded header, as a parsed list + of :class:`falcon.Forwarded` objects, or ``None`` if the header + is missing. + (See also: RFC 7239, Section 4) user_agent (str): Value of the User-Agent header, or ``None`` if the header is missing. referer (str): Value of Referer header, or ``None`` if @@ -316,23 +384,27 @@ class Request(object): """ __slots__ = ( + '__dict__', + '_bounded_stream', + '_cached_access_route', + '_cached_forwarded', + '_cached_forwarded_prefix', + '_cached_forwarded_uri', '_cached_headers', - '_cached_uri', + '_cached_prefix', '_cached_relative_uri', + '_cached_uri', + '_cookies', + '_params', + '_wsgierrors', 'content_type', + 'context', 'env', 'method', - '_params', + 'options', 'path', 'query_string', 'stream', - '_bounded_stream', - 'context', - '_wsgierrors', - 'options', - '_cookies', - '_cached_access_route', - '__dict__', 'uri_template', '_media', ) @@ -388,10 +460,14 @@ class Request(object): self._cookies = None - self._cached_headers = None - self._cached_uri = None - self._cached_relative_uri = None self._cached_access_route = None + self._cached_forwarded = None + self._cached_forwarded_prefix = None + self._cached_forwarded_uri = None + self._cached_headers = None + self._cached_prefix = None + self._cached_relative_uri = None + self._cached_uri = None try: self.content_type = self.env['CONTENT_TYPE'] @@ -457,6 +533,70 @@ class Request(object): referer = helpers.header_property('HTTP_REFERER') + @property + def forwarded(self): + # PERF(kgriffs): We could DRY up this memoization pattern using + # a decorator, but that would incur additional overhead without + # resorting to some trickery to rewrite the body of the method + # itself (vs. simply wrapping it with some memoization logic). + # At some point we might look into this but I don't think + # it's worth it right now. + if self._cached_forwarded is None: + # PERF(kgriffs): If someone is calling this, they are probably + # confident that the header exists, so most of the time we + # expect this call to succeed. Therefore, we won't need to + # pay the penalty of a raised exception in most cases, and + # there is no need to spend extra cycles calling get() or + # checking beforehand whether the key is in the dict. + try: + forwarded = self.env['HTTP_FORWARDED'] + except KeyError: + return None + + parsed_elements = [] + + for element in forwarded.split(','): + parsed_element = Forwarded() + + # NOTE(kgriffs): Calling strip() is necessary here since + # "an HTTP list allows white spaces to occur between the + # identifiers" (see also RFC 7239, Section 7.1). + for param in element.strip().split(';'): + # PERF(kgriffs): partition() is faster than split(). + name, __, value = param.partition('=') + if not value: + # NOTE(kgriffs): The '=' separator was not found or + # the value was missing. Ignore this malformed + # param. + continue + + # NOTE(kgriffs): According to RFC 7239, parameter + # names are case-insensitive. + name = name.lower() + value = unquote_string(value) + if name == 'by': + parsed_element.dest = value + elif name == 'for': + parsed_element.src = value + elif name == 'host': + parsed_element.host = value + elif name == 'proto': + # NOTE(kgriffs): RFC 7239 only requires that + # the "proto" value conform to the Host ABNF + # described in RFC 7230. The Host ABNF, in turn, + # does not require that the scheme be in any + # particular case, so we normalize it here to be + # consistent with the WSGI spec that *does* + # require the value of 'wsgi.url_scheme' to be + # either 'http' or 'https' (case-sensitive). + parsed_element.scheme = value.lower() + + parsed_elements.append(parsed_element) + + self._cached_forwarded = parsed_elements + + return self._cached_forwarded + @property def client_accepts_json(self): return self.client_accepts('application/json') @@ -579,30 +719,56 @@ class Request(object): @property def app(self): - return self.env.get('SCRIPT_NAME', '') + # PERF(kgriffs): try..except is faster than get() assuming that + # we normally expect the key to exist. Even though PEP-3333 + # allows WSGI servers to omit the key when the value is an + # empty string, uwsgi, gunicorn, waitress, and wsgiref all + # include it even in that case. + try: + return self.env['SCRIPT_NAME'] + except KeyError: + return '' @property def scheme(self): return self.env['wsgi.url_scheme'] + @property + def forwarded_scheme(self): + # PERF(kgriffs): Since the Forwarded header is still relatively + # new, we expect X-Forwarded-Proto to be more common, so + # try to avoid calling self.forwarded if we can, since it uses a + # try...catch that will usually result in a relatively expensive + # raised exception. + if 'HTTP_FORWARDED' in self.env: + first_hop = self.forwarded[0] + scheme = first_hop.scheme or self.scheme + else: + # PERF(kgriffs): This call should normally succeed, so + # just go for it without wasting time checking it + # first. Note also that the indexing operator is + # slightly faster than using get(). + try: + scheme = self.env['HTTP_X_FORWARDED_PROTO'].lower() + except KeyError: + scheme = self.env['wsgi.url_scheme'] + + return scheme + # TODO(kgriffs): Remove this deprecated alias in Falcon 2.0 protocol = scheme @property def uri(self): if self._cached_uri is None: - protocol = self.env['wsgi.url_scheme'] + scheme = self.env['wsgi.url_scheme'] # PERF: For small numbers of items, '+' is faster # than ''.join(...). Concatenation is also generally # faster than formatting. - value = (protocol + '://' + + value = (scheme + '://' + self.netloc + - self.app + - self.path) - - if self.query_string: - value = value + '?' + self.query_string + self.relative_uri) self._cached_uri = value @@ -610,6 +776,53 @@ class Request(object): url = uri + @property + def forwarded_uri(self): + if self._cached_forwarded_uri is None: + # PERF: For small numbers of items, '+' is faster + # than ''.join(...). Concatenation is also generally + # faster than formatting. + value = (self.forwarded_scheme + '://' + + self.forwarded_host + + self.relative_uri) + + self._cached_forwarded_uri = value + + return self._cached_forwarded_uri + + @property + def relative_uri(self): + if self._cached_relative_uri is None: + if self.query_string: + self._cached_relative_uri = (self.app + self.path + '?' + + self.query_string) + else: + self._cached_relative_uri = self.app + self.path + + return self._cached_relative_uri + + @property + def prefix(self): + if self._cached_prefix is None: + self._cached_prefix = ( + self.env['wsgi.url_scheme'] + '://' + + self.netloc + + self.app + ) + + return self._cached_prefix + + @property + def forwarded_prefix(self): + if self._cached_forwarded_prefix is None: + self._cached_forwarded_prefix = ( + self.forwarded_scheme + '://' + + self.forwarded_host + + self.app + ) + + return self._cached_forwarded_prefix + @property def host(self): try: @@ -625,23 +838,34 @@ class Request(object): return host + @property + def forwarded_host(self): + # PERF(kgriffs): Since the Forwarded header is still relatively + # new, we expect X-Forwarded-Host to be more common, so + # try to avoid calling self.forwarded if we can, since it uses a + # try...catch that will usually result in a relatively expensive + # raised exception. + if 'HTTP_FORWARDED' in self.env: + first_hop = self.forwarded[0] + host = first_hop.host or self.host + else: + # PERF(kgriffs): This call should normally succeed, assuming + # that the caller is expecting a forwarded header, so + # just go for it without wasting time checking it + # first. + try: + host = self.env['HTTP_X_FORWARDED_HOST'] + except KeyError: + host = self.host + + return host + @property def subdomain(self): # PERF(kgriffs): .partition is slightly faster than .split subdomain, sep, remainder = self.host.partition('.') return subdomain if sep else None - @property - def relative_uri(self): - if self._cached_relative_uri is None: - if self.query_string: - self._cached_relative_uri = (self.app + self.path + '?' + - self.query_string) - else: - self._cached_relative_uri = self.app + self.path - - return self._cached_relative_uri - @property def headers(self): # NOTE(kgriffs: First time here will cache the dict so all we @@ -696,7 +920,11 @@ class Request(object): # aware that an upstream proxy is malfunctioning. if 'HTTP_FORWARDED' in self.env: - self._cached_access_route = self._parse_rfc_forwarded() + self._cached_access_route = [] + for hop in self.forwarded: + if hop.src is not None: + host, __ = parse_host(hop.src) + self._cached_access_route.append(host) elif 'HTTP_X_FORWARDED_FOR' in self.env: addresses = self.env['HTTP_X_FORWARDED_FOR'].split(',') self._cached_access_route = [ip.strip() for ip in addresses] @@ -738,7 +966,8 @@ class Request(object): # NOTE(kgriffs): According to PEP-3333 we should first # try to use the Host header if present. # - # PERF(kgriffs): try..except is faster than .get + # PERF(kgriffs): try..except is faster than get() when we + # expect the key to be present most of the time. try: netloc_value = env['HTTP_HOST'] except KeyError: @@ -1383,33 +1612,6 @@ class Request(object): self._params.update(extra_params) - def _parse_rfc_forwarded(self): - """Parse RFC 7239 "Forwarded" header. - - Returns: - list: addresses derived from "for" parameters. - """ - - addr = [] - - for forwarded in self.env['HTTP_FORWARDED'].split(','): - for param in forwarded.split(';'): - # PERF(kgriffs): Partition() is faster than split(). - key, _, val = param.strip().partition('=') - if not val: - # NOTE(kgriffs): The '=' separator was not found or - # it was, but the value was missing. - continue - - if key.lower() != 'for': - # We only want "for" params - continue - - host, _ = parse_host(unquote_string(val)) - addr.append(host) - - return addr - # PERF: To avoid typos and improve storage space and speed over a dict. class RequestOptions(object): @@ -1495,3 +1697,36 @@ class RequestOptions(object): self.strip_url_path_trailing_slash = True self.default_media_type = DEFAULT_MEDIA_TYPE self.media_handlers = Handlers() + + +class Forwarded(object): + """Represents a parsed Forwarded header. + + (See also: RFC 7239, Section 4) + + Attributes: + src (str): The value of the "for" parameter, or + ``None`` if the parameter is absent. Identifies the + node making the request to the proxy. + dest (str): The value of the "by" parameter, or + ``None`` if the parameter is absent. Identifies the + client-facing interface of the proxy. + host (str): The value of the "host" parameter, or + ``None`` if the parameter is absent. Provides the host + request header field as received by the proxy. + scheme (str): The value of the "proto" parameter, or + ``None`` if the parameter is absent. Indicates the + protocol that was used to make the request to + the proxy. + """ + + # NOTE(kgriffs): Use "client" since "for" is a keyword, and + # "scheme" instead of "proto" to be consistent with the + # falcon.Request interface. + __slots__ = ('src', 'dest', 'host', 'scheme') + + def __init__(self): + self.src = None + self.dest = None + self.host = None + self.scheme = None diff --git a/falcon/testing/helpers.py b/falcon/testing/helpers.py index 29a5122..eeaa728 100644 --- a/falcon/testing/helpers.py +++ b/falcon/testing/helpers.py @@ -157,6 +157,12 @@ def create_environ(path='/', query_string='', protocol='HTTP/1.1', else: port = str(port) + # NOTE(kgriffs): Judging by the algorithm given in PEP-3333 for + # reconstructing the URL, SCRIPT_NAME is expected to contain a + # preceding slash character. + if app and not app.startswith('/'): + app = '/' + app + env = { 'SERVER_PROTOCOL': protocol, 'SERVER_SOFTWARE': 'gunicorn/0.17.0', diff --git a/tests/test_access_route.py b/tests/test_request_access_route.py similarity index 100% rename from tests/test_access_route.py rename to tests/test_request_access_route.py diff --git a/tests/test_req_vars.py b/tests/test_request_attrs.py similarity index 95% rename from tests/test_req_vars.py rename to tests/test_request_attrs.py index ca94e78..d1b5e56 100644 --- a/tests/test_req_vars.py +++ b/tests/test_request_attrs.py @@ -1,4 +1,5 @@ import datetime +import itertools import pytest import six @@ -11,7 +12,7 @@ import falcon.uri _PROTOCOLS = ['HTTP/1.0', 'HTTP/1.1'] -class TestReqVars(object): +class TestRequestAttributes(object): def setup_method(self, method): self.qs = 'marker=deadbeef&limit=10' @@ -105,10 +106,12 @@ class TestReqVars(object): path = req.path query_string = req.query_string - expected_uri = ''.join([scheme, '://', host, app, path, - '?', query_string]) + expected_prefix = ''.join([scheme, '://', host, app]) + expected_uri = ''.join([expected_prefix, path, '?', query_string]) - assert expected_uri == req.uri + assert req.uri == expected_uri + assert req.prefix == expected_prefix + assert req.prefix == expected_prefix # Check cached value @pytest.mark.skipif(not six.PY3, reason='Test only applies to Python 3') @pytest.mark.parametrize('test_path', [ @@ -141,10 +144,11 @@ class TestReqVars(object): assert req.path == falcon.uri.decode(test_path) def test_uri(self): - uri = ('http://' + testing.DEFAULT_HOST + ':8080' + - self.app + self.relative_uri) + prefix = 'http://' + testing.DEFAULT_HOST + ':8080' + self.app + uri = prefix + self.relative_uri assert self.req.url == uri + assert self.req.prefix == prefix # NOTE(kgriffs): Call twice to check caching works assert self.req.uri == uri @@ -695,20 +699,35 @@ class TestReqVars(object): assert req.scheme == scheme assert req.port == 443 - @pytest.mark.parametrize('protocol', _PROTOCOLS) - def test_scheme_http(self, protocol): + @pytest.mark.parametrize( + 'protocol, set_forwarded_proto', + list(itertools.product(_PROTOCOLS, [True, False])) + ) + def test_scheme_http(self, protocol, set_forwarded_proto): scheme = 'http' + forwarded_scheme = 'HttPs' + + headers = dict(self.headers) + + if set_forwarded_proto: + headers['X-Forwarded-Proto'] = forwarded_scheme + req = Request(testing.create_environ( protocol=protocol, scheme=scheme, app=self.app, path='/hello', query_string=self.qs, - headers=self.headers)) + headers=headers)) assert req.scheme == scheme assert req.port == 80 + if set_forwarded_proto: + assert req.forwarded_scheme == forwarded_scheme.lower() + else: + assert req.forwarded_scheme == scheme + @pytest.mark.parametrize('protocol', _PROTOCOLS) def test_netloc_default_port(self, protocol): req = Request(testing.create_environ( @@ -750,6 +769,21 @@ class TestReqVars(object): assert req.port == port assert req.netloc == '{0}:{1}'.format(host, port) + def test_app_present(self): + req = Request(testing.create_environ(app='/moving-pictures')) + assert req.app == '/moving-pictures' + + def test_app_blank(self): + req = Request(testing.create_environ(app='')) + assert req.app == '' + + def test_app_missing(self): + env = testing.create_environ() + del env['SCRIPT_NAME'] + req = Request(env) + + assert req.app == '' + # ------------------------------------------------------------------------- # Helpers # ------------------------------------------------------------------------- diff --git a/tests/test_request_forwarded.py b/tests/test_request_forwarded.py new file mode 100644 index 0000000..c771005 --- /dev/null +++ b/tests/test_request_forwarded.py @@ -0,0 +1,122 @@ +from falcon.request import Request +import falcon.testing as testing + + +def test_no_forwarded_headers(): + req = Request(testing.create_environ( + host='example.com', + path='/languages', + app='backoffice' + )) + + assert req.forwarded is None + assert req.forwarded_uri == req.uri + assert req.forwarded_uri == 'http://example.com/backoffice/languages' + assert req.forwarded_prefix == 'http://example.com/backoffice' + + +def test_x_forwarded_host(): + req = Request(testing.create_environ( + host='suchproxy.suchtesting.com', + path='/languages', + headers={'X-Forwarded-Host': 'something.org'} + )) + + assert req.forwarded is None + assert req.forwarded_host == 'something.org' + assert req.forwarded_uri != req.uri + assert req.forwarded_uri == 'http://something.org/languages' + assert req.forwarded_prefix == 'http://something.org' + assert req.forwarded_prefix == 'http://something.org' # Check cached value + + +def test_x_forwarded_proto(): + req = Request(testing.create_environ( + host='example.org', + path='/languages', + headers={'X-Forwarded-Proto': 'HTTPS'} + )) + + assert req.forwarded is None + assert req.forwarded_scheme == 'https' + assert req.forwarded_uri != req.uri + assert req.forwarded_uri == 'https://example.org/languages' + assert req.forwarded_prefix == 'https://example.org' + + +def test_forwarded_host(): + req = Request(testing.create_environ( + host='suchproxy02.suchtesting.com', + path='/languages', + headers={ + 'Forwarded': 'host=something.org , host=suchproxy01.suchtesting.com' + } + )) + + assert req.forwarded is not None + for f in req.forwarded: + assert f.src is None + assert f.dest is None + assert f.scheme is None + + assert req.forwarded[0].host == 'something.org' + assert req.forwarded[1].host == 'suchproxy01.suchtesting.com' + + assert req.forwarded_host == 'something.org' + assert req.forwarded_uri != req.uri + assert req.forwarded_uri == 'http://something.org/languages' + assert req.forwarded_prefix == 'http://something.org' + + +def test_forwarded_multiple_params(): + req = Request(testing.create_environ( + host='suchproxy02.suchtesting.com', + path='/languages', + headers={ + 'Forwarded': ( + 'host=something.org;proto=hTTps;ignore=me;for=108.166.30.185, ' + 'by=203.0.113.43;host=suchproxy01.suchtesting.com;proto=httP' + ) + } + )) + + assert req.forwarded is not None + + assert req.forwarded[0].host == 'something.org' + assert req.forwarded[0].scheme == 'https' + assert req.forwarded[0].src == '108.166.30.185' + assert req.forwarded[0].dest is None + + assert req.forwarded[1].host == 'suchproxy01.suchtesting.com' + assert req.forwarded[1].scheme == 'http' + assert req.forwarded[1].src is None + assert req.forwarded[1].dest == '203.0.113.43' + + assert req.forwarded_scheme == 'https' + assert req.forwarded_host == 'something.org' + assert req.forwarded_uri != req.uri + assert req.forwarded_uri == 'https://something.org/languages' + assert req.forwarded_prefix == 'https://something.org' + + +def test_forwarded_missing_first_hop_host(): + req = Request(testing.create_environ( + host='suchproxy02.suchtesting.com', + path='/languages', + app='doge', + headers={ + 'Forwarded': 'for=108.166.30.185,host=suchproxy01.suchtesting.com' + } + )) + + assert req.forwarded[0].host is None + assert req.forwarded[0].src == '108.166.30.185' + + assert req.forwarded[1].host == 'suchproxy01.suchtesting.com' + assert req.forwarded[1].src is None + + assert req.forwarded_scheme == 'http' + assert req.forwarded_host == 'suchproxy02.suchtesting.com' + assert req.forwarded_uri == req.uri + assert req.forwarded_uri == 'http://suchproxy02.suchtesting.com/doge/languages' + assert req.forwarded_prefix == 'http://suchproxy02.suchtesting.com/doge' diff --git a/tox.ini b/tox.ini index 5157c8e..fede60c 100644 --- a/tox.ini +++ b/tox.ini @@ -199,7 +199,17 @@ commands = uwsgi --http localhost:8000 --wsgi-file {toxinidir}/tests/dump_wsgi.p [testenv:py27_dump_gunicorn] basepython = python2.7 deps = gunicorn -commands = gunicorn: gunicorn -b localhost:8000 tests.dump_wsgi +commands = gunicorn -b localhost:8000 tests.dump_wsgi + +[testenv:py36_dump_gunicorn] +basepython = python3.6 +deps = gunicorn +commands = gunicorn -b localhost:8000 tests.dump_wsgi + +[testenv:py27_dump_waitress] +basepython = python2.7 +deps = waitress +commands = waitress-serve --listen=localhost:8000 tests.dump_wsgi:application [testenv:py27_dump_wsgiref] basepython = python2.7