diff options
| author | David Lord <davidism@gmail.com> | 2018-05-27 20:23:20 -0700 |
|---|---|---|
| committer | David Lord <davidism@gmail.com> | 2018-05-27 20:23:20 -0700 |
| commit | b9f3dbeacbba1a893fa6b6c08e6e462860bdf4d0 (patch) | |
| tree | ae12b323627214b66bf0701f51280755fcd1cb8e | |
| parent | 30eb77772e90ce9d7a488ca92c154b1d0f437e11 (diff) | |
| download | werkzeug-proxyfix.tar.gz | |
wipproxyfix
| -rw-r--r-- | tests/contrib/test_fixers.py | 35 | ||||
| -rw-r--r-- | tests/test_wsgi.py | 66 | ||||
| -rw-r--r-- | werkzeug/contrib/fixers.py | 223 | ||||
| -rw-r--r-- | werkzeug/wsgi.py | 25 |
4 files changed, 256 insertions, 93 deletions
diff --git a/tests/contrib/test_fixers.py b/tests/contrib/test_fixers.py index 0ff91a93..a6a29956 100644 --- a/tests/contrib/test_fixers.py +++ b/tests/contrib/test_fixers.py @@ -158,6 +158,41 @@ class TestServerFixer(object): assert wsgi_headers['Location'] == '{}/foo/bar.hml'.format( assumed_host) + @pytest.mark.parametrize(('environ', 'remote_addr', 'url_root'), ( + pytest.param({ + 'REMOTE_ADDR': '192.168.0.1', + 'HTTP_HOST': 'spam', + }, '192.168.0.1', 'http://spam/', id='basic'), + )) + def test_proxy_fix_new(self, environ, remote_addr, url_root): + map = Map([Rule('/parrot', endpoint='parrot')]) + + @Request.application + def app(request): + assert request.remote_addr == remote_addr + assert request.url_root == url_root + + urls = map.bind_to_environ(request.environ) + assert urls.build('parrot') == '/'.join(( + request.script_root, 'parrot')) + assert urls.match('/parrot')[0] == 'parrot' + + return Response('success') + + app = fixers.ProxyFix(app) + + environ = create_environ(environ_overrides=environ) + if 'HTTP_HOST' not in environ: + del environ['HTTP_HOST'] + + response = Response.from_app(app, environ) + assert response.get_data() == b'success' + + redirect_app = redirect('/parrot') + response = Response.from_app(redirect_app, environ) + location = response.get_wsgi_headers(environ)['Location'] + assert location == '/'.join(url_root, ) + def test_proxy_fix_forwarded_prefix(self): @fixers.ProxyFix @Request.application diff --git a/tests/test_wsgi.py b/tests/test_wsgi.py index 4fd99ed1..1e3c5ba8 100644 --- a/tests/test_wsgi.py +++ b/tests/test_wsgi.py @@ -98,32 +98,46 @@ def test_dispatchermiddleware(): assert b''.join(app_iter).strip() == b'NOT FOUND' -def test_get_host_by_http_host(): - env = {'HTTP_HOST': 'example.org', 'wsgi.url_scheme': 'http'} - assert wsgi.get_host(env) == 'example.org' - env['HTTP_HOST'] = 'example.org:8080' - assert wsgi.get_host(env) == 'example.org:8080' - env['HOST_NAME'] = 'ignore me' - assert wsgi.get_host(env) == 'example.org:8080' - - -def test_get_host_by_server_name_and_port(): - env = {'SERVER_NAME': 'example.org', 'SERVER_PORT': '80', - 'wsgi.url_scheme': 'http'} - assert wsgi.get_host(env) == 'example.org' - env['wsgi.url_scheme'] = 'https' - assert wsgi.get_host(env) == 'example.org:80' - env['SERVER_PORT'] = '8080' - assert wsgi.get_host(env) == 'example.org:8080' - env['SERVER_PORT'] = '443' - assert wsgi.get_host(env) == 'example.org' - - -def test_get_host_ignore_x_forwarded_for(): - env = {'HTTP_X_FORWARDED_HOST': 'forwarded', - 'HTTP_HOST': 'example.org', - 'wsgi.url_scheme': 'http'} - assert wsgi.get_host(env) == 'example.org' +@pytest.mark.parametrize(('environ', 'expect'), ( + pytest.param({ + 'HTTP_HOST': 'spam', + }, 'spam', id='host'), + pytest.param({ + 'HTTP_HOST': 'spam:80', + }, 'spam', id='host, strip http port'), + pytest.param({ + 'wsgi.url_scheme': 'https', + 'HTTP_HOST': 'spam:443', + }, 'spam', id='host, strip https port'), + pytest.param({ + 'HTTP_HOST': 'spam:8080', + }, 'spam:8080', id='host, custom port'), + pytest.param({ + 'HTTP_HOST': 'spam', + 'SERVER_NAME': 'eggs', + 'SERVER_PORT': '80', + }, 'spam', id='prefer host'), + pytest.param({ + 'SERVER_NAME': 'eggs', + 'SERVER_PORT': '80' + }, 'eggs', id='name, ignore http port'), + pytest.param({ + 'wsgi.url_scheme': 'https', + 'SERVER_NAME': 'eggs', + 'SERVER_PORT': '443' + }, 'eggs', id='name, ignore https port'), + pytest.param({ + 'SERVER_NAME': 'eggs', + 'SERVER_PORT': '8080' + }, 'eggs:8080', id='name, custom port'), + pytest.param({ + 'HTTP_HOST': 'ham', + 'HTTP_X_FORWARDED_HOST': 'eggs' + }, 'ham', id='ignore x-forwarded-host'), +)) +def test_get_host(environ, expect): + environ.setdefault('wsgi.url_scheme', 'http') + assert wsgi.get_host(environ) == expect def test_get_host_validate_trusted_hosts(): diff --git a/werkzeug/contrib/fixers.py b/werkzeug/contrib/fixers.py index 86e977ba..735df329 100644 --- a/werkzeug/contrib/fixers.py +++ b/werkzeug/contrib/fixers.py @@ -16,6 +16,8 @@ :copyright: Copyright 2009 by the Werkzeug Team, see AUTHORS for more details. :license: BSD, see LICENSE for more details. """ +import warnings + try: from urllib import unquote except ImportError: @@ -95,76 +97,185 @@ class PathInfoFromRequestUriFix(object): class ProxyFix(object): + """Adjust the WSGI environ based on ``Forwarded`` headers that + proxies in front of the application may set. + + When the application is running behind a server like Nginx (or + another server or proxy), WSGI will see the request as coming from + that server rather than the real client. Proxies set various headers + to track where the request actually came from. + + This middleware should only be applied if the application is + actually behind such a proxy, and should be configured with the + number of proxies that are chained in front of it. Not all proxies + set all the headers. Since incoming headers can be faked, you must + set how many proxies are setting each header so the middleware knows + what to trust. + + The original values of the headers are stored in the WSGI + environ as ``werkzeug.proxy_fix.orig``, a dict. + + :param app: The WSGI application. + :param x_for: Number of values to trust for ``X-Forwarded-For``. + :param x_proto: Number of values to trust for ``X-Forwarded-Proto``. + :param x_host: Number of values to trust for ``X-Forwarded-Host``. + :param x_port: Number of values to trust for ``X-Forwarded-Port``. + :param x_prefix: Number of values to trust for + ``X-Forwarded-Prefix``. + :param num_proxies: Deprecated, use ``x_for`` instead. + + .. versionchanged:: 0.15 + Support ``X-Forwarded-Proto``, ``X-Forwarded-Port``, + ``X-Forwarded-Prefix``. + + .. versionchanged:: 0.15 + All headers support multiple values. Previously only + ``X-Forwarded-For`` parsed multiple values. + + .. versionchanged:: 0.15 + Deprecate ``num_proxies`` argument. Each header is configured + with a separate number of trusted proxies. + + .. versionchanged:: 0.15 + Original WSGI environ values are stored in the + ``werkzeug.proxy_fix.orig`` dict. Previously they were stored + under separate keys. + """ - """This middleware can be applied to add HTTP proxy support to an - application that was not designed with HTTP proxies in mind. It - sets `REMOTE_ADDR`, `HTTP_HOST` from `X-Forwarded` headers. While - Werkzeug-based applications already can use - :py:func:`werkzeug.wsgi.get_host` to retrieve the current host even if - behind proxy setups, this middleware can be used for applications which - access the WSGI environment directly. + def __init__( + self, app, num_proxies=None, + x_for=1, x_proto=0, x_host=0, x_port=0, x_prefix=0 + ): + self.app = app + self.trusted = { + 'x_for': x_for, + 'x_proto': x_proto, + 'x_host': x_host, + 'x_port': x_port, + 'x_prefix': x_prefix, + } + self.num_proxies = num_proxies - If you have more than one proxy server in front of your app, set - `num_proxies` accordingly. + @property + def num_proxies(self): + """The number of proxies setting ``X-Forwarded-For`` in front + of the application. - Do not use this middleware in non-proxy setups for security reasons. + .. deprecated:: 0.15 + A separate number of trusted proxies for each header is + stored in :attr:`trusted`. ``num_proxies`` maps to the + ``x_for`` key. - The original values of `REMOTE_ADDR` and `HTTP_HOST` are stored in - the WSGI environment as `werkzeug.proxy_fix.orig_remote_addr` and - `werkzeug.proxy_fix.orig_http_host`. + :internal: + """ + warnings.warn(DeprecationWarning( + "num_proxies is deprecated. Use trusted['x_for'] instead.")) + return self.trusted['x_for'] + + @num_proxies.setter + def num_proxies(self, value): + if value is not None: + warnings.warn(DeprecationWarning( + 'num_proxies is deprecated. Use x_for and other header' + ' offsets instead.')) + self.trusted['x_for'] = value - :param app: the WSGI application - :param num_proxies: the number of proxy servers in front of the app. - """ + def get_remote_addr(self, forwarded_for): + """Get the real ``remote_addr`` by looking backwards ``x_for`` + number of values in the ``X-Forwarded-For`` header. - def __init__(self, app, num_proxies=1): - self.app = app - self.num_proxies = num_proxies + :param forwarded_for: List of values parsed from the + ``X-Forwarded-For`` header. + :return: The real ``remote_addr``, or ``None`` if there were not + at least ``x_for`` values. - def get_remote_addr(self, forwarded_for): - """Selects the new remote addr from the given list of ips in - X-Forwarded-For. By default it picks the one that the `num_proxies` - proxy server provides. Before 0.9 it would always pick the first. + .. deprecated:: 0.15 + Use :meth:`get_by_offset('x_for', values) <get_by_offset>` + instead. + + .. versionchanged:: 0.9 + Use ``num_proxies`` instead of always picking the first + value. .. versionadded:: 0.8 """ - if len(forwarded_for) >= self.num_proxies: - return forwarded_for[-self.num_proxies] + warnings.warn(DeprecationWarning( + "get_remote_addr is deprecated. Use" + " get_by_offset('x_for', values) instead.")) + return self.get_by_offset('x_for', forwarded_for) + + def get_by_offset(self, key, values): + """Get the real value from a list of values parsed from a header + based on the configured number of trusted proxies. + + :param key: Key to get from :attr:`trusted`. + :param values: List of values parsed from a header. + :return: The real value, or ``None`` if there are fewer values + than the number of trusted proxies. + + .. versionadded:: 0.15 + """ + offset = self.trusted[key] + if len(values) >= offset: + return values[-offset] + + def set_x_host(self, value, environ): + """Set ``HTTP_HOST`` and ``SERVER_NAME`` based on + ``X-Forwarded-Host``. + + :internal: + """ + environ['HTTP_HOST'] = value + environ['SERVER_NAME'] = value + + def set_x_port(self, value, environ): + """Set ``HTTP_HOST`` and ``SERVER_PORT`` based on + ``X-Forwarded-Port``. + + If ``HTTP_HOST`` is not set, it will be skipped. Otherwise the + port will be added or changed. + + :internal: + """ + host = environ.get('HTTP_HOST') + if host: + parts = host.split(':', 1) + host = parts[0] if len(parts) == 2 else host + environ['HTTP_HOST'] = '%s:%s' % (host, value) + + environ['SERVER_PORT'] = value def __call__(self, environ, start_response): + """Modify the WSGI environ based on the various ``Forwarded`` + headers before calling the wrapped application. Store the + original environ values in ``werkzeug.proxy_fix.orig_{key}``. + """ getter = environ.get - forwarded_proto = getter('HTTP_X_FORWARDED_PROTO', '').split(',') - forwarded_for = getter('HTTP_X_FORWARDED_FOR', '').split(',') - forwarded_host = getter('HTTP_X_FORWARDED_HOST', '') - forwarded_port = getter('HTTP_X_FORWARDED_PORT', '') - forwarded_prefix = getter('HTTP_X_FORWARDED_PREFIX', '') - environ.update({ - 'werkzeug.proxy_fix.orig_wsgi_url_scheme': getter('wsgi.url_scheme'), - 'werkzeug.proxy_fix.orig_remote_addr': getter('REMOTE_ADDR'), - 'werkzeug.proxy_fix.orig_http_host': getter('HTTP_HOST'), - 'werkzeug.proxy_fix.orig_server_port': getter('SERVER_PORT'), - 'werkzeug.proxy_fix.orig_script_name': getter('SCRIPT_NAME'), - }) - forwarded_for = [x for x in [x.strip() for x in forwarded_for] if x] - forwarded_proto = [x for x in [x.strip() for x in forwarded_proto] if x] - remote_addr = self.get_remote_addr(forwarded_for) - if remote_addr is not None: - environ['REMOTE_ADDR'] = remote_addr - if forwarded_host: - environ['HTTP_HOST'] = forwarded_host - if forwarded_port: - if environ.get('HTTP_HOST'): - parts = environ['HTTP_HOST'].split(':', 1) - if len(parts) == 2: - environ['HTTP_HOST'] = parts[0] + ':' + forwarded_port + environ['werkzeug.proxy_fix.orig'] = { + 'REMOTE_ADDR': getter('REMOTE_ADDR'), + 'wsgi.url_scheme': getter('wsgi.url_scheme'), + 'HTTP_HOST': getter('HTTP_HOST'), + 'SERVER_NAME': getter('SERVER_NAME'), + 'SERVER_PORT': getter('SERVER_PORT'), + 'SCRIPT_NAME': getter('SCRIPT_NAME'), + } + + for get_key, offset_key, set_key in ( + ('X_FORWARDED_FOR', 'x_for', 'REMOTE_ADDR'), + ('X_FORWARDED_PROTO', 'x_proto', 'wsgi.url_scheme'), + ('X_FORWARDED_HOST', 'x_host', self.set_x_host), + ('X_FORWARDED_PORT', 'x_port', self.set_x_port), + ('X_FORWARDED_PREFIX', 'x_prefix', 'SCRIPT_NAME'), + ): + value = self.get_by_offset( + offset_key, + [x.strip() for x in getter(get_key, '').split(',')]) + if value: + if callable(set_key): + set_key(value, environ) else: - environ['HTTP_HOST'] += ':' + forwarded_port - else: - environ['SERVER_PORT'] = forwarded_port - if forwarded_proto: - environ['wsgi.url_scheme'] = forwarded_proto[0] - if forwarded_prefix: - environ['SCRIPT_NAME'] = forwarded_prefix + environ[set_key] = value + return self.app(environ, start_response) diff --git a/werkzeug/wsgi.py b/werkzeug/wsgi.py index ec7bb0a9..1f58c18b 100644 --- a/werkzeug/wsgi.py +++ b/werkzeug/wsgi.py @@ -143,17 +143,20 @@ def host_is_trusted(hostname, trusted_list): def get_host(environ, trusted_hosts=None): - """Return the real host for the given WSGI environment. This first checks - the normal `Host` header, and if it's not present, then `SERVER_NAME` - and `SERVER_PORT` environment variables. - - Optionally it verifies that the host is in a list of trusted hosts. - If the host is not in there it will raise a - :exc:`~werkzeug.exceptions.SecurityError`. - - :param environ: the WSGI environment to get the host of. - :param trusted_hosts: a list of trusted hosts, see :func:`host_is_trusted` - for more information. + """Return the host for the given WSGI environment. This first checks + the ``Host`` header. If it's not present, then ``SERVER_NAME`` and + ``SERVER_PORT`` are used. The host will only contain the port if it + is different than the standard port for the protocol. + + Optionally, verify that the host is trusted using + :func:`host_is_trusted` and raise a + :exc:`~werkzeug.exceptions.SecurityError` if it is not. + + :param environ: The WSGI environment to get the host from. + :param trusted_hosts: A list of trusted hosts. + :return: Host, with port if necessary. + :raise ~werkzeug.exceptions.SecurityError: If the host is not + trusted. """ if 'HTTP_HOST' in environ: rv = environ['HTTP_HOST'] |
