From 76d930e46e7021b3ef7f7b3ee169e410f4c6e8c8 Mon Sep 17 00:00:00 2001 From: Alex Soto Date: Tue, 26 May 2026 15:19:30 -0400 Subject: [PATCH] Make NSUrlSessionHandler handle redirects like the .NET handlers do This change updates NSUrlSessionHandler redirect auth handling so it matches the .NET handlers more closely, this is just for .NET compatibility and for correctness; there's no security enforcement here. After an automatic redirect credentials are now resolved against the current redirect target Url instead of the orginal request Url. Also, non CredentialCache credentials are not reused across redirects by default, which is consistent with `SocketsHttpHandler` and `WinHttpHandler`. This avoids sending credentials that were scoped, or only meant for the original origin, to a redirected destination. CredentialCache entries still work when they are explicitly scoped for the redirect target. --- src/Foundation/NSUrlSessionHandler.cs | 98 +++++- .../System.Net.Http/MessageHandlers.cs | 284 ++++++++++++++++++ 2 files changed, 379 insertions(+), 3 deletions(-) diff --git a/src/Foundation/NSUrlSessionHandler.cs b/src/Foundation/NSUrlSessionHandler.cs index fc35793204e2..e34081682faa 100644 --- a/src/Foundation/NSUrlSessionHandler.cs +++ b/src/Foundation/NSUrlSessionHandler.cs @@ -134,6 +134,15 @@ static NSUrlSessionConfiguration CreateConfig () // Double.MaxValue does not work, so default to 24 hours config.TimeoutIntervalForRequest = 24 * 60 * 60; config.TimeoutIntervalForResource = 24 * 60 * 60; + + // Disable shared credential storage so credentials we pass with UseCredential in DidReceiveChallenge dont get saved in + // the SharedCredentialStorage so (native) NSUrlSession can't try to authenticate later requests by itself using old credentials + // incluiding redirects, and then our managed DidReceiveChallenge delegate may not get called at all. We already manage + // the credential flow in DidReceiveChallenge and the Credentials property. The switch is just a compat in case we + // someone needs to go back to the old behaviour. + var useSharedCredentialStorage = AppContext.TryGetSwitch ("Foundation.NSUrlSessionHandler.UseSharedCredentialStorage", out var useSharedStorage) && useSharedStorage; + if (!useSharedCredentialStorage) + config.URLCredentialStorage = null; return config; } @@ -1029,7 +1038,30 @@ void WillCacheResponseImpl (NSUrlSession session, NSUrlSessionDataTask dataTask, [Preserve (Conditional = true)] public override void WillPerformHttpRedirection (NSUrlSession session, NSUrlSessionTask task, NSHttpUrlResponse response, NSUrlRequest newRequest, Action completionHandler) { - completionHandler (sessionHandler.AllowAutoRedirect ? newRequest : null!); + if (!sessionHandler.AllowAutoRedirect) { + completionHandler (null!); + return; + } + + var inflight = GetInflightData (task); + + if (inflight is null) { + completionHandler (null!); + return; + } + + inflight.HasRedirected = true; + + if (newRequest.Url?.AbsoluteString is string redirectUrl) { + inflight.CurrentRequestUrl = redirectUrl; + + if (Uri.TryCreate (redirectUrl, UriKind.Absolute, out var redirectUri)) + inflight.HasCrossOriginRedirect |= IsCrossOriginRedirect (inflight.RequestUrl, redirectUri); + else + inflight.HasCrossOriginRedirect = true; + } + + completionHandler (newRequest); } [Preserve (Conditional = true)] @@ -1127,6 +1159,22 @@ void DidReceiveChallengeImpl (NSUrlSession session, NSUrlSessionTask task, NSUrl } } + // Detect redirect from the task as a fallback in case + // WillPerformHttpRedirection has not updated infligth state yet + if (!inflight.HasRedirected) { + var originalUrl = task.OriginalRequest?.Url?.AbsoluteString; + var currentUrl = task.CurrentRequest?.Url?.AbsoluteString; + if (originalUrl is not null && currentUrl is not null + && !string.Equals (originalUrl, currentUrl, StringComparison.Ordinal)) { + inflight.HasRedirected = true; + inflight.CurrentRequestUrl = currentUrl; + if (Uri.TryCreate (currentUrl, UriKind.Absolute, out var redirectUri)) + inflight.HasCrossOriginRedirect |= IsCrossOriginRedirect (inflight.RequestUrl, redirectUri); + else + inflight.HasCrossOriginRedirect = true; + } + } + if (sessionHandler.Credentials is not null && TryGetAuthenticationType (challenge.ProtectionSpace, out var authType)) { NetworkCredential? credentialsToUse = null; if (authType != RejectProtectionSpaceAuthType) { @@ -1147,8 +1195,9 @@ void DidReceiveChallengeImpl (NSUrlSession session, NSUrlSessionTask task, NSUrl var nsurlRespose = challenge.FailureResponse as NSHttpUrlResponse; var responseIsUnauthorized = (nsurlRespose is null) ? false : nsurlRespose.StatusCode == (int) HttpStatusCode.Unauthorized && challenge.PreviousFailureCount > 0; if (!responseIsUnauthorized) { - var uri = inflight.Request.RequestUri!; - credentialsToUse = sessionHandler.Credentials.GetCredential (uri, authType); + var uri = GetCredentialLookupUri (task, inflight); + if (ShouldLookupCredentials (sessionHandler.Credentials, inflight)) + credentialsToUse = sessionHandler.Credentials.GetCredential (uri, authType); } } @@ -1165,6 +1214,45 @@ void DidReceiveChallengeImpl (NSUrlSession session, NSUrlSessionTask task, NSUrl } } + static Uri GetCredentialLookupUri (NSUrlSessionTask task, InflightData inflight) + { + var currentRequestUrl = task.CurrentRequest?.Url?.AbsoluteString; + if (currentRequestUrl is not null && Uri.TryCreate (currentRequestUrl, UriKind.Absolute, out var currentRequestUri)) + return currentRequestUri; + + if (Uri.TryCreate (inflight.CurrentRequestUrl, UriKind.Absolute, out var inflightCurrentRequestUri)) + return inflightCurrentRequestUri; + + return inflight.Request.RequestUri!; + } + + static bool ShouldLookupCredentials (ICredentials credentials, InflightData inflight) + { + if (credentials is CredentialCache) + return true; + + if (!inflight.HasRedirected) + return true; + + // We are now matching .NET handlers (SocketsHttpHandler and WinHttpHandler) redirect behavior by dropping non CredentialCache credentials after a redirect + // Ref: + // https://github.com/dotnet/runtime/blob/eb5503a1f0dc40ee7b73eb79a039eb143ee25038/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpHandler.cs#L541-L547 + // https://github.com/dotnet/runtime/blob/eb5503a1f0dc40ee7b73eb79a039eb143ee25038/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/RedirectHandler.cs#L52-L87 + // Provide a way for customers to opt into the old behavior for same origin redirects. + var allowSameOriginRedirectCredentials = AppContext.TryGetSwitch ("Foundation.NSUrlSessionHandler.AllowSameOriginRedirectCredentials", out var allowRedirectCred) && allowRedirectCred; + return allowSameOriginRedirectCredentials && !inflight.HasCrossOriginRedirect; + } + + static bool IsCrossOriginRedirect (string originalRequestUrl, Uri currentRequestUri) + { + if (!Uri.TryCreate (originalRequestUrl, UriKind.Absolute, out var originalRequestUri)) + return true; + + return !string.Equals (originalRequestUri.Scheme, currentRequestUri.Scheme, StringComparison.OrdinalIgnoreCase) + || !string.Equals (originalRequestUri.IdnHost, currentRequestUri.IdnHost, StringComparison.OrdinalIgnoreCase) + || originalRequestUri.Port != currentRequestUri.Port; + } + static readonly string RejectProtectionSpaceAuthType = "reject"; static bool TryGetAuthenticationType (NSUrlProtectionSpace protectionSpace, [NotNullWhen (true)] out string? authenticationType) @@ -1196,6 +1284,9 @@ static bool TryGetAuthenticationType (NSUrlProtectionSpace protectionSpace, [Not class InflightData { public readonly object Lock = new object (); public string RequestUrl { get; set; } + public string CurrentRequestUrl { get; set; } + public bool HasRedirected { get; set; } + public bool HasCrossOriginRedirect { get; set; } public TaskCompletionSource CompletionSource { get; } = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); public CancellationToken CancellationToken { get; set; } @@ -1214,6 +1305,7 @@ class InflightData { public InflightData (string requestUrl, CancellationToken cancellationToken, HttpRequestMessage request) { RequestUrl = requestUrl; + CurrentRequestUrl = requestUrl; CancellationToken = cancellationToken; Request = request; } diff --git a/tests/monotouch-test/System.Net.Http/MessageHandlers.cs b/tests/monotouch-test/System.Net.Http/MessageHandlers.cs index 2accb1397400..b159fab8c8c5 100644 --- a/tests/monotouch-test/System.Net.Http/MessageHandlers.cs +++ b/tests/monotouch-test/System.Net.Http/MessageHandlers.cs @@ -3,6 +3,7 @@ // using System.Collections; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using System.Net; @@ -25,6 +26,9 @@ namespace MonoTests.System.Net.Http { [TestFixture] [Preserve (AllMembers = true)] public class MessageHandlerTest { + const string AllowSameOriginRedirectCredentialsSwitch = "Foundation.NSUrlSessionHandler.AllowSameOriginRedirectCredentials"; + const string UseSharedCredentialStorageSwitch = "Foundation.NSUrlSessionHandler.UseSharedCredentialStorage"; + public MessageHandlerTest () { // Https seems broken on our macOS 10.9 bot, so skip this test. @@ -468,6 +472,138 @@ public void RedirectionWithAuthorizationHeaders (Type handlerType) } } + [TestCase (true, false, HttpStatusCode.Unauthorized, false, TestName = "NSUrlSessionHandlerOriginCredentialCacheNotSentToCrossOriginRedirectTarget")] + [TestCase (true, true, HttpStatusCode.OK, true, TestName = "NSUrlSessionHandlerTargetCredentialCacheSentToCrossOriginRedirectTarget")] + [TestCase (false, false, HttpStatusCode.Unauthorized, false, TestName = "NSUrlSessionHandlerNetworkCredentialNotSentToCrossOriginRedirectTarget")] + public void NSUrlSessionHandlerCredentialsCrossOriginRedirectTarget (bool useCredentialCache, bool cacheRedirectTarget, HttpStatusCode expectedStatusCode, bool expectAuthorizationHeader) + { + if (!HttpListener.IsSupported) { + Assert.Inconclusive ("HttpListener is not supported"); + } + + using var server = new RedirectBasicAuthServer (crossOrigin: true); + using var handler = new NSUrlSessionHandler (); + var username = "origin-user"; + var password = "origin-password"; + + if (useCredentialCache) { + var cache = new CredentialCache (); + var credentialUri = cacheRedirectTarget ? new Uri (server.TargetUri, "protected") : server.OriginUri; + cache.Add (credentialUri, "basic", new NetworkCredential (username, password)); + handler.Credentials = cache; + } else { + handler.Credentials = new NetworkCredential (username, password); + } + + HttpStatusCode statusCode = HttpStatusCode.NotFound; + var done = TestRuntime.TryRunAsync (TimeSpan.FromSeconds (30), async () => { + using var client = new HttpClient (handler); + using var response = await client.GetAsync (new Uri (server.OriginUri, "start")); + statusCode = response.StatusCode; + }, out var ex); + + Assert.That (done, Is.True, "Request timed out."); + Assert.That (ex, Is.Null, "Exception"); + Assert.That (statusCode, Is.EqualTo (expectedStatusCode), "StatusCode"); + Assert.That (server.TargetRequestCount, Is.GreaterThanOrEqualTo (1), "Target request count"); + Assert.That (server.TargetAuthorizationHeaders.Length > 0, Is.EqualTo (expectAuthorizationHeader), "Authorization header presence."); + } + + [TestCase (false, HttpStatusCode.Unauthorized, TestName = "NSUrlSessionHandlerNetworkCredentialNotSentAfterSameOriginRedirectByDefault")] + [TestCase (true, HttpStatusCode.OK, TestName = "NSUrlSessionHandlerNetworkCredentialSentAfterSameOriginRedirectWithAppContextSwitch")] + public void NSUrlSessionHandlerNetworkCredentialSameOriginRedirectCredentials (bool allowSameOriginRedirectCredentials, HttpStatusCode expectedStatusCode) + { + if (!HttpListener.IsSupported) { + Assert.Inconclusive ("HttpListener is not supported"); + } + + AppContext.TryGetSwitch (AllowSameOriginRedirectCredentialsSwitch, out var originalValue); + try { + AppContext.SetSwitch (AllowSameOriginRedirectCredentialsSwitch, allowSameOriginRedirectCredentials); + + using var server = new RedirectBasicAuthServer (crossOrigin: false); + using var handler = new NSUrlSessionHandler { + Credentials = new NetworkCredential ("origin-user", "origin-password"), + }; + + HttpStatusCode statusCode = HttpStatusCode.NotFound; + var done = TestRuntime.TryRunAsync (TimeSpan.FromSeconds (30), async () => { + using var client = new HttpClient (handler); + using var response = await client.GetAsync (new Uri (server.OriginUri, "start")); + statusCode = response.StatusCode; + }, out var ex); + + Assert.That (done, Is.True, "Request timed out."); + Assert.That (ex, Is.Null, "Exception"); + Assert.That (statusCode, Is.EqualTo (expectedStatusCode), "StatusCode"); + Assert.That (server.TargetRequestCount, Is.GreaterThanOrEqualTo (1), "Target request count"); + Assert.That (server.TargetAuthorizationHeaders.Length > 0, Is.EqualTo (allowSameOriginRedirectCredentials), "Authorization header presence."); + } finally { + AppContext.SetSwitch (AllowSameOriginRedirectCredentialsSwitch, originalValue); + } + } + + [TestCase (false, HttpStatusCode.Unauthorized, false, TestName = "NSUrlSessionHandlerNetworkCredentialNotSentToCrossOriginRedirectWithDefaultCredentialStorage")] + [TestCase (true, HttpStatusCode.OK, true, TestName = "NSUrlSessionHandlerNetworkCredentialSentToCrossOriginRedirectWithSharedCredentialStorage")] + public void NSUrlSessionHandlerUseSharedCredentialStorage (bool useSharedCredentialStorage, HttpStatusCode expectedStatusCode, bool expectSecondRequestAuthorizationHeader) + { + if (!HttpListener.IsSupported) { + Assert.Inconclusive ("HttpListener is not supported"); + } + + AppContext.TryGetSwitch (UseSharedCredentialStorageSwitch, out var originalValue); + try { + AppContext.SetSwitch (UseSharedCredentialStorageSwitch, useSharedCredentialStorage); + + using var server = new RedirectBasicAuthServer (crossOrigin: true); + + // First, prime the shared credential storage by making a successful auth request + // using a CredentialCache with the target URI. When shared storage is enabled, + // NSUrlSession stores the credential with ForSession persistence in SharedCredentialStorage, + // making it available to subsequent handlers targeting the same host:port. + var cache = new CredentialCache (); + cache.Add (new Uri (server.TargetUri, "protected"), "basic", new NetworkCredential ("origin-user", "origin-password")); + + using (var primingHandler = new NSUrlSessionHandler { Credentials = cache }) { + var primingDone = TestRuntime.TryRunAsync (TimeSpan.FromSeconds (30), async () => { + using var client = new HttpClient (primingHandler); + using var response = await client.GetAsync (new Uri (server.OriginUri, "start")); + }, out var primingEx); + + Assert.That (primingDone, Is.True, "Priming request timed out."); + Assert.That (primingEx, Is.Null, "Priming exception"); + } + + // Record how many auth headers the server received from the priming request + var authHeadersAfterPriming = server.TargetAuthorizationHeaders.Length; + + // Now make a second request with a NetworkCredential (not CredentialCache) to the same server. + // With shared storage enabled, NSUrlSession finds cached credentials in SharedCredentialStorage + // and pre-emptively authenticates the redirect target without calling DidReceiveChallenge. + // With shared storage disabled (default), no stored credentials exist, and the + // DidReceiveChallenge delegate blocks the NetworkCredential after the cross-origin redirect. + using var handler = new NSUrlSessionHandler { + Credentials = new NetworkCredential ("origin-user", "origin-password"), + }; + + HttpStatusCode statusCode = HttpStatusCode.NotFound; + var done = TestRuntime.TryRunAsync (TimeSpan.FromSeconds (30), async () => { + using var client = new HttpClient (handler); + using var response = await client.GetAsync (new Uri (server.OriginUri, "start")); + statusCode = response.StatusCode; + }, out var ex); + + Assert.That (done, Is.True, "Request timed out."); + Assert.That (ex, Is.Null, "Exception"); + Assert.That (statusCode, Is.EqualTo (expectedStatusCode), "StatusCode"); + + var authHeadersFromSecondRequest = server.TargetAuthorizationHeaders.Length - authHeadersAfterPriming; + Assert.That (authHeadersFromSecondRequest > 0, Is.EqualTo (expectSecondRequestAuthorizationHeader), "Authorization header on second request."); + } finally { + AppContext.SetSwitch (UseSharedCredentialStorageSwitch, originalValue); + } + } + [TestCase (typeof (SocketsHttpHandler))] [TestCase (typeof (NSUrlSessionHandler))] public void RejectSslCertificatesServicePointManager (Type handlerType) @@ -867,6 +1003,154 @@ static NWListener CreateNWTlsListener (bool requireClientCert) return (cert.Export (X509ContentType.Pfx, password), password); } + sealed class RedirectBasicAuthServer : IDisposable { + readonly bool crossOrigin; + readonly HttpListener originListener; + readonly HttpListener? targetListener; + readonly Task originTask; + readonly Task? targetTask; + readonly object targetAuthorizationHeadersLock = new object (); + readonly List targetAuthorizationHeaders = new List (); + readonly string expectedBasicAuth; + int targetRequestCount; + + public Uri OriginUri { get; } + public Uri TargetUri { get; } + public int TargetRequestCount => Volatile.Read (ref targetRequestCount); + + public string [] TargetAuthorizationHeaders { + get { + lock (targetAuthorizationHeadersLock) + return targetAuthorizationHeaders.ToArray (); + } + } + + public RedirectBasicAuthServer (bool crossOrigin, string username = "origin-user", string password = "origin-password") + { + this.crossOrigin = crossOrigin; + expectedBasicAuth = "Basic " + Convert.ToBase64String (global::System.Text.Encoding.UTF8.GetBytes ($"{username}:{password}")); + originListener = CreateStartedHttpListener (out var originUri); + OriginUri = originUri; + + if (crossOrigin) { + targetListener = CreateStartedHttpListener (out var targetUri); + TargetUri = targetUri; + } else { + TargetUri = OriginUri; + } + + originTask = Task.Run (RunOrigin); + if (targetListener is not null) + targetTask = Task.Run (RunTarget); + } + + async Task RunOrigin () + { + while (true) { + var context = await GetContextAsync (originListener); + if (context is null) + return; + + if (!crossOrigin && string.Equals (context.Request.Url?.AbsolutePath, "/protected", StringComparison.Ordinal)) { + RespondToProtectedResource (context); + } else { + RespondWithRedirect (context); + } + } + } + + async Task RunTarget () + { + if (targetListener is null) + return; + + while (true) { + var context = await GetContextAsync (targetListener); + if (context is null) + return; + + RespondToProtectedResource (context); + } + } + + void RespondWithRedirect (HttpListenerContext context) + { + var response = context.Response; + response.StatusCode = (int) HttpStatusCode.Redirect; + response.RedirectLocation = new Uri (TargetUri, "protected").AbsoluteUri; + response.Close (); + } + + void RespondToProtectedResource (HttpListenerContext context) + { + Interlocked.Increment (ref targetRequestCount); + + var authorization = context.Request.Headers ["Authorization"]; + if (!string.IsNullOrEmpty (authorization)) { + lock (targetAuthorizationHeadersLock) + targetAuthorizationHeaders.Add (authorization); + } + + var response = context.Response; + if (string.Equals (authorization, expectedBasicAuth, StringComparison.Ordinal)) { + response.StatusCode = (int) HttpStatusCode.OK; + } else { + response.StatusCode = (int) HttpStatusCode.Unauthorized; + response.AddHeader ("WWW-Authenticate", "Basic realm=\"redirect-target\""); + } + response.Close (); + } + + static async Task GetContextAsync (HttpListener listener) + { + try { + return await listener.GetContextAsync (); + } catch (HttpListenerException) { + return null; + } catch (ObjectDisposedException) { + return null; + } catch (InvalidOperationException) { + return null; + } + } + + static HttpListener CreateStartedHttpListener (out Uri uri) + { + const int MinPort = 49215; + const int MaxPort = 65535; + + for (var port = MinPort; port < MaxPort; port++) { + var listener = new HttpListener (); + var url = $"http://127.0.0.1:{port}/"; + listener.Prefixes.Add (url); + try { + listener.Start (); + uri = new Uri (url); + return listener; + } catch { + listener.Close (); + } + } + + throw new InvalidOperationException ("Could not start a local HTTP listener."); + } + + public void Dispose () + { + originListener.Close (); + targetListener?.Close (); + + try { + if (targetTask is null) + Task.WaitAll (new [] { originTask }, TimeSpan.FromSeconds (1)); + else + Task.WaitAll (new [] { originTask, targetTask }, TimeSpan.FromSeconds (1)); + } catch { + // Listener disposal wakes the request loops. + } + } + } + [Test] public void AssertDefaultValuesNSUrlSessionHandler () {