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 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) +``` diff --git a/impact_stack/proxyfix/__init__.py b/impact_stack/proxyfix/__init__.py index 0a32972..28ac9fd 100644 --- a/impact_stack/proxyfix/__init__.py +++ b/impact_stack/proxyfix/__init__.py @@ -1,3 +1,92 @@ -"""Boilerplate project module.""" +"""WSGI ProxyFix middleware.""" -HELLO = "hello world" +import typing as t +from ipaddress import ip_address + + +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: + proxies: List of IP-addreses which’s X-Forwarded headers should be trusted. + """ + + @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: 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): + ip_str = ip_str.strip() + try: + if ip_address(ip_str) not in self.trusted: + return ip_str + except ValueError: + return previous + previous = ip_str + return previous + + 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") + + 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 := env("HTTP_X_FORWARDED_HOST", ""): + environ["HTTP_HOST"] = forwarded_host + 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 + + 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 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 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..1607144 --- /dev/null +++ b/tests/proxyfix_test.py @@ -0,0 +1,97 @@ +"""Test the WSGI middlewares.""" + +from copy import copy +from unittest import mock + +import pytest + +from impact_stack import proxyfix + + +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): + """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.from_config(create_config_getter(["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" + app = mock.Mock() + fix.wrap(app)(env, mock.Mock()) + assert app.mock_calls == [mock.call(expected_env, mock.ANY)] + + def test_no_trusted_layer(self): + """Test request from an untrusted remote.""" + fix = proxyfix.ProxyFix.from_config(create_config_getter(["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.from_config(create_config_getter(["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.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) + assert env == expected_env + + @pytest.mark.parametrize( + "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"), + ], + ) + 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) == expected