Skip to content

Commit 2117dfb

Browse files
authored
[Core] Persist cross domain redirect flag (#45518)
Set the `insecure_domain_change` flag on `request.context` instead of `request.context.options` in order to persist it for all request attempts. Signed-off-by: Paul Van Eck <paulvaneck@microsoft.com>
1 parent fb6b3ca commit 2117dfb

8 files changed

Lines changed: 278 additions & 17 deletions

File tree

sdk/core/azure-core/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
### Bugs Fixed
1010

1111
- Fixed `PipelineClient.format_url` to preserve trailing slash in the base URL when the URL template is query-string-only (e.g., `?key=value`). #45365
12+
- Fixed `SensitiveHeaderCleanupPolicy` to persist the `insecure_domain_change` flag across retries after a cross-domain redirect. #45518
1213

1314
### Other Changes
1415

sdk/core/azure-core/azure/core/pipeline/policies/_authentication.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -215,10 +215,6 @@ def send(self, request: PipelineRequest[HTTPRequestType]) -> PipelineResponse[HT
215215
raise ex from HttpResponseError(response=response.http_response)
216216

217217
if request_authorized:
218-
# if we receive a challenge response, we retrieve a new token
219-
# which matches the new target. In this case, we don't want to remove
220-
# token from the request so clear the 'insecure_domain_change' tag
221-
request.context.options.pop("insecure_domain_change", False)
222218
try:
223219
response = self.next.send(request)
224220
self.on_response(request, response)

sdk/core/azure-core/azure/core/pipeline/policies/_authentication_async.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,10 +130,6 @@ async def send(
130130
raise ex from HttpResponseError(response=response.http_response)
131131

132132
if request_authorized:
133-
# if we receive a challenge response, we retrieve a new token
134-
# which matches the new target. In this case, we don't want to remove
135-
# token from the request so clear the 'insecure_domain_change' tag
136-
request.context.options.pop("insecure_domain_change", False)
137133
try:
138134
response = await self.next.send(request)
139135
except Exception:

sdk/core/azure-core/azure/core/pipeline/policies/_redirect.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,9 +210,8 @@ def send(self, request: PipelineRequest[HTTPRequestType]) -> PipelineResponse[HT
210210
if domain_changed(original_domain, request.http_request.url):
211211
# "insecure_domain_change" is used to indicate that a redirect
212212
# has occurred to a different domain. This tells the SensitiveHeaderCleanupPolicy
213-
# to clean up sensitive headers. We need to remove it before sending the request
214-
# to the transport layer.
215-
request.context.options["insecure_domain_change"] = True
213+
# to clean up sensitive headers.
214+
request.context["insecure_domain_change"] = True
216215
continue
217216
return response
218217

sdk/core/azure-core/azure/core/pipeline/policies/_redirect_async.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,8 @@ async def send(
8181
if domain_changed(original_domain, request.http_request.url):
8282
# "insecure_domain_change" is used to indicate that a redirect
8383
# has occurred to a different domain. This tells the SensitiveHeaderCleanupPolicy
84-
# to clean up sensitive headers. We need to remove it before sending the request
85-
# to the transport layer.
86-
request.context.options["insecure_domain_change"] = True
84+
# to clean up sensitive headers.
85+
request.context["insecure_domain_change"] = True
8786
continue
8887
return response
8988

sdk/core/azure-core/azure/core/pipeline/policies/_sensitive_header_cleanup_policy.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,8 @@ def on_request(self, request: PipelineRequest[HTTPRequestType]) -> None:
7272
"""
7373
# "insecure_domain_change" is used to indicate that a redirect
7474
# has occurred to a different domain. This tells the SensitiveHeaderCleanupPolicy
75-
# to clean up sensitive headers. We need to remove it before sending the request
76-
# to the transport layer.
77-
insecure_domain_change = request.context.options.pop("insecure_domain_change", False)
75+
# to clean up sensitive headers.
76+
insecure_domain_change = request.context.get("insecure_domain_change", False)
7877
if not self._disable_redirect_cleanup and insecure_domain_change:
7978
for header in self._blocked_redirect_headers:
8079
request.http_request.headers.pop(header, None)

sdk/core/azure-core/tests/async_tests/test_authentication_async.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
AsyncBearerTokenCredentialPolicy,
1919
SansIOHTTPPolicy,
2020
AsyncRedirectPolicy,
21+
AsyncRetryPolicy,
2122
SensitiveHeaderCleanupPolicy,
2223
)
2324
from azure.core.pipeline.policies._authentication import MAX_REFRESH_JITTER_SECONDS
@@ -768,3 +769,141 @@ async def test_jitter_set_on_token_request_async():
768769

769770
assert policy._refresh_jitter == 25
770771
mock_randint.assert_called_once_with(0, MAX_REFRESH_JITTER_SECONDS)
772+
773+
774+
@pytest.mark.asyncio
775+
async def test_challenge_auth_header_stripped_after_redirect():
776+
"""Assuming the SensitiveHeaderCleanupPolicy is in the pipeline, the authorization header should be stripped after
777+
a redirect to a different domain by default, and preserved if the policy is configured to disable cleanup."""
778+
779+
class MockTransport(AsyncHttpTransport):
780+
def __init__(self, cleanup_disabled=False):
781+
self._first = True
782+
self._cleanup_disabled = cleanup_disabled
783+
784+
async def __aexit__(self, exc_type, exc_val, exc_tb):
785+
pass
786+
787+
async def close(self):
788+
pass
789+
790+
async def open(self):
791+
pass
792+
793+
async def send(self, request, **kwargs):
794+
if self._first:
795+
self._first = False
796+
assert request.headers["Authorization"] == "Bearer {}".format(auth_header)
797+
response = Response()
798+
response.status_code = 307
799+
response.headers["location"] = "https://redirect-target.example.invalid"
800+
return response
801+
802+
# Second request: after redirect
803+
if self._cleanup_disabled:
804+
assert request.headers.get("Authorization")
805+
else:
806+
assert not request.headers.get("Authorization")
807+
response = Response()
808+
response.status_code = 401
809+
response.headers["WWW-Authenticate"] = (
810+
'Bearer error="insufficient_claims", claims="eyJhY2Nlc3NfdG9rZW4iOnsiZm9vIjoiYmFyIn19"'
811+
)
812+
return response
813+
814+
auth_header = "token"
815+
get_token_call_count = 0
816+
817+
async def mock_get_token(*_, **__):
818+
nonlocal get_token_call_count
819+
get_token_call_count += 1
820+
return AccessToken(auth_header, 0)
821+
822+
credential = Mock(spec_set=["get_token"], get_token=mock_get_token)
823+
auth_policy = AsyncBearerTokenCredentialPolicy(credential, "scope")
824+
redirect_policy = AsyncRedirectPolicy()
825+
header_clean_up_policy = SensitiveHeaderCleanupPolicy()
826+
pipeline = AsyncPipeline(transport=MockTransport(), policies=[redirect_policy, auth_policy, header_clean_up_policy])
827+
828+
response = await pipeline.run(HttpRequest("GET", "https://legitimate.azure.com"))
829+
assert response.http_response.status_code == 401
830+
831+
header_clean_up_policy = SensitiveHeaderCleanupPolicy(disable_redirect_cleanup=True)
832+
pipeline = AsyncPipeline(
833+
transport=MockTransport(cleanup_disabled=True),
834+
policies=[redirect_policy, auth_policy, header_clean_up_policy],
835+
)
836+
response = await pipeline.run(HttpRequest("GET", "https://legitimate.azure.com"))
837+
assert response.http_response.status_code == 401
838+
839+
840+
@pytest.mark.asyncio
841+
async def test_auth_header_stripped_after_cross_domain_redirect_with_retry():
842+
"""After a cross-domain redirect, if the redirected-to endpoint returns a retryable status code,
843+
the Authorization header should still be stripped on the retry attempt. This verifies that the
844+
insecure_domain_change flag persists across retries so SensitiveHeaderCleanupPolicy continues to
845+
remove the Authorization header."""
846+
847+
class MockTransport(AsyncHttpTransport):
848+
def __init__(self):
849+
self._request_count = 0
850+
851+
async def __aexit__(self, exc_type, exc_val, exc_tb):
852+
pass
853+
854+
async def close(self):
855+
pass
856+
857+
async def open(self):
858+
pass
859+
860+
async def send(self, request, **kwargs):
861+
self._request_count += 1
862+
863+
if self._request_count == 1:
864+
# First request: to the original domain — should have auth header
865+
assert request.headers.get("Authorization") == "Bearer {}".format(auth_header)
866+
response = Response()
867+
response.status_code = 307
868+
response.headers["location"] = "https://redirect-target.example.invalid"
869+
return response
870+
871+
if self._request_count == 2:
872+
# Second request: after redirect to attacker domain — auth header should be stripped
873+
assert not request.headers.get(
874+
"Authorization"
875+
), "Authorization header should be stripped on first request to redirected domain"
876+
response = Response()
877+
response.status_code = 500
878+
return response
879+
880+
if self._request_count == 3:
881+
# Third request: retry to attacker domain — auth header should STILL be stripped
882+
assert not request.headers.get(
883+
"Authorization"
884+
), "Authorization header should be stripped on retry to redirected domain"
885+
response = Response()
886+
response.status_code = 200
887+
return response
888+
889+
raise RuntimeError("Unexpected request count: {}".format(self._request_count))
890+
891+
auth_header = "token"
892+
893+
async def mock_get_token(*_, **__):
894+
return AccessToken(auth_header, 0)
895+
896+
credential = Mock(spec_set=["get_token"], get_token=mock_get_token)
897+
auth_policy = AsyncBearerTokenCredentialPolicy(credential, "scope")
898+
redirect_policy = AsyncRedirectPolicy()
899+
retry_policy = AsyncRetryPolicy(retry_total=1, retry_backoff_factor=0)
900+
header_clean_up_policy = SensitiveHeaderCleanupPolicy()
901+
transport = MockTransport()
902+
# Pipeline order matches the real default: redirect -> retry -> auth -> ... -> sensitive header cleanup
903+
pipeline = AsyncPipeline(
904+
transport=transport,
905+
policies=[redirect_policy, retry_policy, auth_policy, header_clean_up_policy],
906+
)
907+
response = await pipeline.run(HttpRequest("GET", "https://legitimate.azure.com"))
908+
assert response.http_response.status_code == 200
909+
assert transport._request_count == 3

sdk/core/azure-core/tests/test_authentication.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from azure.core.pipeline.policies import (
2323
BearerTokenCredentialPolicy,
2424
RedirectPolicy,
25+
RetryPolicy,
2526
SansIOHTTPPolicy,
2627
AzureKeyCredentialPolicy,
2728
AzureSasCredentialPolicy,
@@ -1088,3 +1089,134 @@ def failing_get_token(*scopes, **kwargs):
10881089
# Verify the exception chaining
10891090
assert exc_info.value.__cause__ is not None
10901091
assert isinstance(exc_info.value.__cause__, HttpResponseError)
1092+
1093+
1094+
def test_challenge_auth_header_stripped_after_redirect():
1095+
"""Assuming the SensitiveHeaderCleanupPolicy is in the pipeline, the authorization header should be stripped after
1096+
a redirect to a different domain by default, and preserved if the policy is configured to disable cleanup."""
1097+
1098+
class MockTransport(HttpTransport):
1099+
def __init__(self, cleanup_disabled=False):
1100+
self._first = True
1101+
self._cleanup_disabled = cleanup_disabled
1102+
1103+
def __exit__(self, exc_type, exc_val, exc_tb):
1104+
pass
1105+
1106+
def close(self):
1107+
pass
1108+
1109+
def open(self):
1110+
pass
1111+
1112+
def send(self, request, **kwargs):
1113+
if self._first:
1114+
self._first = False
1115+
assert request.headers["Authorization"] == "Bearer {}".format(auth_header)
1116+
response = Response()
1117+
response.status_code = 307
1118+
response.headers["location"] = "https://redirect-target.example.invalid"
1119+
return response
1120+
1121+
# Second request: after redirect
1122+
if self._cleanup_disabled:
1123+
assert request.headers.get("Authorization")
1124+
else:
1125+
assert not request.headers.get("Authorization")
1126+
response = Response()
1127+
response.status_code = 401
1128+
response.headers["WWW-Authenticate"] = (
1129+
'Bearer error="insufficient_claims", claims="eyJhY2Nlc3NfdG9rZW4iOnsiZm9vIjoiYmFyIn19"'
1130+
)
1131+
return response
1132+
1133+
auth_header = "token"
1134+
get_token_call_count = 0
1135+
1136+
def mock_get_token(*_, **__):
1137+
nonlocal get_token_call_count
1138+
get_token_call_count += 1
1139+
return AccessToken(auth_header, 0)
1140+
1141+
credential = Mock(spec_set=["get_token"], get_token=mock_get_token)
1142+
auth_policy = BearerTokenCredentialPolicy(credential, "scope")
1143+
redirect_policy = RedirectPolicy()
1144+
header_clean_up_policy = SensitiveHeaderCleanupPolicy()
1145+
pipeline = Pipeline(transport=MockTransport(), policies=[redirect_policy, auth_policy, header_clean_up_policy])
1146+
response = pipeline.run(HttpRequest("GET", "https://legitimate.azure.com"))
1147+
assert response.http_response.status_code == 401
1148+
1149+
header_clean_up_policy = SensitiveHeaderCleanupPolicy(disable_redirect_cleanup=True)
1150+
pipeline = Pipeline(
1151+
transport=MockTransport(cleanup_disabled=True), policies=[redirect_policy, auth_policy, header_clean_up_policy]
1152+
)
1153+
response = pipeline.run(HttpRequest("GET", "https://legitimate.azure.com"))
1154+
assert response.http_response.status_code == 401
1155+
1156+
1157+
def test_auth_header_stripped_after_cross_domain_redirect_with_retry():
1158+
"""After a cross-domain redirect, if the redirected-to endpoint returns a retryable status code,
1159+
the Authorization header should still be stripped on the retry attempt. This verifies that the
1160+
insecure_domain_change flag persists across retries so SensitiveHeaderCleanupPolicy continues to
1161+
remove the Authorization header."""
1162+
1163+
class MockTransport(HttpTransport):
1164+
def __init__(self):
1165+
self._request_count = 0
1166+
1167+
def __exit__(self, exc_type, exc_val, exc_tb):
1168+
pass
1169+
1170+
def close(self):
1171+
pass
1172+
1173+
def open(self):
1174+
pass
1175+
1176+
def send(self, request, **kwargs):
1177+
self._request_count += 1
1178+
1179+
if self._request_count == 1:
1180+
# First request: to the original domain — should have auth header
1181+
assert request.headers.get("Authorization") == "Bearer {}".format(auth_header)
1182+
response = Response()
1183+
response.status_code = 307
1184+
response.headers["location"] = "https://redirect-target.example.invalid"
1185+
return response
1186+
1187+
if self._request_count == 2:
1188+
# Second request: after redirect to attacker domain — auth header should be stripped
1189+
assert not request.headers.get(
1190+
"Authorization"
1191+
), "Authorization header should be stripped on first request to redirected domain"
1192+
response = Response()
1193+
response.status_code = 500
1194+
return response
1195+
1196+
if self._request_count == 3:
1197+
# Third request: retry to attacker domain — auth header should STILL be stripped
1198+
assert not request.headers.get(
1199+
"Authorization"
1200+
), "Authorization header should be stripped on retry to redirected domain"
1201+
response = Response()
1202+
response.status_code = 200
1203+
return response
1204+
1205+
raise RuntimeError("Unexpected request count: {}".format(self._request_count))
1206+
1207+
auth_header = "token"
1208+
token = AccessToken(auth_header, 0)
1209+
credential = Mock(spec_set=["get_token"], get_token=Mock(return_value=token))
1210+
auth_policy = BearerTokenCredentialPolicy(credential, "scope")
1211+
redirect_policy = RedirectPolicy()
1212+
retry_policy = RetryPolicy(retry_total=1, retry_backoff_factor=0)
1213+
header_clean_up_policy = SensitiveHeaderCleanupPolicy()
1214+
transport = MockTransport()
1215+
# Pipeline order matches the real default: redirect -> retry -> auth -> ... -> sensitive header cleanup
1216+
pipeline = Pipeline(
1217+
transport=transport,
1218+
policies=[redirect_policy, retry_policy, auth_policy, header_clean_up_policy],
1219+
)
1220+
response = pipeline.run(HttpRequest("GET", "https://legitimate.azure.com"))
1221+
assert response.http_response.status_code == 200
1222+
assert transport._request_count == 3

0 commit comments

Comments
 (0)