Skip to content

Commit b051cfd

Browse files
authored
[Azure.Core] Do not flow token on cross-host redirect (Azure#59606)
When auto-redirect is enabled (HttpPipelineTransportOptions .IsClientRedirectEnabled = true, or per-message RedirectPolicy.SetAllowAutoRedirect), RedirectPolicy strips the Authorization header before following the redirect, but the per-retry BearerTokenAuthenticationPolicy that runs downstream unconditionally re-attached the cached bearer token to the redirected request -- including when the redirect target was a different host. The result was that an Entra/AAD access token issued for the original service could be sent to any host named in the response's Location header. BearerTokenAuthenticationPolicy now records the request URI authority (host:port) it last authorized on the HttpMessage, and on re-entry compares it against the current authority. If the authority has changed (cross-host redirect): - Any Authorization header is removed defensively (matching RedirectPolicy's intent). - AuthorizeRequest(Async) is skipped -- no cached token is re-attached and the credential is not re-called. - The WWW-Authenticate (CAE 401) handler is also skipped -- a challenge issued by a redirect-target host cannot cause the credential to be invoked with claims derived from that host. Same-host redirects, normal non-redirect flow, and CAE challenges against the originally-authorized host remain bit-identical to today. RedirectPolicy itself is unchanged; its existing Authorization strip is retained as defense-in-depth. The marquee Azure cross-host redirect scenario -- Azure Container Registry to blob storage -- is not affected: ACR's ContainerRegistryChallengeAuthenticationPolicy already overrides AuthorizeRequest as a no-op, and blob-storage redirect targets carry a SAS token in the URL rather than relying on a propagated Authorization header. Callers who explicitly enabled auto-redirect AND depended on cross-host bearer-token propagation should construct a separate client targeting the redirect-target host with a credential scoped to that host's resource.
1 parent ee61c9b commit b051cfd

3 files changed

Lines changed: 221 additions & 3 deletions

File tree

sdk/core/Azure.Core/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
### Bugs Fixed
1010

11+
- Fixed `BearerTokenAuthenticationPolicy` so that the `Authorization` header is no longer re-attached to a request that has been redirected to a different host. Previously, `RedirectPolicy` would strip the `Authorization` header before following a redirect, but the per-retry `BearerTokenAuthenticationPolicy` would re-add the cached bearer token to the redirected request — including when the redirect target was a different host. The policy now detects when the request URI authority has changed since it last authorized the message, defensively strips any `Authorization` header, and skips both re-authorization and the `WWW-Authenticate` (CAE) `401` handler so that no bearer token is sent to — or fetched in response to a challenge from — the redirect target. Same-host redirects, normal (non-redirected) requests, and CAE handling against the original host are unchanged. Callers who explicitly enabled auto-redirect (via `HttpPipelineTransportOptions.IsClientRedirectEnabled = true` or `RedirectPolicy.SetAllowAutoRedirect(message, true)`) and depended on the bearer token being re-attached on cross-host redirects should construct a separate client targeting the redirect-target host with a credential bound to that host's resource.
12+
1113
### Other Changes
1214

1315
## 1.58.0 (2026-06-04)

sdk/core/Azure.Core/src/Pipeline/BearerTokenAuthenticationPolicy.cs

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,19 +160,52 @@ private async ValueTask ProcessAsync(HttpMessage message, ReadOnlyMemory<HttpPip
160160
throw new InvalidOperationException("Bearer token authentication is not permitted for non TLS protected (https) endpoints.");
161161
}
162162

163+
// If the request URI authority has changed since this policy last authorized this
164+
// message, treat the message as having been redirected to a different host. Strip
165+
// any Authorization header that an earlier authorization may have left in place,
166+
// and skip both re-authorization and the WWW-Authenticate (CAE) 401 handler so
167+
// that no bearer token issued for the original host is sent to — or fetched in
168+
// response to a challenge from — a different host.
169+
var authoritySuppressed = false;
170+
var currentAuthority = GetRequestAuthority(message.Request);
171+
172+
if (message.TryGetProperty(typeof(AuthorizedRequestAuthorityKey), out var prior)
173+
&& prior is string priorAuthority
174+
&& !string.Equals(priorAuthority, currentAuthority, StringComparison.OrdinalIgnoreCase))
175+
{
176+
message.Request.Headers.Remove(HttpHeader.Names.Authorization);
177+
authoritySuppressed = true;
178+
}
179+
180+
if (!authoritySuppressed)
181+
{
182+
if (async)
183+
{
184+
await AuthorizeRequestAsync(message).ConfigureAwait(false);
185+
}
186+
else
187+
{
188+
AuthorizeRequest(message);
189+
}
190+
message.SetProperty(typeof(AuthorizedRequestAuthorityKey), currentAuthority);
191+
}
192+
163193
if (async)
164194
{
165-
await AuthorizeRequestAsync(message).ConfigureAwait(false);
166195
await ProcessNextAsync(message, pipeline).ConfigureAwait(false);
167196
}
168197
else
169198
{
170-
AuthorizeRequest(message);
171199
ProcessNext(message, pipeline);
172200
}
173201

174202
// Check if we have received a challenge or we have not yet issued the first request.
175-
if (message.Response.Status == (int)HttpStatusCode.Unauthorized && message.Response.Headers.Contains(HttpHeader.Names.WwwAuthenticate))
203+
// Only honor a WWW-Authenticate challenge against the host that we authorized; if
204+
// authority changed mid-pipeline, the challenge is from an unverified target and
205+
// must not trigger a credential call or a retry with an Authorization header.
206+
if (!authoritySuppressed
207+
&& message.Response.Status == (int)HttpStatusCode.Unauthorized
208+
&& message.Response.Headers.Contains(HttpHeader.Names.WwwAuthenticate))
176209
{
177210
// Attempt to get the TokenRequestContext based on the challenge.
178211
// If we fail to get the context, the challenge was not present or invalid.
@@ -216,6 +249,12 @@ protected void AuthenticateAndAuthorizeRequest(HttpMessage message, TokenRequest
216249
message.Request.Headers.SetValue(HttpHeader.Names.Authorization, headerValue);
217250
}
218251

252+
// Composes a stable host:port string from the request URI builder without invoking
253+
// ToUri(), which would throw on partially-constructed URIs. The string is intended
254+
// for direct case-insensitive equality comparison, not for parsing.
255+
private static string GetRequestAuthority(Request request)
256+
=> (request.Uri.Host ?? string.Empty) + ":" + request.Uri.Port.ToString(System.Globalization.CultureInfo.InvariantCulture);
257+
219258
internal class AccessTokenCache
220259
{
221260
private readonly object _syncObj = new object();
@@ -491,5 +530,12 @@ public async ValueTask<AuthHeaderValueInfo> GetCurrentHeaderValue(bool async, bo
491530
}
492531
}
493532
}
533+
534+
// Private marker used as the HttpMessage property key to record the request URI
535+
// authority that this policy last authorized against. Used to detect cross-host
536+
// redirects so we can suppress re-authorization of the redirected request.
537+
private class AuthorizedRequestAuthorityKey
538+
{
539+
}
494540
}
495541
}

sdk/core/Azure.Core/tests/BearerTokenAuthenticationPolicyTests.cs

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,6 +1026,176 @@ private static IEnumerable<object[]> CaeTestDetails()
10261026
yield return new object[] { "multiple challenges", """PoP realm="", authorization_uri="https://login.microsoftonline.com/common/oauth2/authorize", client_id="00000003-0000-0000-c000-000000000000", nonce="ey==", Bearer realm="", authorization_uri="https://login.microsoftonline.com/common/oauth2/authorize", client_id="00000003-0000-0000-c000-000000000000", error_description="Continuous access evaluation resulted in challenge with result: InteractionRequired and code: TokenIssuedBeforeRevocationTimestamp", error="insufficient_claims", claims="eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTcyNjI1ODEyMiJ9fX0=" """, 200, """{"access_token":{"nbf":{"essential":true, "value":"1726258122"}}}""", "eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTcyNjI1ODEyMiJ9fX0=" };
10271027
}
10281028

1029+
[Test]
1030+
public async Task BearerTokenAuthenticationPolicy_CrossHostRedirect_DoesNotReAttachAuthorization()
1031+
{
1032+
var callCount = 0;
1033+
var credential = new TokenCredentialStub((r, c) =>
1034+
{
1035+
Interlocked.Increment(ref callCount);
1036+
return new AccessToken("token", DateTimeOffset.UtcNow.AddHours(2));
1037+
}, IsAsync);
1038+
1039+
var policy = new BearerTokenAuthenticationPolicy(credential, "scope");
1040+
1041+
// MockTransport stores the Request by reference, so the Authorization header that
1042+
// RedirectPolicy strips before the second hop also disappears from Requests[0].
1043+
// Capture each request's Authorization header as it is sent.
1044+
var observedAuth = new List<string>();
1045+
var responses = new Queue<MockResponse>(new[]
1046+
{
1047+
new MockResponse(302).WithHeader("Location", "https://attacker.example/path"),
1048+
new MockResponse(200),
1049+
});
1050+
var transport = CreateMockTransport(req =>
1051+
{
1052+
observedAuth.Add(req.Headers.TryGetValue("Authorization", out string value) ? value : null);
1053+
return responses.Dequeue();
1054+
});
1055+
1056+
var pipeline = new HttpPipeline(transport, new HttpPipelinePolicy[] { RedirectPolicy.Shared, policy });
1057+
1058+
await SendRequestAsync(pipeline, message =>
1059+
{
1060+
message.Request.Method = RequestMethod.Get;
1061+
message.Request.Uri.Reset(new Uri("https://example.com/"));
1062+
RedirectPolicy.SetAllowAutoRedirect(message, true);
1063+
});
1064+
1065+
Assert.AreEqual(2, transport.Requests.Count);
1066+
Assert.AreEqual("https://attacker.example/path", transport.Requests[1].Uri.ToString());
1067+
1068+
Assert.AreEqual("Bearer token", observedAuth[0],
1069+
"Authorization header must be attached on the original-host request.");
1070+
Assert.IsNull(observedAuth[1],
1071+
"Authorization header must not be re-attached after a cross-host redirect.");
1072+
1073+
Assert.AreEqual(1, callCount,
1074+
"Credential must not be re-called when the redirect target host differs from the authorized host.");
1075+
}
1076+
1077+
[Test]
1078+
public async Task BearerTokenAuthenticationPolicy_SameHostRedirect_PreservesAuthorization()
1079+
{
1080+
var callCount = 0;
1081+
var credential = new TokenCredentialStub((r, c) =>
1082+
{
1083+
Interlocked.Increment(ref callCount);
1084+
return new AccessToken("token", DateTimeOffset.UtcNow.AddHours(2));
1085+
}, IsAsync);
1086+
1087+
var policy = new BearerTokenAuthenticationPolicy(credential, "scope");
1088+
1089+
var observedAuth = new List<string>();
1090+
var responses = new Queue<MockResponse>(new[]
1091+
{
1092+
new MockResponse(302).WithHeader("Location", "/redirected"),
1093+
new MockResponse(200),
1094+
});
1095+
var transport = CreateMockTransport(req =>
1096+
{
1097+
observedAuth.Add(req.Headers.TryGetValue("Authorization", out string value) ? value : null);
1098+
return responses.Dequeue();
1099+
});
1100+
1101+
var pipeline = new HttpPipeline(transport, new HttpPipelinePolicy[] { RedirectPolicy.Shared, policy });
1102+
1103+
await SendRequestAsync(pipeline, message =>
1104+
{
1105+
message.Request.Method = RequestMethod.Get;
1106+
message.Request.Uri.Reset(new Uri("https://example.com/original"));
1107+
RedirectPolicy.SetAllowAutoRedirect(message, true);
1108+
});
1109+
1110+
Assert.AreEqual(2, transport.Requests.Count);
1111+
Assert.AreEqual("https://example.com/redirected", transport.Requests[1].Uri.ToString());
1112+
1113+
Assert.AreEqual("Bearer token", observedAuth[0]);
1114+
Assert.AreEqual("Bearer token", observedAuth[1],
1115+
"Authorization header must be re-attached on same-host redirects.");
1116+
1117+
Assert.AreEqual(1, callCount, "Credential should be served from cache on same-host redirect.");
1118+
}
1119+
1120+
[Test]
1121+
public async Task BearerTokenAuthenticationPolicy_CaeChallengeOnOriginalHost_IsStillHandled()
1122+
{
1123+
var callCount = 0;
1124+
string lastClaims = null;
1125+
var credential = new TokenCredentialStub((r, c) =>
1126+
{
1127+
Interlocked.Increment(ref callCount);
1128+
lastClaims = r.Claims;
1129+
return new AccessToken(callCount.ToString(), DateTimeOffset.UtcNow.AddHours(2));
1130+
}, IsAsync);
1131+
1132+
var policy = new BearerTokenAuthenticationPolicy(credential, "scope");
1133+
var transport = CreateMockTransport(
1134+
new MockResponse(401).WithHeader(
1135+
"WWW-Authenticate",
1136+
"""Bearer realm="", authorization_uri="https://login.microsoftonline.com/common/oauth2/authorize", error="insufficient_claims", claims="eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwidmFsdWUiOiIxNzI2MDc3NTk1In0sInhtc19jYWVlcnJvciI6eyJ2YWx1ZSI6IjEwMDEyIn19fQ==" """),
1137+
new MockResponse(200));
1138+
1139+
var response = await SendGetRequest(transport, policy, uri: new Uri("https://example.com/Original"));
1140+
1141+
Assert.AreEqual(200, response.Status);
1142+
Assert.AreEqual(2, transport.Requests.Count);
1143+
Assert.AreEqual(2, callCount, "CAE handler should call the credential a second time with claims.");
1144+
Assert.IsNotNull(lastClaims, "Second credential call should carry decoded CAE claims.");
1145+
}
1146+
1147+
[Test]
1148+
public async Task BearerTokenAuthenticationPolicy_CaeChallengeFromRedirectTargetHost_IsSuppressed()
1149+
{
1150+
var callCount = 0;
1151+
string lastClaims = null;
1152+
var credential = new TokenCredentialStub((r, c) =>
1153+
{
1154+
Interlocked.Increment(ref callCount);
1155+
lastClaims = r.Claims;
1156+
return new AccessToken("token", DateTimeOffset.UtcNow.AddHours(2));
1157+
}, IsAsync);
1158+
1159+
var policy = new BearerTokenAuthenticationPolicy(credential, "scope");
1160+
1161+
var observedAuth = new List<string>();
1162+
var responses = new Queue<MockResponse>(new[]
1163+
{
1164+
new MockResponse(302).WithHeader("Location", "https://attacker.example/path"),
1165+
new MockResponse(401).WithHeader(
1166+
"WWW-Authenticate",
1167+
"""Bearer realm="", authorization_uri="https://login.microsoftonline.com/common/oauth2/authorize", error="insufficient_claims", claims="eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwidmFsdWUiOiIxNzI2MDc3NTk1In0sInhtc19jYWVlcnJvciI6eyJ2YWx1ZSI6IjEwMDEyIn19fQ==" """),
1168+
});
1169+
var transport = CreateMockTransport(req =>
1170+
{
1171+
observedAuth.Add(req.Headers.TryGetValue("Authorization", out string value) ? value : null);
1172+
return responses.Dequeue();
1173+
});
1174+
1175+
var pipeline = new HttpPipeline(transport, new HttpPipelinePolicy[] { RedirectPolicy.Shared, policy });
1176+
1177+
var message = await SendMessageRequestAsync(pipeline, msg =>
1178+
{
1179+
msg.Request.Method = RequestMethod.Get;
1180+
msg.Request.Uri.Reset(new Uri("https://example.com/"));
1181+
RedirectPolicy.SetAllowAutoRedirect(msg, true);
1182+
});
1183+
1184+
Assert.AreEqual(401, message.Response.Status,
1185+
"The 401 from a redirect-target host must surface to the caller without an authenticated retry.");
1186+
Assert.AreEqual(2, transport.Requests.Count,
1187+
"The CAE handler must not retry against a redirect-target host.");
1188+
1189+
Assert.AreEqual("Bearer token", observedAuth[0]);
1190+
Assert.IsNull(observedAuth[1],
1191+
"Authorization header must not be sent to a redirect-target host.");
1192+
1193+
Assert.AreEqual(1, callCount,
1194+
"Credential must not be re-called in response to a challenge from a redirect-target host.");
1195+
Assert.IsNull(lastClaims,
1196+
"Credential must not be called with CAE claims derived from a redirect-target host's challenge.");
1197+
}
1198+
10291199
private class ChallengeBasedAuthenticationTestPolicy : BearerTokenAuthenticationPolicy
10301200
{
10311201
public string TenantId { get; private set; }

0 commit comments

Comments
 (0)