@@ -171,3 +171,241 @@ async def test_non_standard_port(mock_request):
171171 handler = ReverseProxyHandler (upstream = "http://upstream-api.com:8080" )
172172 headers = handler ._prepare_headers (mock_request )
173173 assert headers ["Host" ] == "upstream-api.com:8080"
174+
175+
176+ @pytest .mark .asyncio
177+ async def test_nginx_proxy_headers_preserved ():
178+ """Test that existing proxy headers from NGINX are preserved."""
179+ # Simulate a request that already has proxy headers set by NGINX
180+ scope = {
181+ "type" : "http" ,
182+ "method" : "GET" ,
183+ "path" : "/test" ,
184+ "headers" : [
185+ (b"host" , b"localhost:8000" ),
186+ (b"user-agent" , b"test-agent" ),
187+ (b"x-forwarded-for" , b"203.0.113.1, 198.51.100.1" ),
188+ (b"x-forwarded-proto" , b"https" ),
189+ (b"x-forwarded-host" , b"api.example.com" ),
190+ (b"x-forwarded-path" , b"/api/v1" ),
191+ ],
192+ }
193+ request = Request (scope )
194+ handler = ReverseProxyHandler (upstream = "http://upstream-api.com" )
195+ headers = handler ._prepare_headers (request )
196+
197+ # Check that the existing proxy headers are preserved in the Forwarded header
198+ forwarded = headers ["Forwarded" ]
199+ assert "for=203.0.113.1, 198.51.100.1" in forwarded
200+ assert "host=api.example.com" in forwarded
201+ assert "proto=https" in forwarded
202+ assert "path=/api/v1" in forwarded
203+
204+ # The original headers should still be present (they're preserved from the request)
205+ assert headers ["X-Forwarded-For" ] == "203.0.113.1, 198.51.100.1"
206+ assert headers ["X-Forwarded-Host" ] == "api.example.com"
207+ assert headers ["X-Forwarded-Proto" ] == "https"
208+ assert headers ["X-Forwarded-Path" ] == "/api/v1"
209+
210+
211+ @pytest .mark .asyncio
212+ async def test_nginx_proxy_headers_preserved_with_legacy ():
213+ """Test that existing proxy headers from NGINX are preserved with legacy mode."""
214+ # Simulate a request that already has proxy headers set by NGINX
215+ scope = {
216+ "type" : "http" ,
217+ "method" : "GET" ,
218+ "path" : "/test" ,
219+ "headers" : [
220+ (b"host" , b"localhost:8000" ),
221+ (b"user-agent" , b"test-agent" ),
222+ (b"x-forwarded-for" , b"203.0.113.1, 198.51.100.1" ),
223+ (b"x-forwarded-proto" , b"https" ),
224+ (b"x-forwarded-host" , b"api.example.com" ),
225+ (b"x-forwarded-path" , b"/api/v1" ),
226+ ],
227+ }
228+ request = Request (scope )
229+ handler = ReverseProxyHandler (
230+ upstream = "http://upstream-api.com" , legacy_forwarded_headers = True
231+ )
232+ headers = handler ._prepare_headers (request )
233+
234+ # Check that the existing proxy headers are preserved in both formats
235+ forwarded = headers ["Forwarded" ]
236+ assert "for=203.0.113.1, 198.51.100.1" in forwarded
237+ assert "host=api.example.com" in forwarded
238+ assert "proto=https" in forwarded
239+ assert "path=/api/v1" in forwarded
240+
241+ # Legacy headers should also be preserved
242+ assert headers ["X-Forwarded-For" ] == "203.0.113.1, 198.51.100.1"
243+ assert headers ["X-Forwarded-Host" ] == "api.example.com"
244+ assert headers ["X-Forwarded-Proto" ] == "https"
245+ assert headers ["X-Forwarded-Path" ] == "/api/v1"
246+
247+
248+ @pytest .mark .asyncio
249+ async def test_partial_nginx_headers_fallback ():
250+ """Test fallback behavior when only some proxy headers are present."""
251+ # Simulate a request with only some proxy headers set by NGINX
252+ scope = {
253+ "type" : "http" ,
254+ "method" : "GET" ,
255+ "path" : "/test" ,
256+ "headers" : [
257+ (b"host" , b"localhost:8000" ),
258+ (b"user-agent" , b"test-agent" ),
259+ (b"x-forwarded-for" , b"203.0.113.1" ),
260+ (b"x-forwarded-proto" , b"https" ),
261+ # Missing X-Forwarded-Host and X-Forwarded-Path
262+ ],
263+ }
264+ request = Request (scope )
265+ handler = ReverseProxyHandler (upstream = "http://upstream-api.com" )
266+ headers = handler ._prepare_headers (request )
267+
268+ # Check that existing headers are preserved and missing ones fall back to request values
269+ forwarded = headers ["Forwarded" ]
270+ assert "for=203.0.113.1" in forwarded # From existing header
271+ assert "host=localhost:8000" in forwarded # Fallback to request host
272+ assert "proto=https" in forwarded # From existing header
273+ assert "path=/" in forwarded # Fallback to request path
274+
275+
276+ @pytest .mark .asyncio
277+ async def test_nginx_headers_with_client_info ():
278+ """Test that NGINX headers take precedence over client info."""
279+ # Simulate a request with both client info and existing proxy headers
280+ scope = {
281+ "type" : "http" ,
282+ "method" : "GET" ,
283+ "path" : "/test" ,
284+ "client" : ("192.168.1.1" , 12345 ), # This should be ignored
285+ "headers" : [
286+ (b"host" , b"localhost:8000" ),
287+ (b"user-agent" , b"test-agent" ),
288+ (b"x-forwarded-for" , b"203.0.113.1, 198.51.100.1" ),
289+ ],
290+ }
291+ request = Request (scope )
292+ handler = ReverseProxyHandler (upstream = "http://upstream-api.com" )
293+ headers = handler ._prepare_headers (request )
294+
295+ # The existing X-Forwarded-For should take precedence over client info
296+ forwarded = headers ["Forwarded" ]
297+ assert "for=203.0.113.1, 198.51.100.1" in forwarded
298+ assert "for=192.168.1.1" not in forwarded
299+
300+
301+ @pytest .mark .asyncio
302+ async def test_nginx_headers_with_https_scheme ():
303+ """Test that NGINX headers take precedence over request scheme."""
304+ # Simulate an HTTPS request with existing proxy headers
305+ scope = {
306+ "type" : "http" ,
307+ "method" : "GET" ,
308+ "path" : "/test" ,
309+ "scheme" : "https" , # This should be ignored
310+ "headers" : [
311+ (b"host" , b"localhost:8000" ),
312+ (b"user-agent" , b"test-agent" ),
313+ (b"x-forwarded-proto" , b"http" ), # NGINX says it's HTTP
314+ ],
315+ }
316+ request = Request (scope )
317+ handler = ReverseProxyHandler (upstream = "http://upstream-api.com" )
318+ headers = handler ._prepare_headers (request )
319+
320+ # The existing X-Forwarded-Proto should take precedence over request scheme
321+ forwarded = headers ["Forwarded" ]
322+ assert "proto=http" in forwarded # From existing header
323+ assert "proto=https" not in forwarded
324+
325+
326+ @pytest .mark .asyncio
327+ async def test_nginx_headers_with_custom_path ():
328+ """Test that NGINX headers take precedence over request path."""
329+ # Simulate a request with a custom path and existing proxy headers
330+ scope = {
331+ "type" : "http" ,
332+ "method" : "GET" ,
333+ "path" : "/custom/path" ,
334+ "headers" : [
335+ (b"host" , b"localhost:8000" ),
336+ (b"user-agent" , b"test-agent" ),
337+ (b"x-forwarded-path" , b"/api/v1/root" ), # NGINX says different path
338+ ],
339+ }
340+ request = Request (scope )
341+ handler = ReverseProxyHandler (upstream = "http://upstream-api.com" )
342+ headers = handler ._prepare_headers (request )
343+
344+ # The existing X-Forwarded-Path should take precedence over request path
345+ forwarded = headers ["Forwarded" ]
346+ assert "path=/api/v1/root" in forwarded # From existing header
347+ assert "path=/custom/path" not in forwarded
348+
349+
350+ @pytest .mark .asyncio
351+ async def test_nginx_headers_legacy_mode_preservation ():
352+ """Test that NGINX headers are preserved in legacy mode without duplication."""
353+ # Simulate a request that already has proxy headers set by NGINX
354+ scope = {
355+ "type" : "http" ,
356+ "method" : "GET" ,
357+ "path" : "/test" ,
358+ "headers" : [
359+ (b"host" , b"localhost:8000" ),
360+ (b"user-agent" , b"test-agent" ),
361+ (b"x-forwarded-for" , b"203.0.113.1" ),
362+ (b"x-forwarded-proto" , b"https" ),
363+ (b"x-forwarded-host" , b"api.example.com" ),
364+ (b"x-forwarded-path" , b"/api/v1" ),
365+ ],
366+ }
367+ request = Request (scope )
368+ handler = ReverseProxyHandler (
369+ upstream = "http://upstream-api.com" , legacy_forwarded_headers = True
370+ )
371+ headers = handler ._prepare_headers (request )
372+
373+ # Check that headers are preserved (not duplicated or overwritten)
374+ assert headers ["X-Forwarded-For" ] == "203.0.113.1"
375+ assert headers ["X-Forwarded-Host" ] == "api.example.com"
376+ assert headers ["X-Forwarded-Proto" ] == "https"
377+ assert headers ["X-Forwarded-Path" ] == "/api/v1"
378+
379+ # Modern Forwarded header should also be present with the same values
380+ forwarded = headers ["Forwarded" ]
381+ assert "for=203.0.113.1" in forwarded
382+ assert "host=api.example.com" in forwarded
383+ assert "proto=https" in forwarded
384+ assert "path=/api/v1" in forwarded
385+
386+
387+ @pytest .mark .asyncio
388+ async def test_nginx_headers_case_insensitive ():
389+ """Test that NGINX headers are handled case-insensitively."""
390+ # Simulate a request with mixed case proxy headers (some proxies do this)
391+ scope = {
392+ "type" : "http" ,
393+ "method" : "GET" ,
394+ "path" : "/test" ,
395+ "headers" : [
396+ (b"host" , b"localhost:8000" ),
397+ (b"user-agent" , b"test-agent" ),
398+ (b"X-Forwarded-For" , b"203.0.113.1" ), # Mixed case
399+ (b"x-forwarded-proto" , b"https" ), # Lower case
400+ (b"X-FORWARDED-HOST" , b"api.example.com" ), # Upper case
401+ ],
402+ }
403+ request = Request (scope )
404+ handler = ReverseProxyHandler (upstream = "http://upstream-api.com" )
405+ headers = handler ._prepare_headers (request )
406+
407+ # All headers should be preserved regardless of case
408+ forwarded = headers ["Forwarded" ]
409+ assert "for=203.0.113.1" in forwarded
410+ assert "host=api.example.com" in forwarded
411+ assert "proto=https" in forwarded
0 commit comments