From 5f4d171d1eb04815e2ccfed4d9f1eee8d2c78f56 Mon Sep 17 00:00:00 2001 From: Roman Zimmermann Date: Tue, 26 Aug 2025 17:52:30 +0200 Subject: [PATCH 1/8] feat: Copy existing code from moflask.wsgi --- impact_stack/proxyfix/__init__.py | 90 ++++++++++++++++++++++++++++- pyproject.toml | 1 + requirements-dev.txt | 19 +++++- requirements.txt | 17 ++++++ tests/__init__.py | 2 +- tests/example_test.py | 8 --- tests/proxyfix_test.py | 96 +++++++++++++++++++++++++++++++ 7 files changed, 221 insertions(+), 12 deletions(-) delete mode 100644 tests/example_test.py create mode 100644 tests/proxyfix_test.py diff --git a/impact_stack/proxyfix/__init__.py b/impact_stack/proxyfix/__init__.py index 0a32972..86f86da 100644 --- a/impact_stack/proxyfix/__init__.py +++ b/impact_stack/proxyfix/__init__.py @@ -1,3 +1,89 @@ -"""Boilerplate project module.""" +"""WSGI ProxyFix middleware.""" -HELLO = "hello world" +import typing +from ipaddress import ip_address + +import flask + + +def _split(string): + return string.split(",") if string else [] + + +class ProxyFix: + """This is a slightly modified version of werkzeug's ProxyFix. + + Instead of using a fixed number of proxies it uses a list of trusted IP + addresses. + + 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. + + 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`. + + Args: + app: The Flask application to wrap. + """ + + def __init__(self, app: flask.Flask): + """Wrap a Flask app and read variables from its config.""" + self.wsgi = app.wsgi_app + proxies = app.config.get("PROXYFIX_TRUSTED", ["127.0.0.1"]) + self.trusted = frozenset(ip_address(p.strip()) for p in proxies) + + def get_remote_addr(self, forwarded_for, remote): + """Select the first “untrusted” remote addr. + + Values to X-Forwarded-For are expected to be appended so the inner proxy layers are to the + right. The innermost untrusted IP is returned. + """ + for ip_str in reversed([remote] + forwarded_for): + ip_str = ip_str.strip() + try: + if not ip_address(ip_str) in self.trusted: + return ip_str + except ValueError: + return ip_str + return remote + + def update_environ(self, environ): + """Update the WSGI environment according to the headers.""" + env = environ.get + remote_addr = env("REMOTE_ADDR") + if not remote_addr: + return + + try: + remote_addr_ip = ip_address(remote_addr) + except ValueError: + remote_addr_ip = ip_address("127.0.0.1") + + forwarded_proto = env("HTTP_X_FORWARDED_PROTO", "") + forwarded_for = _split(env("HTTP_X_FORWARDED_FOR", "")) + forwarded_host = env("HTTP_X_FORWARDED_HOST", "") + environ.update( + { + "werkzeug.proxy_fix.orig_wsgi_url_scheme": env("wsgi.url_scheme"), + "werkzeug.proxy_fix.orig_remote_addr": env("REMOTE_ADDR"), + "werkzeug.proxy_fix.orig_http_host": env("HTTP_HOST"), + } + ) + + if remote_addr_ip in self.trusted: + if forwarded_host: + environ["HTTP_HOST"] = forwarded_host + if forwarded_proto: + https = "https" in forwarded_proto.lower() + environ["wsgi.url_scheme"] = "https" if https else "http" + + remote_addr = self.get_remote_addr(forwarded_for, remote_addr) + if remote_addr is not None: + environ["REMOTE_ADDR"] = remote_addr + + def __call__(self, environ, start_response): + """Change the WSGI environment and call the wrapped app.""" + self.update_environ(environ) + return self.wsgi(environ, start_response) diff --git a/pyproject.toml b/pyproject.toml index dbb8111..ffe22ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,6 +2,7 @@ name = "impact-stack-proxyfix" dynamic = ["version"] dependencies = [ + "flask", ] [project.optional-dependencies] diff --git a/requirements-dev.txt b/requirements-dev.txt index 0b644ce..95674f8 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,10 +8,14 @@ astroid==3.3.11 # via pylint black==25.1.0 # via impact-stack-proxyfix (pyproject.toml) +blinker==1.9.0 + # via flask cfgv==3.4.0 # via pre-commit click==8.2.1 - # via black + # via + # black + # flask coverage[toml]==7.9.2 # via pytest-cov dill==0.4.0 @@ -20,6 +24,8 @@ distlib==0.3.9 # via virtualenv filelock==3.18.0 # via virtualenv +flask==3.1.2 + # via impact-stack-proxyfix (pyproject.toml) identify==2.6.12 # via pre-commit iniconfig==2.1.0 @@ -28,6 +34,15 @@ isort==6.0.1 # via # impact-stack-proxyfix (pyproject.toml) # pylint +itsdangerous==2.2.0 + # via flask +jinja2==3.1.6 + # via flask +markupsafe==3.0.2 + # via + # flask + # jinja2 + # werkzeug mccabe==0.7.0 # via pylint mypy-extensions==1.1.0 @@ -67,3 +82,5 @@ tomlkit==0.13.3 # via pylint virtualenv==20.31.2 # via pre-commit +werkzeug==3.1.3 + # via flask diff --git a/requirements.txt b/requirements.txt index 3acf792..1c51b81 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,20 @@ # # pip-compile --extra=hosting --output-file=requirements.txt pyproject.toml # +blinker==1.9.0 + # via flask +click==8.2.1 + # via flask +flask==3.1.2 + # via impact-stack-proxyfix (pyproject.toml) +itsdangerous==2.2.0 + # via flask +jinja2==3.1.6 + # via flask +markupsafe==3.0.2 + # via + # flask + # jinja2 + # werkzeug +werkzeug==3.1.3 + # via flask diff --git a/tests/__init__.py b/tests/__init__.py index 90bdfed..fc03c2e 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -"""Tests for the boilerplate_project.""" +"""Tests for the impact-stack-rest library.""" diff --git a/tests/example_test.py b/tests/example_test.py deleted file mode 100644 index a617204..0000000 --- a/tests/example_test.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Example test case.""" - -from impact_stack import proxyfix - - -def test_hello(): - """Test a trivial assertion.""" - assert proxyfix.HELLO == "hello world" diff --git a/tests/proxyfix_test.py b/tests/proxyfix_test.py new file mode 100644 index 0000000..f39c9ab --- /dev/null +++ b/tests/proxyfix_test.py @@ -0,0 +1,96 @@ +"""Test the proxyfix middlewares.""" + +from copy import copy +from unittest import mock + +from impact_stack import proxyfix + + +def create_app_stub(trusted): + """Create a stub Flask app.""" + app = mock.Mock() + app.wsgi_app = None + app.config = {} + app.config["PROXYFIX_TRUSTED"] = trusted + return app + + +def copy_env(env): + """Create a copy of a WSGI environment with the original values backed up.""" + new_env = copy(env) + new_env["werkzeug.proxy_fix.orig_http_host"] = env["HTTP_HOST"] + new_env["werkzeug.proxy_fix.orig_remote_addr"] = env["REMOTE_ADDR"] + new_env["werkzeug.proxy_fix.orig_wsgi_url_scheme"] = env["wsgi.url_scheme"] + return new_env + + +class ProxyFixTest: + """Test the proxy fix middleware.""" + + def test_one_forwarded_for_layer(self): + """Test a single trusted reverse proxy.""" + fix = proxyfix.ProxyFix(create_app_stub(["127.0.0.1"])) + env = { + "REMOTE_ADDR": "127.0.0.1", + "HTTP_HOST": "example.com", + "wsgi.url_scheme": "http", + "HTTP_X_FORWARDED_FOR": "8.8.8.8", + "HTTP_X_FORWARDED_PROTO": "https", + } + expected_env = copy_env(env) + expected_env["REMOTE_ADDR"] = "8.8.8.8" + expected_env["wsgi.url_scheme"] = "https" + fix.update_environ(env) + assert env == expected_env + + def test_multiple_trusted_multiple_untrusted(self): + """Test getting the remote IP for multiple proxy layers.""" + fix = proxyfix.ProxyFix(create_app_stub(["127.0.0.1", "10.0.0.1"])) + remote_address = fix.get_remote_addr( + ["8.8.8.8", "10.0.0.1", "127.0.0.1"], + "1.1.1.1", + ) + assert remote_address == "8.8.8.8" + + def test_all_trusted(self): + """Test getting the remote IP with only trusted IPs.""" + fix = proxyfix.ProxyFix(create_app_stub(["127.0.0.1"])) + assert fix.get_remote_addr([], "127.0.0.1") == "127.0.0.1" + + def test_no_trusted_layer(self): + """Test request from an untrusted remote.""" + fix = proxyfix.ProxyFix(create_app_stub(["127.0.0.1"])) + env = { + "REMOTE_ADDR": "8.8.8.8", + "HTTP_HOST": "example.com", + "wsgi.url_scheme": "http", + "HTTP_X_FORWARDED_FOR": "8.8.8.8", + "HTTP_X_FORWARDED_HOST": "untrusted.com", + "HTTP_X_FORWARDED_PROTO": "https", + } + expected_env = copy_env(env) + fix.update_environ(env) + assert env == expected_env + + def test_new_ip_equals_old_ip(self): + """Test a local request with HTTPS.""" + fix = proxyfix.ProxyFix(create_app_stub(["127.0.0.1"])) + env = { + "REMOTE_ADDR": "127.0.0.1", + "HTTP_HOST": "example.com", + "wsgi.url_scheme": "http", + "HTTP_X_FORWARDED_FOR": "127.0.0.1", + "HTTP_X_FORWARDED_PROTO": "https", + } + expected_env = copy_env(env) + expected_env["wsgi.url_scheme"] = "https" + fix.update_environ(env) + assert env == expected_env + + def test_no_proxy_works_transparently(self): + """Test updating the environment without any reverse proxy.""" + fix = proxyfix.ProxyFix(create_app_stub(["127.0.0.1"])) + env = {"REMOTE_ADDR": "127.0.0.1", "HTTP_HOST": "example.com", "wsgi.url_scheme": "http"} + expected_env = copy_env(env) + fix.update_environ(env) + assert env == expected_env From 81f548d9702acc515a7c77551aa123ea21da35b1 Mon Sep 17 00:00:00 2001 From: Roman Zimmermann Date: Wed, 27 Aug 2025 10:19:38 +0200 Subject: [PATCH 2/8] feat: Remove dependency on flask --- impact_stack/proxyfix/__init__.py | 29 +++++++++++++++++------------ pyproject.toml | 1 - requirements-dev.txt | 19 +------------------ requirements.txt | 17 ----------------- tests/proxyfix_test.py | 30 ++++++++++++++---------------- 5 files changed, 32 insertions(+), 64 deletions(-) diff --git a/impact_stack/proxyfix/__init__.py b/impact_stack/proxyfix/__init__.py index 86f86da..dfbb11d 100644 --- a/impact_stack/proxyfix/__init__.py +++ b/impact_stack/proxyfix/__init__.py @@ -1,10 +1,8 @@ """WSGI ProxyFix middleware.""" -import typing +import typing as t from ipaddress import ip_address -import flask - def _split(string): return string.split(",") if string else [] @@ -25,13 +23,16 @@ class ProxyFix: `werkzeug.proxy_fix.orig_http_host`. Args: - app: The Flask application to wrap. + proxies: List of IP-addreses which’s X-Forwarded headers should be trusted. """ - def __init__(self, app: flask.Flask): - """Wrap a Flask app and read variables from its config.""" - self.wsgi = app.wsgi_app - proxies = app.config.get("PROXYFIX_TRUSTED", ["127.0.0.1"]) + @classmethod + def from_config(cls, config_getter: t.Callable[[str, t.Any], t.Any]): + """Create a new instance from a config getter function.""" + return cls(config_getter("PROXYFIX_TRUSTED", ["127.0.0.1"])) + + def __init__(self, proxies: t.Iterable[str]): + """Create a new instance by passing the list of trusted proxies.""" self.trusted = frozenset(ip_address(p.strip()) for p in proxies) def get_remote_addr(self, forwarded_for, remote): @@ -83,7 +84,11 @@ def update_environ(self, environ): if remote_addr is not None: environ["REMOTE_ADDR"] = remote_addr - def __call__(self, environ, start_response): - """Change the WSGI environment and call the wrapped app.""" - self.update_environ(environ) - return self.wsgi(environ, start_response) + def wrap(self, wsgi_app): + """Wrap a wsgi app with this middleware.""" + + def wrapped(environ, start_response): + self.update_environ(environ) + return wsgi_app(environ, start_response) + + return wrapped diff --git a/pyproject.toml b/pyproject.toml index ffe22ea..dbb8111 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,6 @@ name = "impact-stack-proxyfix" dynamic = ["version"] dependencies = [ - "flask", ] [project.optional-dependencies] diff --git a/requirements-dev.txt b/requirements-dev.txt index 95674f8..0b644ce 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,14 +8,10 @@ astroid==3.3.11 # via pylint black==25.1.0 # via impact-stack-proxyfix (pyproject.toml) -blinker==1.9.0 - # via flask cfgv==3.4.0 # via pre-commit click==8.2.1 - # via - # black - # flask + # via black coverage[toml]==7.9.2 # via pytest-cov dill==0.4.0 @@ -24,8 +20,6 @@ distlib==0.3.9 # via virtualenv filelock==3.18.0 # via virtualenv -flask==3.1.2 - # via impact-stack-proxyfix (pyproject.toml) identify==2.6.12 # via pre-commit iniconfig==2.1.0 @@ -34,15 +28,6 @@ isort==6.0.1 # via # impact-stack-proxyfix (pyproject.toml) # pylint -itsdangerous==2.2.0 - # via flask -jinja2==3.1.6 - # via flask -markupsafe==3.0.2 - # via - # flask - # jinja2 - # werkzeug mccabe==0.7.0 # via pylint mypy-extensions==1.1.0 @@ -82,5 +67,3 @@ tomlkit==0.13.3 # via pylint virtualenv==20.31.2 # via pre-commit -werkzeug==3.1.3 - # via flask diff --git a/requirements.txt b/requirements.txt index 1c51b81..3acf792 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,20 +4,3 @@ # # pip-compile --extra=hosting --output-file=requirements.txt pyproject.toml # -blinker==1.9.0 - # via flask -click==8.2.1 - # via flask -flask==3.1.2 - # via impact-stack-proxyfix (pyproject.toml) -itsdangerous==2.2.0 - # via flask -jinja2==3.1.6 - # via flask -markupsafe==3.0.2 - # via - # flask - # jinja2 - # werkzeug -werkzeug==3.1.3 - # via flask diff --git a/tests/proxyfix_test.py b/tests/proxyfix_test.py index f39c9ab..3401701 100644 --- a/tests/proxyfix_test.py +++ b/tests/proxyfix_test.py @@ -1,4 +1,4 @@ -"""Test the proxyfix middlewares.""" +"""Test the WSGI middlewares.""" from copy import copy from unittest import mock @@ -6,13 +6,10 @@ from impact_stack import proxyfix -def create_app_stub(trusted): - """Create a stub Flask app.""" - app = mock.Mock() - app.wsgi_app = None - app.config = {} - app.config["PROXYFIX_TRUSTED"] = trusted - return app +def create_config_getter(trusted: list[str]): + """Create a config getter with a given PROXYFIX_TRUSTED list.""" + config = {"PROXYFIX_TRUSTED": trusted} + return config.get def copy_env(env): @@ -29,7 +26,7 @@ class ProxyFixTest: def test_one_forwarded_for_layer(self): """Test a single trusted reverse proxy.""" - fix = proxyfix.ProxyFix(create_app_stub(["127.0.0.1"])) + fix = proxyfix.ProxyFix.from_config(create_config_getter(["127.0.0.1"])) env = { "REMOTE_ADDR": "127.0.0.1", "HTTP_HOST": "example.com", @@ -40,12 +37,13 @@ def test_one_forwarded_for_layer(self): expected_env = copy_env(env) expected_env["REMOTE_ADDR"] = "8.8.8.8" expected_env["wsgi.url_scheme"] = "https" - fix.update_environ(env) - assert env == expected_env + app = mock.Mock() + fix.wrap(app)(env, mock.Mock()) + assert app.mock_calls == [mock.call(expected_env, mock.ANY)] def test_multiple_trusted_multiple_untrusted(self): """Test getting the remote IP for multiple proxy layers.""" - fix = proxyfix.ProxyFix(create_app_stub(["127.0.0.1", "10.0.0.1"])) + fix = proxyfix.ProxyFix.from_config(create_config_getter(["127.0.0.1", "10.0.0.1"])) remote_address = fix.get_remote_addr( ["8.8.8.8", "10.0.0.1", "127.0.0.1"], "1.1.1.1", @@ -54,12 +52,12 @@ def test_multiple_trusted_multiple_untrusted(self): def test_all_trusted(self): """Test getting the remote IP with only trusted IPs.""" - fix = proxyfix.ProxyFix(create_app_stub(["127.0.0.1"])) + fix = proxyfix.ProxyFix.from_config(create_config_getter(["127.0.0.1"])) assert fix.get_remote_addr([], "127.0.0.1") == "127.0.0.1" def test_no_trusted_layer(self): """Test request from an untrusted remote.""" - fix = proxyfix.ProxyFix(create_app_stub(["127.0.0.1"])) + fix = proxyfix.ProxyFix.from_config(create_config_getter(["127.0.0.1"])) env = { "REMOTE_ADDR": "8.8.8.8", "HTTP_HOST": "example.com", @@ -74,7 +72,7 @@ def test_no_trusted_layer(self): def test_new_ip_equals_old_ip(self): """Test a local request with HTTPS.""" - fix = proxyfix.ProxyFix(create_app_stub(["127.0.0.1"])) + fix = proxyfix.ProxyFix.from_config(create_config_getter(["127.0.0.1"])) env = { "REMOTE_ADDR": "127.0.0.1", "HTTP_HOST": "example.com", @@ -89,7 +87,7 @@ def test_new_ip_equals_old_ip(self): def test_no_proxy_works_transparently(self): """Test updating the environment without any reverse proxy.""" - fix = proxyfix.ProxyFix(create_app_stub(["127.0.0.1"])) + fix = proxyfix.ProxyFix.from_config(create_config_getter(["127.0.0.1"])) env = {"REMOTE_ADDR": "127.0.0.1", "HTTP_HOST": "example.com", "wsgi.url_scheme": "http"} expected_env = copy_env(env) fix.update_environ(env) From fa84ef1e8eeb6addebe6d0cbc472f5e79f21c6d2 Mon Sep 17 00:00:00 2001 From: Roman Zimmermann Date: Wed, 27 Aug 2025 10:43:02 +0200 Subject: [PATCH 3/8] fix: Systematically test and fix get_remote_addr() --- impact_stack/proxyfix/__init__.py | 8 +++++--- tests/proxyfix_test.py | 31 +++++++++++++++++-------------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/impact_stack/proxyfix/__init__.py b/impact_stack/proxyfix/__init__.py index dfbb11d..82ddf60 100644 --- a/impact_stack/proxyfix/__init__.py +++ b/impact_stack/proxyfix/__init__.py @@ -41,13 +41,15 @@ def get_remote_addr(self, forwarded_for, remote): Values to X-Forwarded-For are expected to be appended so the inner proxy layers are to the right. The innermost untrusted IP is returned. """ - for ip_str in reversed([remote] + forwarded_for): + previous = None + for ip_str in reversed(forwarded_for + [remote]): ip_str = ip_str.strip() try: - if not ip_address(ip_str) in self.trusted: + if ip_address(ip_str) not in self.trusted: return ip_str except ValueError: - return ip_str + return previous + previous = ip_str return remote def update_environ(self, environ): diff --git a/tests/proxyfix_test.py b/tests/proxyfix_test.py index 3401701..953bbef 100644 --- a/tests/proxyfix_test.py +++ b/tests/proxyfix_test.py @@ -3,6 +3,8 @@ from copy import copy from unittest import mock +import pytest + from impact_stack import proxyfix @@ -41,20 +43,6 @@ def test_one_forwarded_for_layer(self): fix.wrap(app)(env, mock.Mock()) assert app.mock_calls == [mock.call(expected_env, mock.ANY)] - def test_multiple_trusted_multiple_untrusted(self): - """Test getting the remote IP for multiple proxy layers.""" - fix = proxyfix.ProxyFix.from_config(create_config_getter(["127.0.0.1", "10.0.0.1"])) - remote_address = fix.get_remote_addr( - ["8.8.8.8", "10.0.0.1", "127.0.0.1"], - "1.1.1.1", - ) - assert remote_address == "8.8.8.8" - - def test_all_trusted(self): - """Test getting the remote IP with only trusted IPs.""" - fix = proxyfix.ProxyFix.from_config(create_config_getter(["127.0.0.1"])) - assert fix.get_remote_addr([], "127.0.0.1") == "127.0.0.1" - def test_no_trusted_layer(self): """Test request from an untrusted remote.""" fix = proxyfix.ProxyFix.from_config(create_config_getter(["127.0.0.1"])) @@ -92,3 +80,18 @@ def test_no_proxy_works_transparently(self): expected_env = copy_env(env) fix.update_environ(env) assert env == expected_env + + @pytest.mark.parametrize( + "forwarded_for,remote,expected", + [ + ([], "8.8.8.8", "8.8.8.8"), + ([], "127.0.0.1", "127.0.0.1"), + (["127.0.0.1"], "8.8.8.8", "8.8.8.8"), + (["8.8.8.8", "10.0.0.1", "127.0.0.1"], "1.1.1.1", "1.1.1.1"), + (["no-ip"], "127.0.0.1", "127.0.0.1"), + ], + ) + def test_get_remote_addr(self, forwarded_for, remote, expected): + """Test getting the innermost non-trusted IP address.""" + fix = proxyfix.ProxyFix.from_config(create_config_getter(["127.0.0.1", "10.0.0.1"])) + assert fix.get_remote_addr(forwarded_for, remote) == expected From 8811a7b702df1ed720130e4aa901b8c26e4fd5e0 Mon Sep 17 00:00:00 2001 From: Roman Zimmermann Date: Wed, 27 Aug 2025 10:47:31 +0200 Subject: [PATCH 4/8] refactor: Assume remote_addr is trusted before calling get_remote_addr() --- impact_stack/proxyfix/__init__.py | 12 +++++------- tests/proxyfix_test.py | 16 ++++++++-------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/impact_stack/proxyfix/__init__.py b/impact_stack/proxyfix/__init__.py index 82ddf60..aae8a88 100644 --- a/impact_stack/proxyfix/__init__.py +++ b/impact_stack/proxyfix/__init__.py @@ -35,14 +35,14 @@ def __init__(self, proxies: t.Iterable[str]): """Create a new instance by passing the list of trusted proxies.""" self.trusted = frozenset(ip_address(p.strip()) for p in proxies) - def get_remote_addr(self, forwarded_for, remote): + def get_remote_addr(self, forwarded_for: list[str]): """Select the first “untrusted” remote addr. Values to X-Forwarded-For are expected to be appended so the inner proxy layers are to the right. The innermost untrusted IP is returned. """ previous = None - for ip_str in reversed(forwarded_for + [remote]): + for ip_str in reversed(forwarded_for): ip_str = ip_str.strip() try: if ip_address(ip_str) not in self.trusted: @@ -50,7 +50,7 @@ def get_remote_addr(self, forwarded_for, remote): except ValueError: return previous previous = ip_str - return remote + return previous def update_environ(self, environ): """Update the WSGI environment according to the headers.""" @@ -81,10 +81,8 @@ def update_environ(self, environ): if forwarded_proto: https = "https" in forwarded_proto.lower() environ["wsgi.url_scheme"] = "https" if https else "http" - - remote_addr = self.get_remote_addr(forwarded_for, remote_addr) - if remote_addr is not None: - environ["REMOTE_ADDR"] = remote_addr + if remote_addr := self.get_remote_addr(forwarded_for): + environ["REMOTE_ADDR"] = remote_addr def wrap(self, wsgi_app): """Wrap a wsgi app with this middleware.""" diff --git a/tests/proxyfix_test.py b/tests/proxyfix_test.py index 953bbef..1607144 100644 --- a/tests/proxyfix_test.py +++ b/tests/proxyfix_test.py @@ -82,16 +82,16 @@ def test_no_proxy_works_transparently(self): assert env == expected_env @pytest.mark.parametrize( - "forwarded_for,remote,expected", + "forwarded_for,expected", [ - ([], "8.8.8.8", "8.8.8.8"), - ([], "127.0.0.1", "127.0.0.1"), - (["127.0.0.1"], "8.8.8.8", "8.8.8.8"), - (["8.8.8.8", "10.0.0.1", "127.0.0.1"], "1.1.1.1", "1.1.1.1"), - (["no-ip"], "127.0.0.1", "127.0.0.1"), + (["8.8.8.8"], "8.8.8.8"), + (["127.0.0.1"], "127.0.0.1"), + (["127.0.0.1", "8.8.8.8"], "8.8.8.8"), + (["8.8.8.8", "10.0.0.1", "127.0.0.1", "1.1.1.1"], "1.1.1.1"), + (["no-ip", "127.0.0.1"], "127.0.0.1"), ], ) - def test_get_remote_addr(self, forwarded_for, remote, expected): + def test_get_remote_addr(self, forwarded_for, expected): """Test getting the innermost non-trusted IP address.""" fix = proxyfix.ProxyFix.from_config(create_config_getter(["127.0.0.1", "10.0.0.1"])) - assert fix.get_remote_addr(forwarded_for, remote) == expected + assert fix.get_remote_addr(forwarded_for) == expected From c1e1e2def912bc6261a0f582e6f37ce8fc1cdfe7 Mon Sep 17 00:00:00 2001 From: Roman Zimmermann Date: Wed, 27 Aug 2025 11:02:26 +0200 Subject: [PATCH 5/8] refactor: simplify update_environ --- impact_stack/proxyfix/__init__.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/impact_stack/proxyfix/__init__.py b/impact_stack/proxyfix/__init__.py index aae8a88..28ac9fd 100644 --- a/impact_stack/proxyfix/__init__.py +++ b/impact_stack/proxyfix/__init__.py @@ -64,9 +64,6 @@ def update_environ(self, environ): except ValueError: remote_addr_ip = ip_address("127.0.0.1") - forwarded_proto = env("HTTP_X_FORWARDED_PROTO", "") - forwarded_for = _split(env("HTTP_X_FORWARDED_FOR", "")) - forwarded_host = env("HTTP_X_FORWARDED_HOST", "") environ.update( { "werkzeug.proxy_fix.orig_wsgi_url_scheme": env("wsgi.url_scheme"), @@ -76,11 +73,12 @@ def update_environ(self, environ): ) if remote_addr_ip in self.trusted: - if forwarded_host: + if forwarded_host := env("HTTP_X_FORWARDED_HOST", ""): environ["HTTP_HOST"] = forwarded_host - if forwarded_proto: + if forwarded_proto := env("HTTP_X_FORWARDED_PROTO", ""): https = "https" in forwarded_proto.lower() environ["wsgi.url_scheme"] = "https" if https else "http" + forwarded_for = _split(env("HTTP_X_FORWARDED_FOR", "")) if remote_addr := self.get_remote_addr(forwarded_for): environ["REMOTE_ADDR"] = remote_addr From 0636481da8b8f7b713aec4d6dbb27853a0de13d2 Mon Sep 17 00:00:00 2001 From: Roman Zimmermann Date: Wed, 27 Aug 2025 11:04:26 +0200 Subject: [PATCH 6/8] build: Add github config --- .github/workflows/test.yaml | 39 +++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/test.yaml diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..8cfa0db --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,39 @@ +name: Run tests + +on: + push: + branches: + - "main" + pull_request: + branches: + - "main" + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + # Run in all these versions of Python + python-version: ["3.11", "3.13"] + + steps: + # Checkout the latest code from the repo + - name: Checkout repo + uses: actions/checkout@v3 + # Setup which version of Python to use + - name: Set Up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache: pip + # Display the Python version being used + - name: Display Python version + run: python -c "import sys; print(sys.version)" + # Install the package. + - name: Install package + run: pip install -r requirements-dev.txt; pip install -e . + - name: Run tests + run: pytest + - name: Run linters + uses: pre-commit/action@v3.0.0 From 1ef61d9000cedf81d80e3c5092fefbc5010054d4 Mon Sep 17 00:00:00 2001 From: Roman Zimmermann Date: Wed, 27 Aug 2025 11:10:58 +0200 Subject: [PATCH 7/8] doc: Add a simple README --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index e69de29..6c68934 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,22 @@ +# WSGI Proxyfix + +This is a modified version of the ProxyFix found in werkzeug. Instead of counting the number of trusted proxies it peels away all the “trusted” IP-addresses until it arrives at the first untrusted one. + + +## Usage + +```python +import proxyfix from impact_stack + +# Flask +app.wsgi_app = proxyfix.ProxyFix.from_config(app.config.get).wrap(app.wsgi_app) + +# Django +import functools +from django.conf import settings +from django.core.wsgi import wsgi_application + +application = get_wsgi_application() +config_getter = functools.partial(getattr, settings) +application = proxyfix.ProxyFix.from_config(config_getter).wrap(application) +``` From d9b9b21559fefe7d8618c8180e701d944d324102 Mon Sep 17 00:00:00 2001 From: Roman Zimmermann Date: Wed, 27 Aug 2025 11:14:52 +0200 Subject: [PATCH 8/8] build: Add dev dependency on twine for publishing --- pyproject.toml | 1 + requirements-dev.txt | 71 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index dbb8111..f5cf6a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dev = [ "pylint", "pytest", "pytest-cov", + "twine", ] [build-system] diff --git a/requirements-dev.txt b/requirements-dev.txt index 0b644ce..727efd3 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,38 +6,79 @@ # astroid==3.3.11 # via pylint +backports-tarfile==1.2.0 + # via jaraco-context black==25.1.0 # via impact-stack-proxyfix (pyproject.toml) +certifi==2025.8.3 + # via requests +cffi==1.17.1 + # via cryptography cfgv==3.4.0 # via pre-commit +charset-normalizer==3.4.3 + # via requests click==8.2.1 # via black coverage[toml]==7.9.2 # via pytest-cov +cryptography==45.0.6 + # via secretstorage dill==0.4.0 # via pylint distlib==0.3.9 # via virtualenv +docutils==0.22 + # via readme-renderer filelock==3.18.0 # via virtualenv +id==1.5.0 + # via twine identify==2.6.12 # via pre-commit +idna==3.10 + # via requests +importlib-metadata==8.7.0 + # via keyring iniconfig==2.1.0 # via pytest isort==6.0.1 # via # impact-stack-proxyfix (pyproject.toml) # pylint +jaraco-classes==3.4.0 + # via keyring +jaraco-context==6.0.1 + # via keyring +jaraco-functools==4.3.0 + # via keyring +jeepney==0.9.0 + # via + # keyring + # secretstorage +keyring==25.6.0 + # via twine +markdown-it-py==4.0.0 + # via rich mccabe==0.7.0 # via pylint +mdurl==0.1.2 + # via markdown-it-py +more-itertools==10.7.0 + # via + # jaraco-classes + # jaraco-functools mypy-extensions==1.1.0 # via black +nh3==0.3.0 + # via readme-renderer nodeenv==1.9.1 # via pre-commit packaging==25.0 # via # black # pytest + # twine pathspec==0.12.1 # via black platformdirs==4.3.8 @@ -51,8 +92,13 @@ pluggy==1.6.0 # pytest-cov pre-commit==4.2.0 # via impact-stack-proxyfix (pyproject.toml) +pycparser==2.22 + # via cffi pygments==2.19.2 - # via pytest + # via + # pytest + # readme-renderer + # rich pylint==3.3.7 # via impact-stack-proxyfix (pyproject.toml) pytest==8.4.1 @@ -63,7 +109,30 @@ pytest-cov==6.2.1 # via impact-stack-proxyfix (pyproject.toml) pyyaml==6.0.2 # via pre-commit +readme-renderer==44.0 + # via twine +requests==2.32.5 + # via + # id + # requests-toolbelt + # twine +requests-toolbelt==1.0.0 + # via twine +rfc3986==2.0.0 + # via twine +rich==14.1.0 + # via twine +secretstorage==3.3.3 + # via keyring tomlkit==0.13.3 # via pylint +twine==6.1.0 + # via impact-stack-proxyfix (pyproject.toml) +urllib3==2.5.0 + # via + # requests + # twine virtualenv==20.31.2 # via pre-commit +zipp==3.23.0 + # via importlib-metadata