Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
```
93 changes: 91 additions & 2 deletions impact_stack/proxyfix/__init__.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ dev = [
"pylint",
"pytest",
"pytest-cov",
"twine",
]

[build-system]
Expand Down
71 changes: 70 additions & 1 deletion requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
2 changes: 1 addition & 1 deletion tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
"""Tests for the boilerplate_project."""
"""Tests for the impact-stack-rest library."""
8 changes: 0 additions & 8 deletions tests/example_test.py

This file was deleted.

Loading