diff --git a/src/Foundation/NSUrlSessionHandler.cs b/src/Foundation/NSUrlSessionHandler.cs index 2db105e4eb85..f286c96583b4 100644 --- a/src/Foundation/NSUrlSessionHandler.cs +++ b/src/Foundation/NSUrlSessionHandler.cs @@ -210,7 +210,13 @@ protected override void Dispose (bool disposing) task.Dispose (); } - session.InvalidateAndCancel (); + // Take the proxy configuration lock so we don't race with ConfigureSessionProxy: either we + // invalidate the session it created (if it ran first), or it observes 'disposed' and doesn't + // create a new session that would never be invalidated (if we ran first). + lock (proxyConfigurationLock) { + disposed = true; + session.InvalidateAndCancel (); + } base.Dispose (disposing); } @@ -449,6 +455,102 @@ async Task CreateRequest (HttpRequestMessage request) return nsrequest; } + readonly object proxyConfigurationLock = new object (); + bool proxyConfigured; + bool disposed; + + // NSUrlSession applies proxy settings per-session (via the configuration's connection proxy dictionary), + // not per-request, so we compute the proxy configuration once (using the first request's destination) and + // recreate the session with it before the first request is sent. + void ConfigureSessionProxy (HttpRequestMessage request) + { + lock (proxyConfigurationLock) { + if (proxyConfigured) + return; + + // The handler has been disposed (and the session already invalidated); don't create a new + // session, since it would never be invalidated. + if (disposed) + return; + + if (TryGetProxyDictionary (request.RequestUri, out var proxyDictionary)) { + var oldSession = session; + var configuration = session.Configuration; + configuration.ConnectionProxyDictionary = proxyDictionary; + session = NSUrlSession.FromConfiguration (configuration, (INSUrlSessionDelegate) new NSUrlSessionHandlerDelegate (this), null); + oldSession.Dispose (); + } + + // Only mark the proxy as configured once we've successfully computed and applied the + // configuration, so that a failure (e.g. a throwing IWebProxy) doesn't permanently leave the + // session unconfigured while pretending otherwise (the next request will try again). + proxyConfigured = true; + } + } + + // Computes the connection proxy dictionary to apply to the session. + // Returns false (and a null dictionary) when the session's default proxy behavior (use the OS-configured proxies) should be used. + bool TryGetProxyDictionary (Uri? destination, out NSDictionary? proxyDictionary) + { + proxyDictionary = null; + + // The developer explicitly asked us to not use any proxy: apply an empty dictionary to + // override any proxy configured in the OS. + if (!useProxy) { + proxyDictionary = new NSDictionary (); + return true; + } + + // No custom proxy: let NSUrlSession use the OS-configured proxies (the default behavior). + if (proxy is null || destination is null) + return false; + + // The proxy says this destination should not be proxied: override any OS-configured proxy. + if (proxy.IsBypassed (destination)) { + proxyDictionary = new NSDictionary (); + return true; + } + + var proxyUri = proxy.GetProxy (destination); + // A null proxy uri (or one that points back at the destination) means "no proxy for this destination". + if (proxyUri is null || proxyUri == destination) { + proxyDictionary = new NSDictionary (); + return true; + } + + // The proxy uri's scheme indicates how to connect to the proxy itself (not which requests to + // proxy). NSUrlSession's connection proxy dictionary can only describe a plain-HTTP connection + // to the proxy (the HTTP/HTTPS proxy keys select the destination scheme, not the protocol used + // to reach the proxy, and there's no key to connect to the proxy over TLS or via SOCKS). Rather + // than silently connecting over plain HTTP and doing the wrong thing for a secure ("https") or + // SOCKS proxy, fail loudly for any scheme we can't honor. + if (!string.Equals (proxyUri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)) + throw new NotSupportedException ($"The proxy scheme '{proxyUri.Scheme}' is not supported. Only 'http' proxies are supported."); + + var strongProxy = new ProxyConfigurationDictionary { + HttpEnable = true, + HttpProxyHost = proxyUri.DnsSafeHost, + HttpProxyPort = proxyUri.Port, + HttpsProxyHost = proxyUri.DnsSafeHost, + HttpsProxyPort = proxyUri.Port, +#if MONOMAC + HttpsEnable = true, +#endif + }; + proxyDictionary = strongProxy.GetDictionary (); +#if !MONOMAC + // The strongly-typed HttpsEnable property (kCFNetworkProxiesHTTPSEnable) is only exposed on + // macOS, but CFNetwork honors the same "HTTPSEnable" key on the other platforms too, and it's + // required for HTTPS proxying (CONNECT tunneling) to work. Add it via the literal key. + if (proxyDictionary is not null) { + var mutableProxyDictionary = new NSMutableDictionary (proxyDictionary); + mutableProxyDictionary ["HTTPSEnable"] = NSNumber.FromBoolean (true); + proxyDictionary = mutableProxyDictionary; + } +#endif + return true; + } + /// To be added. /// To be added. /// To be added. @@ -456,8 +558,13 @@ async Task CreateRequest (HttpRequestMessage request) /// To be added. protected override async Task SendAsync (HttpRequestMessage request, CancellationToken cancellationToken) { + // Mark the handler as non-modifiable before any per-first-request initialization (such as + // configuring the session proxy), so other threads can't mutate handler properties once the + // first request has started being processed. Volatile.Write (ref sentRequest, true); + ConfigureSessionProxy (request); + var nsrequest = await CreateRequest (request).ConfigureAwait (false); var dataTask = session.CreateDataTask (nsrequest); @@ -558,14 +665,23 @@ public X509CertificateCollection ClientCertificates { public ClientCertificateOption ClientCertificateOptions { get; set; } - // We're ignoring this property, just like Xamarin.Android does: - // https://github.com/xamarin/xamarin-android/blob/09e8cb5c07ea6c39383185a3f90e53186749b802/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs#L152 - [UnsupportedOSPlatform ("ios")] - [UnsupportedOSPlatform ("maccatalyst")] - [UnsupportedOSPlatform ("tvos")] - [UnsupportedOSPlatform ("macos")] - [EditorBrowsable (EditorBrowsableState.Never)] - public ICredentials? DefaultProxyCredentials { get; set; } + ICredentials? defaultProxyCredentials; + + /// The credentials to submit to the proxy server for authentication. + /// The credentials to use to authenticate with the proxy, or to not provide any proxy credentials. + /// + /// These credentials are only used when the proxy itself () doesn't provide its own credentials. + /// Proxy authentication is only supported for HTTPS destinations (which are proxied using a CONNECT tunnel). For plain HTTP destinations NSUrlSession doesn't deliver a proxy authentication challenge, so these credentials aren't applied and an authenticating proxy will return an HTTP 407 response instead. + /// + public ICredentials? DefaultProxyCredentials { + get { + return defaultProxyCredentials; + } + set { + EnsureModifiability (); + defaultProxyCredentials = value; + } + } public int MaxAutomaticRedirections { get => int.MaxValue; @@ -614,18 +730,27 @@ public bool PreAuthenticate { [EditorBrowsable (EditorBrowsableState.Never)] public IDictionary? Properties { get { return null; } } - // We dont support any custom proxies, and don't let anybody wonder why their proxy isn't - // being used if they try to assign one (in any case we also return false from 'SupportsProxy'). - [UnsupportedOSPlatform ("ios")] - [UnsupportedOSPlatform ("maccatalyst")] - [UnsupportedOSPlatform ("tvos")] - [UnsupportedOSPlatform ("macos")] - [EditorBrowsable (EditorBrowsableState.Never)] + IWebProxy? proxy; + + /// The proxy to use for requests. + /// The custom proxy to use, or to use the proxies configured in the operating system. + /// + /// Setting this property only has an effect if is (which is the default value). + /// NSUrlSession applies proxy settings per-session, not per-request, so the proxy is evaluated only once, using the destination of the first request sent by this handler, and the result is applied to every subsequent request made by this handler. + /// + /// This has important consequences when a single handler is reused for requests to multiple destinations: the proxy's per-destination decisions ( and the proxy returned by ) are computed from the first request's destination only. + /// As a result a proxy's bypass list and any destination-dependent proxy selection are not honored for later requests to other destinations: requests that should have been proxied might be sent directly (or through the wrong proxy), and requests that should have bypassed the proxy might be sent through it. + /// + /// If per-destination proxy behavior is required, use a separate (and thus a separate ) for each destination or proxy configuration. + /// Proxy authentication (using 's own credentials or ) is only supported for HTTPS destinations (which are proxied using a CONNECT tunnel). For plain HTTP destinations NSUrlSession doesn't deliver a proxy authentication challenge, so proxy credentials aren't applied and an authenticating proxy will return an HTTP 407 response instead. + /// public IWebProxy? Proxy { - get => null; + get { + return proxy; + } set { - if (value is not null) - throw new PlatformNotSupportedException (); + EnsureModifiability (); + proxy = value; } } @@ -743,9 +868,10 @@ public bool SupportsAutomaticDecompression { get => true; } - // We don't support using custom proxies, but NSUrlSession will automatically use any proxies configured in the OS. + // We support custom proxies (applied per-session via the connection proxy dictionary), and + // NSUrlSession will also automatically use any proxies configured in the OS. public bool SupportsProxy { - get => false; + get => true; } // We support the AllowAutoRedirect property, but we don't support changing the MaxAutomaticRedirections value, @@ -754,13 +880,15 @@ public bool SupportsRedirectConfiguration { get => false; } - // NSUrlSession will automatically use any proxies configured in the OS (so always return true in the getter). - // There doesn't seem to be a way to turn this off, so throw if someone attempts to disable this. + bool useProxy = true; + + // When true (the default), NSUrlSession uses either the custom 'Proxy' (if set) or the proxies configured in the OS. + // When false, no proxy is used (this overrides any proxy configured in the OS). public bool UseProxy { - get => true; + get => useProxy; set { - if (!value) - ObjCRuntime.ThrowHelper.ThrowArgumentOutOfRangeException (nameof (value), value, "It's not possible to disable the use of system proxies."); ; + EnsureModifiability (); + useProxy = value; } } @@ -1150,8 +1278,10 @@ void DidReceiveChallengeImpl (NSUrlSession session, NSUrlSessionTask task, NSUrl // but we are hiding such a situation from our users, we can nevertheless know if the header was added and deal with it. The idea is as follows, // check if we are in the first attempt, if we are (PreviousFailureCount == 0), we check the headers of the request and if we do have the Auth // header, it means that we do not have the correct credentials, in any other case just do what it is expected. + // This only applies to server authentication challenges: a proxy authentication challenge is unrelated to + // the request's (server-targeted) Authorization header, so it's handled separately below. - if (challenge.PreviousFailureCount == 0) { + if (challenge.PreviousFailureCount == 0 && !challenge.ProtectionSpace.IsProxy) { var authHeader = inflight.Request.Headers?.Authorization; if (!(string.IsNullOrEmpty (authHeader?.Scheme) && string.IsNullOrEmpty (authHeader?.Parameter))) { completionHandler (NSUrlSessionAuthChallengeDisposition.RejectProtectionSpace, null!); @@ -1175,7 +1305,35 @@ void DidReceiveChallengeImpl (NSUrlSession session, NSUrlSessionTask task, NSUrl } } - if (sessionHandler.Credentials is not null && TryGetAuthenticationType (challenge.ProtectionSpace, out var authType)) { + // Proxy authentication challenges are handled separately from server authentication challenges: + // the credentials come from the proxy configuration (the proxy's own credentials or the + // DefaultProxyCredentials) rather than from the handler's Credentials property. + if (challenge.ProtectionSpace.IsProxy) { + var proxy = sessionHandler.Proxy; + var proxyCredentials = proxy?.Credentials ?? sessionHandler.DefaultProxyCredentials; + // Only provide the credentials for the first challenge; if they were rejected we let the request + // fail instead of retrying the same (bad) credentials indefinitely. + if (proxyCredentials is not null && challenge.PreviousFailureCount == 0) { + var proxyUri = GetProxyLookupUri (challenge.ProtectionSpace); + // Look up the credentials using the proxy's authentication method (Basic/Digest/NTLM), so + // that a CredentialCache keyed by a specific scheme resolves correctly. Fall back to "basic" + // for any authentication method we don't recognize: a plain NetworkCredential ignores the + // authentication type anyway, so this preserves the behavior for the common case. + if (!TryGetAuthenticationType (challenge.ProtectionSpace, out var proxyAuthType) || proxyAuthType == RejectProtectionSpaceAuthType) + proxyAuthType = "basic"; + var proxyCredential = proxyCredentials.GetCredential (proxyUri, proxyAuthType); + if (proxyCredential is not null) { + var proxyNSCredential = new NSUrlCredential (proxyCredential.UserName, proxyCredential.Password, NSUrlCredentialPersistence.ForSession); + completionHandler (NSUrlSessionAuthChallengeDisposition.UseCredential, proxyNSCredential); + return; + } + } + completionHandler (NSUrlSessionAuthChallengeDisposition.PerformDefaultHandling, challenge.ProposedCredential); + return; + } + + var challengeCredentials = sessionHandler.Credentials; + if (challengeCredentials is not null && TryGetAuthenticationType (challenge.ProtectionSpace, out var authType)) { NetworkCredential? credentialsToUse = null; if (authType != RejectProtectionSpaceAuthType) { // interesting situation, when we use a credential that we created that is empty, we are not getting the RejectProtectionSpaceAuthType, @@ -1196,8 +1354,8 @@ void DidReceiveChallengeImpl (NSUrlSession session, NSUrlSessionTask task, NSUrl var responseIsUnauthorized = (nsurlRespose is null) ? false : nsurlRespose.StatusCode == (int) HttpStatusCode.Unauthorized && challenge.PreviousFailureCount > 0; if (!responseIsUnauthorized) { var uri = GetCredentialLookupUri (task, inflight); - if (ShouldLookupCredentials (sessionHandler.Credentials, inflight)) - credentialsToUse = sessionHandler.Credentials.GetCredential (uri, authType); + if (ShouldLookupCredentials (challengeCredentials, inflight)) + credentialsToUse = challengeCredentials.GetCredential (uri, authType); } } @@ -1214,6 +1372,15 @@ void DidReceiveChallengeImpl (NSUrlSession session, NSUrlSessionTask task, NSUrl } } + static Uri GetProxyLookupUri (NSUrlProtectionSpace protectionSpace) + { + // Derive the scheme from the proxy type (HTTP vs HTTPS proxy), not from ReceivesCredentialSecurely + // (which describes whether the authentication method protects the credentials, not the proxy protocol). + var scheme = protectionSpace.ProxyType == (string) NSUrlProtectionSpace.HTTPSProxy ? "https" : "http"; + var builder = new UriBuilder (scheme, protectionSpace.Host, (int) protectionSpace.Port); + return builder.Uri; + } + static Uri GetCredentialLookupUri (NSUrlSessionTask task, InflightData inflight) { var currentRequestUrl = task.CurrentRequest?.Url?.AbsoluteString; diff --git a/tests/dotnet/UnitTests/expected/MacCatalyst-MonoVM-interpreter-size.txt b/tests/dotnet/UnitTests/expected/MacCatalyst-MonoVM-interpreter-size.txt index be2a2fca5019..3b349e833c8a 100644 --- a/tests/dotnet/UnitTests/expected/MacCatalyst-MonoVM-interpreter-size.txt +++ b/tests/dotnet/UnitTests/expected/MacCatalyst-MonoVM-interpreter-size.txt @@ -1,9 +1,9 @@ -AppBundleSize: 5,794,059 bytes (5,658.3 KB = 5.5 MB) +AppBundleSize: 5,793,245 bytes (5,657.5 KB = 5.5 MB) # The following list of files and their sizes is just informational / for review, and isn't used in the test: Contents/Info.plist: - 1,078 bytes (1.1 KB = 0.0 MB) + 1,096 bytes (1.1 KB = 0.0 MB) Contents/MacOS/SizeTestApp: - 4,573,752 bytes (4,466.6 KB = 4.4 MB) + 4,572,920 bytes (4,465.7 KB = 4.4 MB) Contents/MonoBundle/Microsoft.MacCatalyst.dll: 157,696 bytes (154.0 KB = 0.2 MB) Contents/MonoBundle/runtimeconfig.bin: diff --git a/tests/dotnet/UnitTests/expected/MacCatalyst-MonoVM-size.txt b/tests/dotnet/UnitTests/expected/MacCatalyst-MonoVM-size.txt index 152e71979dbb..2ee383d41b42 100644 --- a/tests/dotnet/UnitTests/expected/MacCatalyst-MonoVM-size.txt +++ b/tests/dotnet/UnitTests/expected/MacCatalyst-MonoVM-size.txt @@ -1,9 +1,9 @@ -AppBundleSize: 16,308,360 bytes (15,926.1 KB = 15.6 MB) +AppBundleSize: 16,308,569 bytes (15,926.3 KB = 15.6 MB) # The following list of files and their sizes is just informational / for review, and isn't used in the test: Contents/Info.plist: - 1,079 bytes (1.1 KB = 0.0 MB) + 1,096 bytes (1.1 KB = 0.0 MB) Contents/MacOS/SizeTestApp: - 13,801,288 bytes (13,477.8 KB = 13.2 MB) + 13,801,480 bytes (13,478.0 KB = 13.2 MB) Contents/MonoBundle/aot-instances.aotdata.arm64: 1,045,352 bytes (1,020.9 KB = 1.0 MB) Contents/MonoBundle/Microsoft.MacCatalyst.aotdata.arm64: diff --git a/tests/dotnet/UnitTests/expected/MacCatalyst-NativeAOT-TrimmableStatic-size.txt b/tests/dotnet/UnitTests/expected/MacCatalyst-NativeAOT-TrimmableStatic-size.txt index c88ddafff650..e19be420c0ad 100644 --- a/tests/dotnet/UnitTests/expected/MacCatalyst-NativeAOT-TrimmableStatic-size.txt +++ b/tests/dotnet/UnitTests/expected/MacCatalyst-NativeAOT-TrimmableStatic-size.txt @@ -1,9 +1,9 @@ -AppBundleSize: 8,772,055 bytes (8,566.5 KB = 8.4 MB) +AppBundleSize: 8,788,640 bytes (8,582.7 KB = 8.4 MB) # The following list of files and their sizes is just informational / for review, and isn't used in the test: Contents/Info.plist: - 1,071 bytes (1.0 KB = 0.0 MB) + 1,096 bytes (1.1 KB = 0.0 MB) Contents/MacOS/SizeTestApp: - 8,769,080 bytes (8,563.6 KB = 8.4 MB) + 8,785,640 bytes (8,579.7 KB = 8.4 MB) Contents/MonoBundle/runtimeconfig.bin: 1,896 bytes (1.9 KB = 0.0 MB) Contents/PkgInfo: diff --git a/tests/dotnet/UnitTests/expected/MacCatalyst-NativeAOT-size.txt b/tests/dotnet/UnitTests/expected/MacCatalyst-NativeAOT-size.txt index a4924c6f857e..288a99e0431b 100644 --- a/tests/dotnet/UnitTests/expected/MacCatalyst-NativeAOT-size.txt +++ b/tests/dotnet/UnitTests/expected/MacCatalyst-NativeAOT-size.txt @@ -1,7 +1,7 @@ -AppBundleSize: 2,742,214 bytes (2,677.9 KB = 2.6 MB) +AppBundleSize: 2,742,248 bytes (2,678.0 KB = 2.6 MB) # The following list of files and their sizes is just informational / for review, and isn't used in the test: Contents/Info.plist: - 1,062 bytes (1.0 KB = 0.0 MB) + 1,096 bytes (1.1 KB = 0.0 MB) Contents/MacOS/SizeTestApp: 2,739,336 bytes (2,675.1 KB = 2.6 MB) Contents/MonoBundle/runtimeconfig.bin: diff --git a/tests/dotnet/UnitTests/expected/MacOSX-CoreCLR-Interpreter-TrimmableStatic-size.txt b/tests/dotnet/UnitTests/expected/MacOSX-CoreCLR-Interpreter-TrimmableStatic-size.txt index 497566949808..65f8748c0b41 100644 --- a/tests/dotnet/UnitTests/expected/MacOSX-CoreCLR-Interpreter-TrimmableStatic-size.txt +++ b/tests/dotnet/UnitTests/expected/MacOSX-CoreCLR-Interpreter-TrimmableStatic-size.txt @@ -1,17 +1,17 @@ -AppBundleSize: 257,724,211 bytes (251,683.8 KB = 245.8 MB) +AppBundleSize: 257,735,803 bytes (251,695.1 KB = 245.8 MB) # The following list of files and their sizes is just informational / for review, and isn't used in the test: Contents/Info.plist: - 718 bytes (0.7 KB = 0.0 MB) + 774 bytes (0.8 KB = 0.0 MB) Contents/MacOS/SizeTestApp: - 7,386,728 bytes (7,213.6 KB = 7.0 MB) + 7,388,024 bytes (7,214.9 KB = 7.0 MB) Contents/MonoBundle/.xamarin/osx-arm64/_Microsoft.macOS.TypeMap.dll: 4,911,616 bytes (4,796.5 KB = 4.7 MB) Contents/MonoBundle/.xamarin/osx-arm64/_SizeTestApp.TypeMap.dll: - 3,072 bytes (3.0 KB = 0.0 MB) + 3,584 bytes (3.5 KB = 0.0 MB) Contents/MonoBundle/.xamarin/osx-arm64/Microsoft.CSharp.dll: 892,712 bytes (871.8 KB = 0.9 MB) Contents/MonoBundle/.xamarin/osx-arm64/Microsoft.macOS.dll: - 37,213,184 bytes (36,341.0 KB = 35.5 MB) + 37,217,792 bytes (36,345.5 KB = 35.5 MB) Contents/MonoBundle/.xamarin/osx-arm64/Microsoft.VisualBasic.Core.dll: 1,334,568 bytes (1,303.3 KB = 1.3 MB) Contents/MonoBundle/.xamarin/osx-arm64/Microsoft.VisualBasic.dll: @@ -359,11 +359,11 @@ Contents/MonoBundle/.xamarin/osx-arm64/WindowsBase.dll: Contents/MonoBundle/.xamarin/osx-x64/_Microsoft.macOS.TypeMap.dll: 4,911,616 bytes (4,796.5 KB = 4.7 MB) Contents/MonoBundle/.xamarin/osx-x64/_SizeTestApp.TypeMap.dll: - 3,072 bytes (3.0 KB = 0.0 MB) + 3,584 bytes (3.5 KB = 0.0 MB) Contents/MonoBundle/.xamarin/osx-x64/Microsoft.CSharp.dll: 795,944 bytes (777.3 KB = 0.8 MB) Contents/MonoBundle/.xamarin/osx-x64/Microsoft.macOS.dll: - 37,213,184 bytes (36,341.0 KB = 35.5 MB) + 37,217,792 bytes (36,345.5 KB = 35.5 MB) Contents/MonoBundle/.xamarin/osx-x64/Microsoft.VisualBasic.Core.dll: 1,166,120 bytes (1,138.8 KB = 1.1 MB) Contents/MonoBundle/.xamarin/osx-x64/Microsoft.VisualBasic.dll: diff --git a/tests/dotnet/UnitTests/expected/MacOSX-CoreCLR-Interpreter-size.txt b/tests/dotnet/UnitTests/expected/MacOSX-CoreCLR-Interpreter-size.txt index 23fdef374c71..2cc092a1a197 100644 --- a/tests/dotnet/UnitTests/expected/MacOSX-CoreCLR-Interpreter-size.txt +++ b/tests/dotnet/UnitTests/expected/MacOSX-CoreCLR-Interpreter-size.txt @@ -1,13 +1,13 @@ -AppBundleSize: 247,975,409 bytes (242,163.5 KB = 236.5 MB) +AppBundleSize: 247,985,977 bytes (242,173.8 KB = 236.5 MB) # The following list of files and their sizes is just informational / for review, and isn't used in the test: Contents/Info.plist: - 718 bytes (0.7 KB = 0.0 MB) + 774 bytes (0.8 KB = 0.0 MB) Contents/MacOS/SizeTestApp: - 8,028,536 bytes (7,840.4 KB = 7.7 MB) + 8,029,832 bytes (7,841.6 KB = 7.7 MB) Contents/MonoBundle/.xamarin/osx-arm64/Microsoft.CSharp.dll: 892,712 bytes (871.8 KB = 0.9 MB) Contents/MonoBundle/.xamarin/osx-arm64/Microsoft.macOS.dll: - 36,932,096 bytes (36,066.5 KB = 35.2 MB) + 36,936,704 bytes (36,071.0 KB = 35.2 MB) Contents/MonoBundle/.xamarin/osx-arm64/Microsoft.VisualBasic.Core.dll: 1,334,568 bytes (1,303.3 KB = 1.3 MB) Contents/MonoBundle/.xamarin/osx-arm64/Microsoft.VisualBasic.dll: @@ -355,7 +355,7 @@ Contents/MonoBundle/.xamarin/osx-arm64/WindowsBase.dll: Contents/MonoBundle/.xamarin/osx-x64/Microsoft.CSharp.dll: 795,944 bytes (777.3 KB = 0.8 MB) Contents/MonoBundle/.xamarin/osx-x64/Microsoft.macOS.dll: - 36,932,096 bytes (36,066.5 KB = 35.2 MB) + 36,936,704 bytes (36,071.0 KB = 35.2 MB) Contents/MonoBundle/.xamarin/osx-x64/Microsoft.VisualBasic.Core.dll: 1,166,120 bytes (1,138.8 KB = 1.1 MB) Contents/MonoBundle/.xamarin/osx-x64/Microsoft.VisualBasic.dll: diff --git a/tests/dotnet/UnitTests/expected/MacOSX-NativeAOT-TrimmableStatic-size.txt b/tests/dotnet/UnitTests/expected/MacOSX-NativeAOT-TrimmableStatic-size.txt index c413dd3990ac..e9cbd87b0a9d 100644 --- a/tests/dotnet/UnitTests/expected/MacOSX-NativeAOT-TrimmableStatic-size.txt +++ b/tests/dotnet/UnitTests/expected/MacOSX-NativeAOT-TrimmableStatic-size.txt @@ -1,9 +1,9 @@ -AppBundleSize: 21,463,772 bytes (20,960.7 KB = 20.5 MB) +AppBundleSize: 21,496,766 bytes (20,992.9 KB = 20.5 MB) # The following list of files and their sizes is just informational / for review, and isn't used in the test: Contents/Info.plist: - 740 bytes (0.7 KB = 0.0 MB) + 774 bytes (0.8 KB = 0.0 MB) Contents/MacOS/SizeTestApp: - 18,374,472 bytes (17,943.8 KB = 17.5 MB) + 18,407,432 bytes (17,976.0 KB = 17.6 MB) Contents/MonoBundle/libSystem.Globalization.Native.dylib: 267,872 bytes (261.6 KB = 0.3 MB) Contents/MonoBundle/libSystem.IO.Compression.Native.dylib: diff --git a/tests/dotnet/UnitTests/expected/MacOSX-NativeAOT-size.txt b/tests/dotnet/UnitTests/expected/MacOSX-NativeAOT-size.txt index 39e8c7257e8f..37164df10323 100644 --- a/tests/dotnet/UnitTests/expected/MacOSX-NativeAOT-size.txt +++ b/tests/dotnet/UnitTests/expected/MacOSX-NativeAOT-size.txt @@ -1,7 +1,7 @@ -AppBundleSize: 8,779,453 bytes (8,573.7 KB = 8.4 MB) +AppBundleSize: 8,779,452 bytes (8,573.7 KB = 8.4 MB) # The following list of files and their sizes is just informational / for review, and isn't used in the test: Contents/Info.plist: - 775 bytes (0.8 KB = 0.0 MB) + 774 bytes (0.8 KB = 0.0 MB) Contents/MacOS/SizeTestApp: 5,690,200 bytes (5,556.8 KB = 5.4 MB) Contents/MonoBundle/libSystem.Globalization.Native.dylib: diff --git a/tests/dotnet/UnitTests/expected/TVOS-MonoVM-interpreter-size.txt b/tests/dotnet/UnitTests/expected/TVOS-MonoVM-interpreter-size.txt index ee26a4761838..c4aaef54895c 100644 --- a/tests/dotnet/UnitTests/expected/TVOS-MonoVM-interpreter-size.txt +++ b/tests/dotnet/UnitTests/expected/TVOS-MonoVM-interpreter-size.txt @@ -1,7 +1,7 @@ -AppBundleSize: 3,571,125 bytes (3,487.4 KB = 3.4 MB) +AppBundleSize: 3,571,143 bytes (3,487.4 KB = 3.4 MB) # The following list of files and their sizes is just informational / for review, and isn't used in the test: Info.plist: - 1,144 bytes (1.1 KB = 0.0 MB) + 1,162 bytes (1.1 KB = 0.0 MB) Microsoft.tvOS.dll: 152,064 bytes (148.5 KB = 0.1 MB) PkgInfo: diff --git a/tests/dotnet/UnitTests/expected/TVOS-MonoVM-size.txt b/tests/dotnet/UnitTests/expected/TVOS-MonoVM-size.txt index ba6c5fbc5b95..d543d10081b4 100644 --- a/tests/dotnet/UnitTests/expected/TVOS-MonoVM-size.txt +++ b/tests/dotnet/UnitTests/expected/TVOS-MonoVM-size.txt @@ -1,9 +1,9 @@ -AppBundleSize: 9,249,490 bytes (9,032.7 KB = 8.8 MB) +AppBundleSize: 9,249,499 bytes (9,032.7 KB = 8.8 MB) # The following list of files and their sizes is just informational / for review, and isn't used in the test: aot-instances.aotdata.arm64: 827,728 bytes (808.3 KB = 0.8 MB) Info.plist: - 1,145 bytes (1.1 KB = 0.0 MB) + 1,162 bytes (1.1 KB = 0.0 MB) Microsoft.tvOS.aotdata.arm64: 22,872 bytes (22.3 KB = 0.0 MB) Microsoft.tvOS.dll: @@ -13,7 +13,7 @@ PkgInfo: runtimeconfig.bin: 1,481 bytes (1.4 KB = 0.0 MB) SizeTestApp: - 7,151,952 bytes (6,984.3 KB = 6.8 MB) + 7,151,944 bytes (6,984.3 KB = 6.8 MB) SizeTestApp.aotdata.arm64: 1,464 bytes (1.4 KB = 0.0 MB) SizeTestApp.dll: diff --git a/tests/dotnet/UnitTests/expected/TVOS-NativeAOT-TrimmableStatic-size.txt b/tests/dotnet/UnitTests/expected/TVOS-NativeAOT-TrimmableStatic-size.txt index bb26be084dab..e70aedda61fe 100644 --- a/tests/dotnet/UnitTests/expected/TVOS-NativeAOT-TrimmableStatic-size.txt +++ b/tests/dotnet/UnitTests/expected/TVOS-NativeAOT-TrimmableStatic-size.txt @@ -1,10 +1,10 @@ -AppBundleSize: 7,804,745 bytes (7,621.8 KB = 7.4 MB) +AppBundleSize: 7,821,227 bytes (7,637.9 KB = 7.5 MB) # The following list of files and their sizes is just informational / for review, and isn't used in the test: Info.plist: - 1,128 bytes (1.1 KB = 0.0 MB) + 1,162 bytes (1.1 KB = 0.0 MB) PkgInfo: 8 bytes (0.0 KB = 0.0 MB) runtimeconfig.bin: 1,889 bytes (1.8 KB = 0.0 MB) SizeTestApp: - 7,801,720 bytes (7,618.9 KB = 7.4 MB) + 7,818,168 bytes (7,634.9 KB = 7.5 MB) diff --git a/tests/dotnet/UnitTests/expected/TVOS-NativeAOT-size.txt b/tests/dotnet/UnitTests/expected/TVOS-NativeAOT-size.txt index ebd4baa64abf..60aca8256c34 100644 --- a/tests/dotnet/UnitTests/expected/TVOS-NativeAOT-size.txt +++ b/tests/dotnet/UnitTests/expected/TVOS-NativeAOT-size.txt @@ -1,7 +1,7 @@ -AppBundleSize: 2,705,640 bytes (2,642.2 KB = 2.6 MB) +AppBundleSize: 2,705,674 bytes (2,642.3 KB = 2.6 MB) # The following list of files and their sizes is just informational / for review, and isn't used in the test: Info.plist: - 1,128 bytes (1.1 KB = 0.0 MB) + 1,162 bytes (1.1 KB = 0.0 MB) PkgInfo: 8 bytes (0.0 KB = 0.0 MB) runtimeconfig.bin: diff --git a/tests/dotnet/UnitTests/expected/iOS-MonoVM-interpreter-size.txt b/tests/dotnet/UnitTests/expected/iOS-MonoVM-interpreter-size.txt index 19f82a66454d..b396d2e38eb1 100644 --- a/tests/dotnet/UnitTests/expected/iOS-MonoVM-interpreter-size.txt +++ b/tests/dotnet/UnitTests/expected/iOS-MonoVM-interpreter-size.txt @@ -1,7 +1,7 @@ -AppBundleSize: 3,571,429 bytes (3,487.7 KB = 3.4 MB) +AppBundleSize: 3,571,447 bytes (3,487.7 KB = 3.4 MB) # The following list of files and their sizes is just informational / for review, and isn't used in the test: Info.plist: - 1,168 bytes (1.1 KB = 0.0 MB) + 1,186 bytes (1.2 KB = 0.0 MB) Microsoft.iOS.dll: 152,064 bytes (148.5 KB = 0.1 MB) PkgInfo: diff --git a/tests/dotnet/UnitTests/expected/iOS-MonoVM-size.txt b/tests/dotnet/UnitTests/expected/iOS-MonoVM-size.txt index 0451547f69d1..3018db933c82 100644 --- a/tests/dotnet/UnitTests/expected/iOS-MonoVM-size.txt +++ b/tests/dotnet/UnitTests/expected/iOS-MonoVM-size.txt @@ -1,9 +1,9 @@ -AppBundleSize: 9,249,530 bytes (9,032.7 KB = 8.8 MB) +AppBundleSize: 9,249,547 bytes (9,032.8 KB = 8.8 MB) # The following list of files and their sizes is just informational / for review, and isn't used in the test: aot-instances.aotdata.arm64: 827,728 bytes (808.3 KB = 0.8 MB) Info.plist: - 1,169 bytes (1.1 KB = 0.0 MB) + 1,186 bytes (1.2 KB = 0.0 MB) Microsoft.iOS.aotdata.arm64: 23,216 bytes (22.7 KB = 0.0 MB) Microsoft.iOS.dll: diff --git a/tests/dotnet/UnitTests/expected/iOS-NativeAOT-TrimmableStatic-size.txt b/tests/dotnet/UnitTests/expected/iOS-NativeAOT-TrimmableStatic-size.txt index 65e9726dc67c..904ad9cea70e 100644 --- a/tests/dotnet/UnitTests/expected/iOS-NativeAOT-TrimmableStatic-size.txt +++ b/tests/dotnet/UnitTests/expected/iOS-NativeAOT-TrimmableStatic-size.txt @@ -1,10 +1,10 @@ -AppBundleSize: 8,981,873 bytes (8,771.4 KB = 8.6 MB) +AppBundleSize: 8,998,346 bytes (8,787.4 KB = 8.6 MB) # The following list of files and their sizes is just informational / for review, and isn't used in the test: Info.plist: - 1,161 bytes (1.1 KB = 0.0 MB) + 1,186 bytes (1.2 KB = 0.0 MB) PkgInfo: 8 bytes (0.0 KB = 0.0 MB) runtimeconfig.bin: 1,888 bytes (1.8 KB = 0.0 MB) SizeTestApp: - 8,978,816 bytes (8,768.4 KB = 8.6 MB) + 8,995,264 bytes (8,784.4 KB = 8.6 MB) diff --git a/tests/dotnet/UnitTests/expected/iOS-NativeAOT-size.txt b/tests/dotnet/UnitTests/expected/iOS-NativeAOT-size.txt index 361df44bb6e9..f79fd23434f0 100644 --- a/tests/dotnet/UnitTests/expected/iOS-NativeAOT-size.txt +++ b/tests/dotnet/UnitTests/expected/iOS-NativeAOT-size.txt @@ -1,7 +1,7 @@ -AppBundleSize: 2,706,000 bytes (2,642.6 KB = 2.6 MB) +AppBundleSize: 2,706,034 bytes (2,642.6 KB = 2.6 MB) # The following list of files and their sizes is just informational / for review, and isn't used in the test: Info.plist: - 1,152 bytes (1.1 KB = 0.0 MB) + 1,186 bytes (1.2 KB = 0.0 MB) PkgInfo: 8 bytes (0.0 KB = 0.0 MB) runtimeconfig.bin: diff --git a/tests/monotouch-test/System.Net.Http/MessageHandlers.cs b/tests/monotouch-test/System.Net.Http/MessageHandlers.cs index 972772d27182..213112734aea 100644 --- a/tests/monotouch-test/System.Net.Http/MessageHandlers.cs +++ b/tests/monotouch-test/System.Net.Http/MessageHandlers.cs @@ -816,7 +816,7 @@ public void TestNSUrlSessionHandlerOptionalClientCertificate () { NWListener? listener = null; try { - listener = CreateNWTlsListener (requireClientCert: false); + listener = TlsTestServer.CreateNWTlsListener (requireClientCert: false); var port = listener.Port; var done = TestRuntime.TryRunAsync (TimeSpan.FromSeconds (30), async () => { @@ -839,7 +839,7 @@ public void TestNSUrlSessionHandlerDetectMissingClientCertificate () { NWListener? listener = null; try { - listener = CreateNWTlsListener (requireClientCert: true); + listener = TlsTestServer.CreateNWTlsListener (requireClientCert: true); var port = listener.Port; var done = TestRuntime.TryRunAsync (TimeSpan.FromSeconds (30), async () => { @@ -867,7 +867,7 @@ public void TestNSUrlSessionHandlerDetectMissingClientCertificateOptOut () NWListener? listener = null; try { AppContext.SetSwitch ("Foundation.NSUrlSessionHandler.NoMissingCertificateHandling", true); - listener = CreateNWTlsListener (requireClientCert: true); + listener = TlsTestServer.CreateNWTlsListener (requireClientCert: true); var port = listener.Port; var done = TestRuntime.TryRunAsync (TimeSpan.FromSeconds (30), async () => { @@ -890,77 +890,6 @@ public void TestNSUrlSessionHandlerDetectMissingClientCertificateOptOut () } } - static NWListener CreateNWTlsListener (bool requireClientCert) - { - var (pfxData, pfxPassword) = CreateSelfSignedServerCertificatePfx (); - using var secIdentity = SecIdentity.Import (pfxData, pfxPassword); - using var secIdentity2 = new SecIdentity2 (secIdentity); - using var readyEvent = new ManualResetEventSlim (false); - NWError? listenerError = null; - - var parameters = NWParameters.CreateSecureTcp ( - configureTls: tlsOptions => { - var tls = (NWProtocolTlsOptions) tlsOptions; - var secOptions = tls.ProtocolOptions; - secOptions.SetLocalIdentity (secIdentity2); - secOptions.SetPeerAuthenticationRequired (requireClientCert); - }); - using var localEndpoint = NWEndpoint.Create ("127.0.0.1", "0"); - parameters.LocalEndpoint = localEndpoint; - - var listener = NWListener.Create (parameters); - parameters.Dispose (); - - listener.SetQueue (CoreFoundation.DispatchQueue.DefaultGlobalQueue); - - listener.SetStateChangedHandler ((state, error) => { - if (state == NWListenerState.Failed) - listenerError = error; - if (state == NWListenerState.Ready || state == NWListenerState.Failed) - readyEvent.Set (); - }); - - listener.SetNewConnectionHandler (connection => { - connection.SetQueue (CoreFoundation.DispatchQueue.DefaultGlobalQueue); - connection.SetStateChangeHandler ((connState, connError) => { - if (connState == NWConnectionState.Ready) { - // Read the HTTP request (just consume it), then send a response - connection.ReceiveReadOnlyData (1, 4096, (data, context, isComplete, error) => { - var response = Encoding.UTF8.GetBytes ("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK"); - connection.Send (response, NWContentContext.FinalMessage, true, sendError => { - connection.Cancel (); - }); - }); - } - }); - connection.Start (); - }); - - listener.Start (); - - if (!readyEvent.Wait (TimeSpan.FromSeconds (10))) - throw new TimeoutException ("NWListener did not become ready in time."); - - if (listenerError is not null) - throw new InvalidOperationException ($"NWListener failed to start: {listenerError}"); - - return listener; - } - - static (byte [] Data, string Password) CreateSelfSignedServerCertificatePfx () - { - using var rsa = RSA.Create (2048); - var certRequest = new CertificateRequest ( - "CN=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - var sanBuilder = new SubjectAlternativeNameBuilder (); - sanBuilder.AddIpAddress (IPAddress.Loopback); - sanBuilder.AddDnsName ("localhost"); - certRequest.CertificateExtensions.Add (sanBuilder.Build ()); - var cert = certRequest.CreateSelfSigned (DateTimeOffset.UtcNow.AddDays (-1), DateTimeOffset.UtcNow.AddYears (1)); - var password = Guid.NewGuid ().ToString (); - return (cert.Export (X509ContentType.Pfx, password), password); - } - static HttpResponseMessage GetResponseWithTimeout (HttpClient client, Uri uri) { HttpResponseMessage? response = null; diff --git a/tests/monotouch-test/System.Net.Http/NSUrlSessionHandlerTest.cs b/tests/monotouch-test/System.Net.Http/NSUrlSessionHandlerTest.cs index 23ccf987229b..5f8be3cbd085 100644 --- a/tests/monotouch-test/System.Net.Http/NSUrlSessionHandlerTest.cs +++ b/tests/monotouch-test/System.Net.Http/NSUrlSessionHandlerTest.cs @@ -18,6 +18,12 @@ namespace MonoTests.System.Net.Http { [Preserve (AllMembers = true)] public class NSUrlSessionHandlerTest { + // The proxy tests below use in-process servers bound to 127.0.0.1, so local network + // connections should be reliable and we don't want to hide any failures by ignoring them + // in CI. Set this to true to restore the usual "ignore transient network failures in CI" + // behavior if these tests ever turn out to be flaky on the bots. + bool ignoreLocalOnlyCIFailures = false; + // https://github.com/dotnet/macios/issues/23958 [Test] public void DecompressedResponseDoesNotHaveContentEncodingOrContentLength () @@ -399,6 +405,177 @@ public void StreamReadAsyncCallerCancellationThrowsOperationCanceledException () } } + [Test] + public void ProxyRoutesRequestsThroughProxy () + { + using var proxy = new ProxyTestServer (); + + HttpStatusCode? statusCode = null; + bool viaProxy = false; + + var done = TestRuntime.TryRunAsync (TimeSpan.FromSeconds (30), async () => { + using var handler = new NSUrlSessionHandler (); + handler.Proxy = new WebProxy (proxy.Url); + Assert.That (handler.UseProxy, Is.True, "UseProxy default"); + Assert.That (handler.SupportsProxy, Is.True, "SupportsProxy"); + using var client = new HttpClient (handler); + var response = await client.GetAsync (NetworkResources.Httpbin.GetUrl).ConfigureAwait (false); + statusCode = response.StatusCode; + viaProxy = response.Headers.Contains ("Via-Test-Proxy"); + }, out var ex); + + if (!done) { + if (ignoreLocalOnlyCIFailures) + TestRuntime.IgnoreInCI ("Transient localhost server failure - ignore in CI"); + Assert.Inconclusive ("Request timed out."); + } + if (ignoreLocalOnlyCIFailures) + TestRuntime.IgnoreInCIIfBadNetwork (ex); + Assert.That (ex, Is.Null, $"Exception: {ex}"); + Assert.That (statusCode, Is.EqualTo (HttpStatusCode.OK), "Status code"); + Assert.That (viaProxy, Is.True, "Response should have gone through the test proxy"); + Assert.That (proxy.AuthenticatedRequestCount, Is.GreaterThan (0), "Proxy should have forwarded at least one request"); + } + + [Test] + public void ProxyWithCredentialsAuthenticatesWithProxy () + { + const string proxyUser = "proxyuser"; + const string proxyPass = "proxypass"; + + // NSUrlSession only delivers a proxy authentication challenge to the delegate for CONNECT + // tunnels (i.e. HTTPS destinations); for a plain HTTP forward proxy it returns the 407 + // directly to the caller. So we route an HTTPS request through the proxy's CONNECT support. + // We also request a non-local hostname: CFNetwork bypasses the proxy for localhost HTTPS + // destinations, so we must use a hostname that isn't local. The proxy tunnels every CONNECT + // to our local TLS test server regardless of the requested host. + Network.NWListener? destination = null; + HttpStatusCode? statusCode = null; + + try { + destination = TlsTestServer.CreateNWTlsListener (requireClientCert: false); + var destinationPort = destination.Port; + using var proxy = new ProxyTestServer (proxyUser, proxyPass, forceTunnelPort: (int) destinationPort); + + var done = TestRuntime.TryRunAsync (TimeSpan.FromSeconds (30), async () => { + using var handler = new NSUrlSessionHandler (); + handler.Proxy = new WebProxy (proxy.Url) { + Credentials = new NetworkCredential (proxyUser, proxyPass), + }; + handler.TrustOverrideForUrl = (sender, url, trust) => true; + using var client = new HttpClient (handler); + var response = await client.GetAsync ("https://proxy-tunnel-target.example/").ConfigureAwait (false); + statusCode = response.StatusCode; + }, out var ex); + + if (!done) { + if (ignoreLocalOnlyCIFailures) + TestRuntime.IgnoreInCI ("Transient localhost server failure - ignore in CI"); + Assert.Inconclusive ("Request timed out."); + } + if (ignoreLocalOnlyCIFailures) + TestRuntime.IgnoreInCIIfBadNetwork (ex); + Assert.That (ex, Is.Null, $"Exception: {ex}"); + Assert.That (statusCode, Is.EqualTo (HttpStatusCode.OK), $"Status code (proxy credentials should have been used); status={statusCode}, requestCount={proxy.RequestCount}, authRequestCount={proxy.AuthenticatedRequestCount}"); + Assert.That (proxy.AuthenticatedRequestCount, Is.GreaterThan (0), "Proxy should have established an authenticated tunnel"); + } finally { + destination?.Cancel (); + destination?.Dispose (); + } + } + + [Test] + public void ProxyWithDefaultProxyCredentialsAuthenticatesWithProxy () + { + const string proxyUser = "proxyuser"; + const string proxyPass = "proxypass"; + + // See ProxyWithCredentialsAuthenticatesWithProxy for why we use an HTTPS (CONNECT) request + // with a non-local hostname and tunnel every CONNECT to a local TLS test server. + Network.NWListener? destination = null; + HttpStatusCode? statusCode = null; + + try { + destination = TlsTestServer.CreateNWTlsListener (requireClientCert: false); + var destinationPort = destination.Port; + using var proxy = new ProxyTestServer (proxyUser, proxyPass, forceTunnelPort: (int) destinationPort); + + var done = TestRuntime.TryRunAsync (TimeSpan.FromSeconds (30), async () => { + using var handler = new NSUrlSessionHandler (); + handler.Proxy = new WebProxy (proxy.Url); + handler.DefaultProxyCredentials = new NetworkCredential (proxyUser, proxyPass); + handler.TrustOverrideForUrl = (sender, url, trust) => true; + using var client = new HttpClient (handler); + var response = await client.GetAsync ("https://proxy-tunnel-target.example/").ConfigureAwait (false); + statusCode = response.StatusCode; + }, out var ex); + + if (!done) { + if (ignoreLocalOnlyCIFailures) + TestRuntime.IgnoreInCI ("Transient localhost server failure - ignore in CI"); + Assert.Inconclusive ("Request timed out."); + } + if (ignoreLocalOnlyCIFailures) + TestRuntime.IgnoreInCIIfBadNetwork (ex); + Assert.That (ex, Is.Null, $"Exception: {ex}"); + Assert.That (statusCode, Is.EqualTo (HttpStatusCode.OK), $"Status code (default proxy credentials should have been used); status={statusCode}, requestCount={proxy.RequestCount}, authRequestCount={proxy.AuthenticatedRequestCount}"); + Assert.That (proxy.AuthenticatedRequestCount, Is.GreaterThan (0), "Proxy should have established an authenticated tunnel"); + } finally { + destination?.Cancel (); + destination?.Dispose (); + } + } + + [Test] + public void ProxyWithUnsupportedSchemeThrows () + { + // A secure ("https") proxy connection can't be expressed via NSUrlSession's connection proxy + // dictionary, so instead of silently connecting to the proxy over plain HTTP we should fail + // loudly. + using var handler = new NSUrlSessionHandler (); + handler.Proxy = new WebProxy ("https://127.0.0.1:8888"); + using var client = new HttpClient (handler); + + Exception? caught = null; + var done = TestRuntime.TryRunAsync (TimeSpan.FromSeconds (30), async () => { + try { + await client.GetAsync ("http://proxy-scheme-test.example/").ConfigureAwait (false); + } catch (Exception e) { + caught = e; + } + }, out var ex); + + Assert.That (done, Is.True, "Request completed"); + Assert.That (ex, Is.Null, $"Exception: {ex}"); + + // The NotSupportedException may be wrapped by HttpClient, so walk the inner exception chain. + var notSupported = caught; + while (notSupported is not null && notSupported is not NotSupportedException) + notSupported = notSupported.InnerException; + Assert.That (notSupported, Is.InstanceOf (), $"Should have thrown a NotSupportedException for an unsupported proxy scheme, but was: {caught}"); + } + + [Test] + public void ProxyPropertiesBehaveCorrectly () + { + using var handler = new NSUrlSessionHandler (); + Assert.That (handler.Proxy, Is.Null, "Proxy default"); + Assert.That (handler.UseProxy, Is.True, "UseProxy default"); + Assert.That (handler.SupportsProxy, Is.True, "SupportsProxy"); + Assert.That (handler.DefaultProxyCredentials, Is.Null, "DefaultProxyCredentials default"); + + var proxy = new WebProxy ("http://127.0.0.1:8888"); + handler.Proxy = proxy; + Assert.That (handler.Proxy, Is.SameAs (proxy), "Proxy set"); + + handler.UseProxy = false; + Assert.That (handler.UseProxy, Is.False, "UseProxy set to false"); + + var credentials = new NetworkCredential ("user", "pass"); + handler.DefaultProxyCredentials = credentials; + Assert.That (handler.DefaultProxyCredentials, Is.SameAs (credentials), "DefaultProxyCredentials set"); + } + static HttpListener? StartListenerOnAvailablePort (out int listeningPort) { // IANA suggested range for dynamic or private ports diff --git a/tests/monotouch-test/System.Net.Http/ProxyTestServer.cs b/tests/monotouch-test/System.Net.Http/ProxyTestServer.cs new file mode 100644 index 000000000000..e9ee3b818c21 --- /dev/null +++ b/tests/monotouch-test/System.Net.Http/ProxyTestServer.cs @@ -0,0 +1,357 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// +// An in-process HTTP forwarding proxy used to test NSUrlSessionHandler's proxy support. +// +// It handles absolute-form HTTP requests (the form a client sends to an HTTP proxy) to test +// proxying of the in-process HTTP test server (HttpbinTestServer), and it handles the CONNECT +// method to tunnel HTTPS requests (which is the only way NSUrlSession delivers proxy +// authentication challenges to the delegate). +// + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +#nullable enable + +namespace MonoTests.System.Net.Http { + [Preserve (AllMembers = true)] + sealed class ProxyTestServer : IDisposable { + readonly TcpListener listener; + readonly string? requiredUser; + readonly string? requiredPassword; + readonly int forceTunnelPort; + int requestCount; + int authenticatedRequestCount; + + // The total number of requests received by the proxy (including any that were rejected with a 407). + public int RequestCount => Volatile.Read (ref requestCount); + + // The number of requests that were successfully authenticated and forwarded. + public int AuthenticatedRequestCount => Volatile.Read (ref authenticatedRequestCount); + + public int Port { get; } + + public string Url => $"http://127.0.0.1:{Port}"; + + // forceTunnelPort: when non-zero, every CONNECT request is tunneled to 127.0.0.1:forceTunnelPort + // regardless of the requested target host. This lets a test request an HTTPS URL with a non-local + // hostname (so CFNetwork actually routes it through the proxy instead of bypassing the proxy for + // localhost destinations) while still tunneling to a local TLS test server. + public ProxyTestServer (string? requiredUser = null, string? requiredPassword = null, int forceTunnelPort = 0) + { + this.requiredUser = requiredUser; + this.requiredPassword = requiredPassword; + this.forceTunnelPort = forceTunnelPort; + + listener = new TcpListener (IPAddress.Loopback, 0); + listener.Start (); + Port = ((IPEndPoint) listener.LocalEndpoint).Port; + _ = Task.Run (AcceptLoop); + } + + async Task AcceptLoop () + { + try { + while (true) { + var client = await listener.AcceptTcpClientAsync ().ConfigureAwait (false); + _ = Task.Run (() => HandleClientSafe (client)); + } + } catch (ObjectDisposedException) { + // the listener was stopped + } catch (SocketException) { + // the listener was stopped + } + } + + async Task HandleClientSafe (TcpClient client) + { + try { + using (client) + using (var stream = client.GetStream ()) { + await HandleClient (stream).ConfigureAwait (false); + } + } catch (Exception e) { + // This is a test proxy, so don't let errors bring down the process, but log them so + // test failures (which often manifest as timeouts) are actionable. + Console.WriteLine ($"ProxyTestServer: error handling client: {e}"); + } + } + + async Task HandleClient (NetworkStream stream) + { + var requestLine = await ReadLineAsync (stream).ConfigureAwait (false); + if (string.IsNullOrEmpty (requestLine)) + return; + + var parts = requestLine.Split (' '); + if (parts.Length < 3) + return; + + var method = parts [0]; + var target = parts [1]; + + var headers = new List> (); + while (true) { + var line = await ReadLineAsync (stream).ConfigureAwait (false); + if (string.IsNullOrEmpty (line)) + break; + var idx = line.IndexOf (':'); + if (idx > 0) + headers.Add (new KeyValuePair (line.Substring (0, idx).Trim (), line.Substring (idx + 1).Trim ())); + } + + if (string.Equals (method, "CONNECT", StringComparison.OrdinalIgnoreCase)) { + Interlocked.Increment (ref requestCount); + await HandleConnect (stream, target, headers).ConfigureAwait (false); + return; + } + + var body = await ReadBodyAsync (stream, headers).ConfigureAwait (false); + + Interlocked.Increment (ref requestCount); + + if (requiredUser is not null) { + var proxyAuth = FindHeader (headers, "Proxy-Authorization"); + if (!IsValidProxyAuth (proxyAuth)) { + await WriteResponseAsync (stream, 407, "Proxy Authentication Required", + new List> { + new ("Proxy-Authenticate", "Basic realm=\"Test Proxy\""), + }, + Encoding.UTF8.GetBytes ("Proxy authentication required")).ConfigureAwait (false); + return; + } + } + + if (!Uri.TryCreate (target, UriKind.Absolute, out var targetUri) || targetUri.Scheme != Uri.UriSchemeHttp) { + await WriteResponseAsync (stream, 400, "Bad Request", new List> (), + Encoding.UTF8.GetBytes ("Only absolute-form HTTP requests are supported")).ConfigureAwait (false); + return; + } + + Interlocked.Increment (ref authenticatedRequestCount); + + await ForwardAsync (stream, method, targetUri, headers, body).ConfigureAwait (false); + } + + // Handles the CONNECT method: validate proxy authentication (if required), then establish a + // raw TCP tunnel to the requested host:port and pipe bytes back and forth. This is what lets + // an HTTPS request flow through the proxy while exercising proxy authentication. + async Task HandleConnect (NetworkStream clientStream, string target, List> headers) + { + if (requiredUser is not null) { + var proxyAuth = FindHeader (headers, "Proxy-Authorization"); + if (!IsValidProxyAuth (proxyAuth)) { + await WriteResponseAsync (clientStream, 407, "Proxy Authentication Required", + new List> { + new ("Proxy-Authenticate", "Basic realm=\"Test Proxy\""), + }, + Encoding.UTF8.GetBytes ("Proxy authentication required")).ConfigureAwait (false); + return; + } + } + + var host = target; + var port = 443; + var colonIdx = target.LastIndexOf (':'); + if (colonIdx > 0) { + host = target.Substring (0, colonIdx); + int.TryParse (target.Substring (colonIdx + 1), out port); + } + if (forceTunnelPort != 0) { + // Ignore the requested target and always tunnel to the local TLS test server. + host = "127.0.0.1"; + port = forceTunnelPort; + } else if (string.Equals (host, "localhost", StringComparison.OrdinalIgnoreCase)) { + // The TLS test server binds to 127.0.0.1, so make sure we connect there (and not ::1). + host = "127.0.0.1"; + } + + using var upstream = new TcpClient (); + await upstream.ConnectAsync (host, port).ConfigureAwait (false); + + Interlocked.Increment (ref authenticatedRequestCount); + + var established = Encoding.ASCII.GetBytes ("HTTP/1.1 200 Connection Established\r\n\r\n"); + await clientStream.WriteAsync (established, 0, established.Length).ConfigureAwait (false); + await clientStream.FlushAsync ().ConfigureAwait (false); + + using var upstreamStream = upstream.GetStream (); + var clientToUpstream = clientStream.CopyToAsync (upstreamStream); + var upstreamToClient = upstreamStream.CopyToAsync (clientStream); + await Task.WhenAny (clientToUpstream, upstreamToClient).ConfigureAwait (false); + } + + static async Task ReadBodyAsync (NetworkStream stream, List> headers) + { + var contentLength = FindHeader (headers, "Content-Length"); + if (contentLength is null || !int.TryParse (contentLength, out var length) || length <= 0) + return null; + + var body = new byte [length]; + var read = 0; + while (read < length) { + var r = await stream.ReadAsync (body, read, length - read).ConfigureAwait (false); + if (r <= 0) + break; + read += r; + } + // If the client disconnected early, return only the bytes we actually read (rather than a + // buffer padded with trailing zero bytes). + if (read < length) + Array.Resize (ref body, read); + return body; + } + + async Task ForwardAsync (NetworkStream clientStream, string method, Uri targetUri, List> headers, byte []? body) + { + // Explicitly avoid using any proxy for the forwarded request, so we don't accidentally loop back into ourselves. + using var handler = new SocketsHttpHandler { + UseProxy = false, + AllowAutoRedirect = false, + AutomaticDecompression = DecompressionMethods.None, + }; + using var client = new HttpClient (handler); + using var request = new HttpRequestMessage (new HttpMethod (method), targetUri); + + if (body is not null) + request.Content = new ByteArrayContent (body); + + foreach (var header in headers) { + if (IsHopByHopHeader (header.Key)) + continue; + if (string.Equals (header.Key, "Host", StringComparison.OrdinalIgnoreCase)) + continue; + // Content-Length is set automatically by ByteArrayContent. + if (string.Equals (header.Key, "Content-Length", StringComparison.OrdinalIgnoreCase)) + continue; + + if (!request.Headers.TryAddWithoutValidation (header.Key, header.Value)) + request.Content?.Headers.TryAddWithoutValidation (header.Key, header.Value); + } + + using var response = await client.SendAsync (request).ConfigureAwait (false); + + var responseHeaders = new List> { + // A marker header so tests can verify the response actually went through this proxy. + new ("Via-Test-Proxy", "true"), + }; + foreach (var header in response.Headers) { + foreach (var value in header.Value) + responseHeaders.Add (new (header.Key, value)); + } + + var content = await response.Content.ReadAsByteArrayAsync ().ConfigureAwait (false); + + foreach (var header in response.Content.Headers) { + // We compute our own Content-Length below, and Transfer-Encoding is hop-by-hop. + if (string.Equals (header.Key, "Content-Length", StringComparison.OrdinalIgnoreCase)) + continue; + foreach (var value in header.Value) + responseHeaders.Add (new (header.Key, value)); + } + + await WriteResponseAsync (clientStream, (int) response.StatusCode, response.ReasonPhrase ?? "", responseHeaders, content).ConfigureAwait (false); + } + + bool IsValidProxyAuth (string? proxyAuthorization) + { + if (proxyAuthorization is null || !proxyAuthorization.StartsWith ("Basic ", StringComparison.Ordinal)) + return false; + + try { + var credentials = Encoding.UTF8.GetString (Convert.FromBase64String (proxyAuthorization.Substring ("Basic ".Length))); + var colonIdx = credentials.IndexOf (':'); + if (colonIdx <= 0) + return false; + + var user = credentials.Substring (0, colonIdx); + var password = credentials.Substring (colonIdx + 1); + return user == requiredUser && password == requiredPassword; + } catch (FormatException) { + // Invalid base64. + return false; + } + } + + static string? FindHeader (List> headers, string name) + { + foreach (var header in headers) { + if (string.Equals (header.Key, name, StringComparison.OrdinalIgnoreCase)) + return header.Value; + } + return null; + } + + static bool IsHopByHopHeader (string name) + { + switch (name.ToLowerInvariant ()) { + case "connection": + case "keep-alive": + case "proxy-authenticate": + case "proxy-authorization": + case "te": + case "trailer": + case "transfer-encoding": + case "upgrade": + return true; + default: + return false; + } + } + + static async Task ReadLineAsync (NetworkStream stream) + { + var builder = new StringBuilder (); + var buffer = new byte [1]; + while (true) { + var read = await stream.ReadAsync (buffer, 0, 1).ConfigureAwait (false); + if (read <= 0) + return builder.Length == 0 ? null : builder.ToString (); + + var c = (char) buffer [0]; + if (c == '\n') + break; + if (c != '\r') + builder.Append (c); + } + return builder.ToString (); + } + + static async Task WriteResponseAsync (NetworkStream stream, int statusCode, string reasonPhrase, List> headers, byte [] content) + { + var builder = new StringBuilder (); + builder.Append ("HTTP/1.1 ").Append (statusCode).Append (' ').Append (reasonPhrase).Append ("\r\n"); + foreach (var header in headers) + builder.Append (header.Key).Append (": ").Append (header.Value).Append ("\r\n"); + builder.Append ("Content-Length: ").Append (content.Length).Append ("\r\n"); + // Force the connection closed so we don't have to deal with keep-alive framing. + builder.Append ("Connection: close\r\n"); + builder.Append ("\r\n"); + + var headerBytes = Encoding.ASCII.GetBytes (builder.ToString ()); + await stream.WriteAsync (headerBytes, 0, headerBytes.Length).ConfigureAwait (false); + if (content.Length > 0) + await stream.WriteAsync (content, 0, content.Length).ConfigureAwait (false); + await stream.FlushAsync ().ConfigureAwait (false); + } + + public void Dispose () + { + try { + listener.Stop (); + } catch (SocketException) { + // ignore errors during cleanup + } catch (ObjectDisposedException) { + // already disposed + } + } + } +} diff --git a/tests/monotouch-test/System.Net.Http/TlsTestServer.cs b/tests/monotouch-test/System.Net.Http/TlsTestServer.cs new file mode 100644 index 000000000000..2d4b673030cd --- /dev/null +++ b/tests/monotouch-test/System.Net.Http/TlsTestServer.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// +// An in-process TLS (HTTPS) server used by the networking tests. It uses a self-signed +// certificate and returns a minimal "200 OK" response to any request, which is enough to +// exercise the client's TLS handling (server trust, client certificates, proxy tunneling, ...). +// + +using System; +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading; + +using CoreFoundation; +using Network; +using Security; + +namespace MonoTests.System.Net.Http { + static class TlsTestServer { + // Creates a secure (TLS) NWListener bound to 127.0.0.1 on an available port. The listener + // answers every connection with a minimal "HTTP/1.1 200 OK" response and then closes it. + public static NWListener CreateNWTlsListener (bool requireClientCert) + { + var (pfxData, pfxPassword) = CreateSelfSignedServerCertificatePfx (); + using var secIdentity = SecIdentity.Import (pfxData, pfxPassword); + using var secIdentity2 = new SecIdentity2 (secIdentity); + using var readyEvent = new ManualResetEventSlim (false); + NWError? listenerError = null; + + var parameters = NWParameters.CreateSecureTcp ( + configureTls: tlsOptions => { + var tls = (NWProtocolTlsOptions) tlsOptions; + var secOptions = tls.ProtocolOptions; + secOptions.SetLocalIdentity (secIdentity2); + secOptions.SetPeerAuthenticationRequired (requireClientCert); + }); + using var localEndpoint = NWEndpoint.Create ("127.0.0.1", "0"); + parameters.LocalEndpoint = localEndpoint; + + var listener = NWListener.Create (parameters); + parameters.Dispose (); + + listener.SetQueue (DispatchQueue.DefaultGlobalQueue); + + listener.SetStateChangedHandler ((state, error) => { + if (state == NWListenerState.Failed) + listenerError = error; + if (state == NWListenerState.Ready || state == NWListenerState.Failed) + readyEvent.Set (); + }); + + listener.SetNewConnectionHandler (connection => { + connection.SetQueue (DispatchQueue.DefaultGlobalQueue); + connection.SetStateChangeHandler ((connState, connError) => { + if (connState == NWConnectionState.Ready) { + // Read the HTTP request (just consume it), then send a response + connection.ReceiveReadOnlyData (1, 4096, (data, context, isComplete, error) => { + var response = Encoding.UTF8.GetBytes ("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK"); + connection.Send (response, NWContentContext.FinalMessage, true, sendError => { + connection.Cancel (); + }); + }); + } + }); + connection.Start (); + }); + + listener.Start (); + + if (!readyEvent.Wait (TimeSpan.FromSeconds (10))) + throw new TimeoutException ("NWListener did not become ready in time."); + + if (listenerError is not null) + throw new InvalidOperationException ($"NWListener failed to start: {listenerError}"); + + return listener; + } + + public static (byte [] Data, string Password) CreateSelfSignedServerCertificatePfx () + { + using var rsa = RSA.Create (2048); + var certRequest = new CertificateRequest ( + "CN=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var sanBuilder = new SubjectAlternativeNameBuilder (); + sanBuilder.AddIpAddress (IPAddress.Loopback); + sanBuilder.AddDnsName ("localhost"); + certRequest.CertificateExtensions.Add (sanBuilder.Build ()); + var cert = certRequest.CreateSelfSigned (DateTimeOffset.UtcNow.AddDays (-1), DateTimeOffset.UtcNow.AddYears (1)); + var password = Guid.NewGuid ().ToString (); + return (cert.Export (X509ContentType.Pfx, password), password); + } + } +}