Skip to content

Commit 2961a48

Browse files
authored
feat: proxy headers, override host (#49)
Enable the configuring of whether or not the Proxy will override the `host` header, making the upstream API believe that the request was sent to the API (important if the upstream sits behind a reverse proxy like NGINX). Additionally, ensure that common proxy headers are set. closes #34
1 parent de6a946 commit 2961a48

File tree

5 files changed

+212
-4
lines changed

5 files changed

+212
-4
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ The application is configurable via environment variables.
8383
- **Type:** string
8484
- **Required:** No, defaults to `/healthz`
8585
- **Example:** `''` (disabled)
86+
- **`OVERRIDE_HOST`**, override the host header for the upstream API
87+
- **Type:** boolean
88+
- **Required:** No, defaults to `true`
89+
- **Example:** `false`, `1`, `True`
8690
- Authentication
8791
- **`OIDC_DISCOVERY_URL`**, OpenID Connect discovery document URL
8892
- **Type:** HTTP(S) URL

src/stac_auth_proxy/app.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,10 @@ async def lifespan(app: FastAPI):
8080

8181
app.add_api_route(
8282
"/{path:path}",
83-
ReverseProxyHandler(upstream=str(settings.upstream_url)).proxy_request,
83+
ReverseProxyHandler(
84+
upstream=str(settings.upstream_url),
85+
override_host=settings.override_host,
86+
).proxy_request,
8487
methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
8588
)
8689

src/stac_auth_proxy/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class Settings(BaseSettings):
3838
oidc_discovery_url: HttpUrl
3939
oidc_discovery_internal_url: HttpUrl
4040

41+
override_host: bool = True
4142
healthz_prefix: str = Field(pattern=_PREFIX_PATTERN, default="/healthz")
4243
wait_for_upstream: bool = True
4344
check_conformance: bool = True

src/stac_auth_proxy/handlers/reverse_proxy.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ class ReverseProxyHandler:
2020
client: httpx.AsyncClient = None
2121
timeout: httpx.Timeout = field(default_factory=lambda: httpx.Timeout(timeout=15.0))
2222

23+
proxy_name: str = "stac-auth-proxy"
24+
override_host: bool = True
25+
legacy_forwarded_headers: bool = False
26+
2327
def __post_init__(self):
2428
"""Initialize the HTTP client."""
2529
self.client = self.client or httpx.AsyncClient(
@@ -28,11 +32,34 @@ def __post_init__(self):
2832
http2=True,
2933
)
3034

35+
def _prepare_headers(self, request: Request) -> MutableHeaders:
36+
"""Prepare headers for the proxied request."""
37+
headers = MutableHeaders(request.headers)
38+
headers.setdefault("Via", f"1.1 {self.proxy_name}")
39+
40+
proxy_client = request.client.host if request.client else "unknown"
41+
proxy_proto = request.url.scheme
42+
proxy_host = request.url.netloc
43+
proxy_path = request.base_url.path
44+
headers.setdefault(
45+
"Forwarded",
46+
f"for={proxy_client};host={proxy_host};proto={proxy_proto};path={proxy_path}",
47+
)
48+
if self.legacy_forwarded_headers:
49+
headers.setdefault("X-Forwarded-For", proxy_client)
50+
headers.setdefault("X-Forwarded-Host", proxy_host)
51+
headers.setdefault("X-Forwarded-Path", proxy_path)
52+
headers.setdefault("X-Forwarded-Proto", proxy_proto)
53+
54+
# Set host to the upstream host
55+
if self.override_host:
56+
headers["Host"] = self.client.base_url.netloc.decode("utf-8")
57+
58+
return headers
59+
3160
async def proxy_request(self, request: Request) -> Response:
3261
"""Proxy a request to the upstream STAC API."""
33-
headers = MutableHeaders(request.headers)
34-
headers.setdefault("X-Forwarded-For", request.client.host)
35-
headers.setdefault("X-Forwarded-Host", request.url.hostname)
62+
headers = self._prepare_headers(request)
3663

3764
# https://github.com/fastapi/fastapi/discussions/7382#discussioncomment-5136466
3865
rp_req = self.client.build_request(

tests/test_reverse_proxy.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"""Tests for the reverse proxy handler's header functionality."""
2+
3+
import pytest
4+
from fastapi import Request
5+
6+
from stac_auth_proxy.handlers.reverse_proxy import ReverseProxyHandler
7+
8+
9+
@pytest.fixture
10+
def mock_request():
11+
"""Create a mock FastAPI request."""
12+
scope = {
13+
"type": "http",
14+
"method": "GET",
15+
"path": "/test",
16+
"headers": [
17+
(b"host", b"localhost:8000"),
18+
(b"user-agent", b"test-agent"),
19+
(b"accept", b"application/json"),
20+
],
21+
}
22+
return Request(scope)
23+
24+
25+
@pytest.fixture
26+
def reverse_proxy_handler():
27+
"""Create a reverse proxy handler instance."""
28+
return ReverseProxyHandler(upstream="http://upstream-api.com")
29+
30+
31+
@pytest.mark.asyncio
32+
async def test_basic_headers(mock_request, reverse_proxy_handler):
33+
"""Test that basic headers are properly set."""
34+
headers = reverse_proxy_handler._prepare_headers(mock_request)
35+
36+
# Check standard headers
37+
assert headers["Host"] == "upstream-api.com"
38+
assert headers["User-Agent"] == "test-agent"
39+
assert headers["Accept"] == "application/json"
40+
41+
# Check modern forwarded header
42+
assert "Forwarded" in headers
43+
forwarded = headers["Forwarded"]
44+
assert "for=unknown" in forwarded
45+
assert "host=localhost:8000" in forwarded
46+
assert "proto=http" in forwarded
47+
assert "path=/" in forwarded
48+
49+
# Check Via header
50+
assert headers["Via"] == "1.1 stac-auth-proxy"
51+
52+
# Legacy headers should not be present by default
53+
assert "X-Forwarded-For" not in headers
54+
assert "X-Forwarded-Host" not in headers
55+
assert "X-Forwarded-Proto" not in headers
56+
assert "X-Forwarded-Path" not in headers
57+
58+
59+
@pytest.mark.asyncio
60+
async def test_legacy_forwarded_headers(mock_request):
61+
"""Test that legacy X-Forwarded-* headers are set when enabled."""
62+
handler = ReverseProxyHandler(
63+
upstream="http://upstream-api.com", legacy_forwarded_headers=True
64+
)
65+
headers = handler._prepare_headers(mock_request)
66+
67+
# Check legacy headers
68+
assert headers["X-Forwarded-For"] == "unknown"
69+
assert headers["X-Forwarded-Host"] == "localhost:8000"
70+
assert headers["X-Forwarded-Proto"] == "http"
71+
assert headers["X-Forwarded-Path"] == "/"
72+
73+
# Modern Forwarded header should still be present
74+
assert "Forwarded" in headers
75+
76+
77+
@pytest.mark.asyncio
78+
async def test_override_host_disabled(mock_request):
79+
"""Test that host override can be disabled."""
80+
handler = ReverseProxyHandler(
81+
upstream="http://upstream-api.com", override_host=False
82+
)
83+
headers = handler._prepare_headers(mock_request)
84+
assert headers["Host"] == "localhost:8000"
85+
86+
87+
@pytest.mark.asyncio
88+
async def test_custom_proxy_name(mock_request):
89+
"""Test that custom proxy name is used in Via header."""
90+
handler = ReverseProxyHandler(
91+
upstream="http://upstream-api.com", proxy_name="custom-proxy"
92+
)
93+
headers = handler._prepare_headers(mock_request)
94+
assert headers["Via"] == "1.1 custom-proxy"
95+
96+
97+
@pytest.mark.asyncio
98+
async def test_forwarded_headers_with_client(mock_request):
99+
"""Test forwarded headers when client information is available."""
100+
# Add client information to the request
101+
mock_request.scope["client"] = ("192.168.1.1", 12345)
102+
handler = ReverseProxyHandler(upstream="http://upstream-api.com")
103+
headers = handler._prepare_headers(mock_request)
104+
105+
# Check modern Forwarded header
106+
forwarded = headers["Forwarded"]
107+
assert "for=192.168.1.1" in forwarded
108+
assert "host=localhost:8000" in forwarded
109+
assert "proto=http" in forwarded
110+
assert "path=/" in forwarded
111+
112+
# Legacy headers should not be present by default
113+
assert "X-Forwarded-For" not in headers
114+
assert "X-Forwarded-Host" not in headers
115+
assert "X-Forwarded-Proto" not in headers
116+
assert "X-Forwarded-Path" not in headers
117+
118+
119+
@pytest.mark.asyncio
120+
async def test_legacy_forwarded_headers_with_client(mock_request):
121+
"""Test legacy forwarded headers when client information is available."""
122+
mock_request.scope["client"] = ("192.168.1.1", 12345)
123+
handler = ReverseProxyHandler(
124+
upstream="http://upstream-api.com", legacy_forwarded_headers=True
125+
)
126+
headers = handler._prepare_headers(mock_request)
127+
128+
# Check legacy headers
129+
assert headers["X-Forwarded-For"] == "192.168.1.1"
130+
assert headers["X-Forwarded-Host"] == "localhost:8000"
131+
assert headers["X-Forwarded-Proto"] == "http"
132+
assert headers["X-Forwarded-Path"] == "/"
133+
134+
# Modern Forwarded header should still be present
135+
assert "Forwarded" in headers
136+
137+
138+
@pytest.mark.asyncio
139+
async def test_https_proto(mock_request):
140+
"""Test that X-Forwarded-Proto is set correctly for HTTPS."""
141+
mock_request.scope["scheme"] = "https"
142+
handler = ReverseProxyHandler(upstream="http://upstream-api.com")
143+
headers = handler._prepare_headers(mock_request)
144+
145+
# Check modern Forwarded header
146+
assert "proto=https" in headers["Forwarded"]
147+
148+
# Legacy headers should not be present by default
149+
assert "X-Forwarded-Proto" not in headers
150+
151+
152+
@pytest.mark.asyncio
153+
async def test_https_proto_legacy(mock_request):
154+
"""Test that X-Forwarded-Proto is set correctly for HTTPS with legacy headers."""
155+
mock_request.scope["scheme"] = "https"
156+
handler = ReverseProxyHandler(
157+
upstream="http://upstream-api.com", legacy_forwarded_headers=True
158+
)
159+
headers = handler._prepare_headers(mock_request)
160+
assert headers["X-Forwarded-Proto"] == "https"
161+
assert "proto=https" in headers["Forwarded"]
162+
163+
164+
@pytest.mark.asyncio
165+
async def test_non_standard_port(mock_request):
166+
"""Test handling of non-standard ports in host header."""
167+
mock_request.scope["headers"] = [
168+
(b"host", b"localhost:8080"),
169+
(b"user-agent", b"test-agent"),
170+
]
171+
handler = ReverseProxyHandler(upstream="http://upstream-api.com:8080")
172+
headers = handler._prepare_headers(mock_request)
173+
assert headers["Host"] == "upstream-api.com:8080"

0 commit comments

Comments
 (0)