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 () {