Skip to content

Commit 9700ed9

Browse files
committed
Adding header removal handler
1 parent f0a220d commit 9700ed9

3 files changed

Lines changed: 259 additions & 38 deletions

File tree

packages/http/httpx/kiota_http/middleware/options/redirect_handler_option.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,40 @@
1+
from typing import Callable, Optional
12
from kiota_abstractions.request_option import RequestOption
23

4+
import httpx
5+
6+
# Type alias for the scrub sensitive headers callback
7+
ScrubSensitiveHeadersCallback = Callable[[httpx.Headers, httpx.URL, httpx.URL], None]
8+
9+
10+
def default_scrub_sensitive_headers(
11+
headers: httpx.Headers, original_url: httpx.URL, new_url: httpx.URL
12+
) -> None:
13+
"""
14+
The default implementation for scrubbing sensitive headers during redirects.
15+
This method removes Authorization and Cookie headers when the host or scheme changes.
16+
Args:
17+
headers: The headers object to modify
18+
original_url: The original request URL
19+
new_url: The new redirect URL
20+
"""
21+
if not headers or not original_url or not new_url:
22+
return
23+
24+
# Remove Authorization and Cookie headers if the request's scheme or host changes
25+
is_different_host_or_scheme = (
26+
original_url.host != new_url.host or original_url.scheme != new_url.scheme
27+
)
28+
29+
if is_different_host_or_scheme:
30+
headers.pop("Authorization", None)
31+
headers.pop("Cookie", None)
32+
33+
# Note: Proxy-Authorization is not handled here as proxy configuration in httpx
34+
# is managed at the transport level and not accessible to middleware.
35+
# In environments where this matters, the proxy configuration should be managed
36+
# at the HTTP client level.
37+
338

439
class RedirectHandlerOption(RequestOption):
540

@@ -15,7 +50,8 @@ def __init__(
1550
self,
1651
max_redirect: int = DEFAULT_MAX_REDIRECT,
1752
should_redirect: bool = True,
18-
allow_redirect_on_scheme_change: bool = False
53+
allow_redirect_on_scheme_change: bool = False,
54+
scrub_sensitive_headers: Optional[ScrubSensitiveHeadersCallback] = None
1955
) -> None:
2056

2157
if max_redirect > self.MAX_MAX_REDIRECT:
@@ -28,6 +64,7 @@ def __init__(
2864
self._max_redirect = max_redirect
2965
self._should_redirect = should_redirect
3066
self._allow_redirect_on_scheme_change = allow_redirect_on_scheme_change
67+
self._scrub_sensitive_headers = scrub_sensitive_headers or default_scrub_sensitive_headers
3168

3269
@property
3370
def max_redirect(self):
@@ -59,6 +96,16 @@ def allow_redirect_on_scheme_change(self):
5996
def allow_redirect_on_scheme_change(self, value: bool):
6097
self._allow_redirect_on_scheme_change = value
6198

99+
@property
100+
def scrub_sensitive_headers(self) -> ScrubSensitiveHeadersCallback:
101+
"""The callback for scrubbing sensitive headers during redirects.
102+
Defaults to default_scrub_sensitive_headers."""
103+
return self._scrub_sensitive_headers
104+
105+
@scrub_sensitive_headers.setter
106+
def scrub_sensitive_headers(self, value: ScrubSensitiveHeadersCallback):
107+
self._scrub_sensitive_headers = value
108+
62109
@staticmethod
63110
def get_key() -> str:
64111
return RedirectHandlerOption.REDIRECT_HANDLER_OPTION_KEY

packages/http/httpx/kiota_http/middleware/redirect_handler.py

Lines changed: 6 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ class RedirectHandler(BaseMiddleware):
2626
}
2727
STATUS_CODE_SEE_OTHER: int = 303
2828
LOCATION_HEADER: str = "Location"
29-
AUTHORIZATION_HEADER: str = "Authorization"
3029

3130
def __init__(self, options: RedirectHandlerOption = RedirectHandlerOption()) -> None:
3231
super().__init__()
@@ -125,7 +124,7 @@ def _build_redirect_request(
125124
"""
126125
method = self._redirect_method(request, response)
127126
url = self._redirect_url(request, response, options)
128-
headers = self._redirect_headers(request, url, method)
127+
headers = self._redirect_headers(request, url, method, options)
129128
stream = self._redirect_stream(request, method)
130129
new_request = httpx.Request(
131130
method=method,
@@ -202,20 +201,18 @@ def _redirect_url(
202201
return url
203202

204203
def _redirect_headers(
205-
self, request: httpx.Request, url: httpx.URL, method: str
204+
self, request: httpx.Request, url: httpx.URL, method: str, options: RedirectHandlerOption
206205
) -> httpx.Headers:
207206
"""
208207
Return the headers that should be used for the redirect request.
209208
"""
210209
headers = httpx.Headers(request.headers)
211210

212-
if not self._same_origin(url, request.url):
213-
if not self.is_https_redirect(request.url, url):
214-
# Strip Authorization headers when responses are redirected
215-
# away from the origin. (Except for direct HTTP to HTTPS redirects.)
216-
headers.pop("Authorization", None)
211+
# Scrub sensitive headers before following the redirect
212+
options.scrub_sensitive_headers(headers, request.url, url)
217213

218-
# Update the Host header.
214+
# Update the Host header if not same origin
215+
if not self._same_origin(url, request.url):
219216
headers["Host"] = url.netloc.decode("ascii")
220217

221218
if method != request.method and method == "GET":
@@ -224,10 +221,6 @@ def _redirect_headers(
224221
headers.pop("Content-Length", None)
225222
headers.pop("Transfer-Encoding", None)
226223

227-
# We should use the client cookie store to determine any cookie header,
228-
# rather than whatever was on the original outgoing request.
229-
headers.pop("Cookie", None)
230-
231224
return headers
232225

233226
def _redirect_stream(
@@ -246,17 +239,3 @@ def _same_origin(self, url: httpx.URL, other: httpx.URL) -> bool:
246239
Return 'True' if the given URLs share the same origin.
247240
"""
248241
return (url.scheme == other.scheme and url.host == other.host)
249-
250-
def port_or_default(self, url: httpx.URL) -> typing.Optional[int]:
251-
if url.port is not None:
252-
return url.port
253-
return {"http": 80, "https": 443}.get(url.scheme)
254-
255-
def is_https_redirect(self, url: httpx.URL, location: httpx.URL) -> bool:
256-
"""
257-
Return 'True' if 'location' is a HTTPS upgrade of 'url'
258-
"""
259-
if url.host != location.host:
260-
return False
261-
262-
return (url.scheme == "http" and location.scheme == "https")

packages/http/httpx/tests/middleware_tests/test_redirect_handler.py

Lines changed: 205 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -69,16 +69,6 @@ def test_not_same_origin(mock_redirect_handler):
6969
assert not mock_redirect_handler._same_origin(origin1, origin2)
7070

7171

72-
def test_is_https_redirect(mock_redirect_handler):
73-
url = httpx.URL("http://example.com")
74-
location = httpx.URL(BASE_URL)
75-
assert mock_redirect_handler.is_https_redirect(url, location)
76-
77-
78-
def test_is_not_https_redirect(mock_redirect_handler):
79-
url = httpx.URL(BASE_URL)
80-
location = httpx.URL("http://www.example.com")
81-
assert not mock_redirect_handler.is_https_redirect(url, location)
8272

8373

8474
@pytest.mark.asyncio
@@ -281,3 +271,208 @@ def request_handler(request: httpx.Request):
281271
with pytest.raises(Exception) as e:
282272
await handler.send(request, mock_transport)
283273
assert "Too many redirects" in str(e.value)
274+
275+
276+
@pytest.mark.asyncio
277+
async def test_redirect_cross_host_removes_auth_and_cookie():
278+
"""Test that cross-host redirects remove both Authorization and Cookie headers"""
279+
280+
def request_handler(request: httpx.Request):
281+
if request.url == "https://other.example.com/api":
282+
return httpx.Response(200, )
283+
return httpx.Response(
284+
MOVED_PERMANENTLY,
285+
headers={LOCATION_HEADER: "https://other.example.com/api"},
286+
)
287+
288+
handler = RedirectHandler()
289+
request = httpx.Request(
290+
'GET',
291+
BASE_URL,
292+
headers={
293+
AUTHORIZATION_HEADER: "Bearer token",
294+
"Cookie": "session=SECRET"
295+
},
296+
)
297+
mock_transport = httpx.MockTransport(request_handler)
298+
resp = await handler.send(request, mock_transport)
299+
assert resp.status_code == 200
300+
assert AUTHORIZATION_HEADER not in resp.request.headers
301+
assert "Cookie" not in resp.request.headers
302+
303+
304+
@pytest.mark.asyncio
305+
async def test_redirect_scheme_change_removes_auth_and_cookie():
306+
"""Test that scheme changes remove both Authorization and Cookie headers"""
307+
308+
def request_handler(request: httpx.Request):
309+
if request.url == "http://example.com/api":
310+
return httpx.Response(200, )
311+
return httpx.Response(
312+
MOVED_PERMANENTLY,
313+
headers={LOCATION_HEADER: "http://example.com/api"},
314+
)
315+
316+
handler = RedirectHandler(RedirectHandlerOption(allow_redirect_on_scheme_change=True))
317+
request = httpx.Request(
318+
'GET',
319+
BASE_URL,
320+
headers={
321+
AUTHORIZATION_HEADER: "Bearer token",
322+
"Cookie": "session=SECRET"
323+
},
324+
)
325+
mock_transport = httpx.MockTransport(request_handler)
326+
resp = await handler.send(request, mock_transport)
327+
assert resp.status_code == 200
328+
assert AUTHORIZATION_HEADER not in resp.request.headers
329+
assert "Cookie" not in resp.request.headers
330+
331+
332+
@pytest.mark.asyncio
333+
async def test_redirect_same_host_and_scheme_keeps_all_headers():
334+
"""Test that same-host and same-scheme redirects keep Authorization and Cookie headers"""
335+
336+
def request_handler(request: httpx.Request):
337+
if request.url == f"{BASE_URL}/v2/api":
338+
return httpx.Response(200, )
339+
return httpx.Response(
340+
MOVED_PERMANENTLY,
341+
headers={LOCATION_HEADER: f"{BASE_URL}/v2/api"},
342+
)
343+
344+
handler = RedirectHandler()
345+
request = httpx.Request(
346+
'GET',
347+
f"{BASE_URL}/v1/api",
348+
headers={
349+
AUTHORIZATION_HEADER: "Bearer token",
350+
"Cookie": "session=SECRET"
351+
},
352+
)
353+
mock_transport = httpx.MockTransport(request_handler)
354+
resp = await handler.send(request, mock_transport)
355+
assert resp.status_code == 200
356+
assert AUTHORIZATION_HEADER in resp.request.headers
357+
assert resp.request.headers[AUTHORIZATION_HEADER] == "Bearer token"
358+
assert "Cookie" in resp.request.headers
359+
assert resp.request.headers["Cookie"] == "session=SECRET"
360+
361+
362+
@pytest.mark.asyncio
363+
async def test_redirect_with_custom_scrubber():
364+
"""Test that custom scrubber can be provided and is used"""
365+
366+
def custom_scrubber(headers, original_url, new_url):
367+
# Custom logic: never remove headers
368+
pass
369+
370+
def request_handler(request: httpx.Request):
371+
if request.url == "https://evil.attacker.com/steal":
372+
return httpx.Response(200, )
373+
return httpx.Response(
374+
MOVED_PERMANENTLY,
375+
headers={LOCATION_HEADER: "https://evil.attacker.com/steal"},
376+
)
377+
378+
options = RedirectHandlerOption(scrub_sensitive_headers=custom_scrubber)
379+
handler = RedirectHandler(options)
380+
request = httpx.Request(
381+
'GET',
382+
BASE_URL,
383+
headers={
384+
AUTHORIZATION_HEADER: "Bearer token",
385+
"Cookie": "session=SECRET"
386+
},
387+
)
388+
mock_transport = httpx.MockTransport(request_handler)
389+
resp = await handler.send(request, mock_transport)
390+
assert resp.status_code == 200
391+
# Headers should be kept because custom scrubber doesn't remove them
392+
assert AUTHORIZATION_HEADER in resp.request.headers
393+
assert "Cookie" in resp.request.headers
394+
395+
396+
def test_default_scrub_sensitive_headers_removes_on_host_change():
397+
"""Test that default scrubber removes Authorization and Cookie when host changes"""
398+
from kiota_http.middleware.options.redirect_handler_option import default_scrub_sensitive_headers
399+
400+
headers = httpx.Headers({
401+
AUTHORIZATION_HEADER: "Bearer token",
402+
"Cookie": "session=SECRET",
403+
"Content-Type": "application/json"
404+
})
405+
original_url = httpx.URL("https://example.com/v1/api")
406+
new_url = httpx.URL("https://other.com/api")
407+
408+
default_scrub_sensitive_headers(headers, original_url, new_url)
409+
410+
assert AUTHORIZATION_HEADER not in headers
411+
assert "Cookie" not in headers
412+
assert "Content-Type" in headers # Other headers should remain
413+
414+
415+
def test_default_scrub_sensitive_headers_removes_on_scheme_change():
416+
"""Test that default scrubber removes Authorization and Cookie when scheme changes"""
417+
from kiota_http.middleware.options.redirect_handler_option import default_scrub_sensitive_headers
418+
419+
headers = httpx.Headers({
420+
AUTHORIZATION_HEADER: "Bearer token",
421+
"Cookie": "session=SECRET",
422+
"Content-Type": "application/json"
423+
})
424+
original_url = httpx.URL("https://example.com/v1/api")
425+
new_url = httpx.URL("http://example.com/v1/api")
426+
427+
default_scrub_sensitive_headers(headers, original_url, new_url)
428+
429+
assert AUTHORIZATION_HEADER not in headers
430+
assert "Cookie" not in headers
431+
assert "Content-Type" in headers
432+
433+
434+
def test_default_scrub_sensitive_headers_keeps_on_same_origin():
435+
"""Test that default scrubber keeps headers when host and scheme are the same"""
436+
from kiota_http.middleware.options.redirect_handler_option import default_scrub_sensitive_headers
437+
438+
headers = httpx.Headers({
439+
AUTHORIZATION_HEADER: "Bearer token",
440+
"Cookie": "session=SECRET",
441+
"Content-Type": "application/json"
442+
})
443+
original_url = httpx.URL("https://example.com/v1/api")
444+
new_url = httpx.URL("https://example.com/v2/api")
445+
446+
default_scrub_sensitive_headers(headers, original_url, new_url)
447+
448+
assert AUTHORIZATION_HEADER in headers
449+
assert "Cookie" in headers
450+
assert "Content-Type" in headers
451+
452+
453+
def test_default_scrub_sensitive_headers_handles_none_gracefully():
454+
"""Test that default scrubber handles None/empty inputs gracefully"""
455+
from kiota_http.middleware.options.redirect_handler_option import default_scrub_sensitive_headers
456+
457+
# Should not raise exceptions
458+
default_scrub_sensitive_headers(None, httpx.URL(BASE_URL), httpx.URL(BASE_URL))
459+
default_scrub_sensitive_headers(httpx.Headers(), None, httpx.URL(BASE_URL))
460+
default_scrub_sensitive_headers(httpx.Headers(), httpx.URL(BASE_URL), None)
461+
462+
463+
def test_custom_scrub_sensitive_headers():
464+
"""Test that custom scrubber can be set on options"""
465+
def custom_scrubber(headers, original_url, new_url):
466+
# Custom logic
467+
pass
468+
469+
options = RedirectHandlerOption(scrub_sensitive_headers=custom_scrubber)
470+
assert options.scrub_sensitive_headers == custom_scrubber
471+
472+
473+
def test_default_options_uses_default_scrubber():
474+
"""Test that default options use the default scrubber"""
475+
from kiota_http.middleware.options.redirect_handler_option import default_scrub_sensitive_headers
476+
477+
options = RedirectHandlerOption()
478+
assert options.scrub_sensitive_headers == default_scrub_sensitive_headers

0 commit comments

Comments
 (0)