Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
abc791a
[Foundation] Implement NSUrlSessionHandler proxy support
rolfbjarne Jul 9, 2026
3430bf6
[Foundation] Fix NSUrlSessionHandler proxy authentication challenge h…
rolfbjarne Jul 9, 2026
cfef971
[tests] Update expected app size files for NSUrlSessionHandler proxy …
rolfbjarne Jul 9, 2026
28dfc09
[Foundation] Send proxy credentials via the connection proxy dictionary
rolfbjarne Jul 9, 2026
a0b5eb5
[Foundation] Revert ineffective proxy-dict credentials; add proxy tes…
rolfbjarne Jul 10, 2026
86dda0d
[Foundation] Test proxy authentication via HTTPS CONNECT tunnel
rolfbjarne Jul 10, 2026
12e3605
Auto-format source code
Jul 10, 2026
71319b4
[tests] Fix missing closing brace in ProxyTestServer.HandleConnect
rolfbjarne Jul 10, 2026
beec855
[tests] Fix proxy-auth tests: avoid CFNetwork localhost proxy bypass
rolfbjarne Jul 10, 2026
91e7078
Address PR review comments
rolfbjarne Jul 14, 2026
6027f05
Address PR review comments (round 2)
rolfbjarne Jul 14, 2026
d2aeaa4
[tests] Don't ignore CI failures for the local-only proxy tests
rolfbjarne Jul 15, 2026
127b6ad
[Foundation] Address proxy-support review feedback
rolfbjarne Jul 16, 2026
d4716ab
Merge remote-tracking branch 'origin/main' into dev/rolf/issue-14632-…
rolfbjarne Jul 17, 2026
4ecd0d5
[tests] Update expected sizes.
rolfbjarne Jul 17, 2026
6bbee6f
[Foundation] Throw for unsupported proxy schemes instead of silently …
rolfbjarne Jul 17, 2026
9e5575d
Merge remote-tracking branch 'origin/main' into dev/rolf/issue-14632-…
rolfbjarne Aug 3, 2026
44246da
[tests] Update expected sizes.
rolfbjarne Aug 3, 2026
6bffe0d
Merge remote-tracking branch 'origin/main' into dev/rolf/issue-14632-…
rolfbjarne Aug 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 152 additions & 28 deletions src/Foundation/NSUrlSessionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -449,15 +449,98 @@ async Task<NSUrlRequest> CreateRequest (HttpRequestMessage request)
return nsrequest;
}

readonly object proxyConfigurationLock = new object ();
Comment thread
rolfbjarne marked this conversation as resolved.
bool proxyConfigured;

// 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;
proxyConfigured = true;

if (!TryGetProxyDictionary (request.RequestUri, out var proxyDictionary))
return;

var oldSession = session;
var configuration = session.Configuration;
configuration.ConnectionProxyDictionary = proxyDictionary;
session = NSUrlSession.FromConfiguration (configuration, (INSUrlSessionDelegate) new NSUrlSessionHandlerDelegate (this), null);
Comment thread
rolfbjarne marked this conversation as resolved.
Outdated
oldSession.Dispose ();
}
}

// 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;
}

var strongProxy = new ProxyConfigurationDictionary {
Comment thread
rolfbjarne marked this conversation as resolved.
HttpEnable = true,
HttpProxyHost = proxyUri.Host,
HttpProxyPort = proxyUri.Port,
HttpsProxyHost = proxyUri.Host,
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;
}

/// <param name="request">To be added.</param>
/// <param name="cancellationToken">To be added.</param>
/// <summary>To be added.</summary>
/// <returns>To be added.</returns>
/// <remarks>To be added.</remarks>
protected override async Task<HttpResponseMessage> 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);

Comment thread
rolfbjarne marked this conversation as resolved.
ConfigureSessionProxy (request);

var nsrequest = await CreateRequest (request).ConfigureAwait (false);
var dataTask = session.CreateDataTask (nsrequest);

Expand Down Expand Up @@ -558,14 +641,20 @@ 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;

/// <summary>The credentials to submit to the proxy server for authentication.</summary>
/// <value>The credentials to use to authenticate with the proxy, or <see langword="null" /> to not provide any proxy credentials.</value>
/// <remarks>These credentials are only used when the proxy itself (<see cref="Proxy" />) doesn't provide its own credentials.</remarks>
public ICredentials? DefaultProxyCredentials {
get {
return defaultProxyCredentials;
}
set {
EnsureModifiability ();
defaultProxyCredentials = value;
}
}

public int MaxAutomaticRedirections {
get => int.MaxValue;
Expand Down Expand Up @@ -614,18 +703,21 @@ public bool PreAuthenticate {
[EditorBrowsable (EditorBrowsableState.Never)]
public IDictionary<string, object>? 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;

/// <summary>The proxy to use for requests.</summary>
/// <value>The custom proxy to use, or <see langword="null" /> to use the proxies configured in the operating system.</value>
/// <remarks>
/// <para>Setting this property only has an effect if <see cref="UseProxy" /> is <see langword="true" /> (which is the default value).</para>
/// <para>NSUrlSession applies proxy settings per-session, not per-request, so the proxy returned by <see cref="IWebProxy.GetProxy(System.Uri)" /> for the first request is applied to every request made by this handler.</para>
/// </remarks>
public IWebProxy? Proxy {
get => null;
get {
return proxy;
}
set {
if (value is not null)
throw new PlatformNotSupportedException ();
EnsureModifiability ();
proxy = value;
}
}

Expand Down Expand Up @@ -743,9 +835,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,
Expand All @@ -754,13 +847,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;
}
}

Expand Down Expand Up @@ -1175,7 +1270,29 @@ 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:
// for a proxy the protection space reports a proxy authentication method (HTTPProxy/HTTPSProxy)
// rather than a specific scheme like Basic, and the credentials come from the proxy configuration
// (the proxy's own credentials or the DefaultProxyCredentials).
if (challenge.ProtectionSpace.IsProxy) {
var proxyCredentials = sessionHandler.Proxy?.Credentials ?? sessionHandler.DefaultProxyCredentials;
Comment thread
rolfbjarne marked this conversation as resolved.
Outdated
// 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);
var proxyCredential = proxyCredentials.GetCredential (proxyUri, "Basic");
Comment thread
Copilot marked this conversation as resolved.
Outdated
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,
Expand All @@ -1196,8 +1313,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);
}
}

Expand All @@ -1214,6 +1331,13 @@ void DidReceiveChallengeImpl (NSUrlSession session, NSUrlSessionTask task, NSUrl
}
}

static Uri GetProxyLookupUri (NSUrlProtectionSpace protectionSpace)
{
var scheme = protectionSpace.ReceivesCredentialSecurely ? "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;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
AppBundleSize: 13,916,973 bytes (13,590.8 KB = 13.3 MB)
AppBundleSize: 13,933,496 bytes (13,606.9 KB = 13.3 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,109 bytes (1.1 KB = 0.0 MB)
1,040 bytes (1.0 KB = 0.0 MB)
Contents/MacOS/SizeTestApp:
13,913,960 bytes (13,587.9 KB = 13.3 MB)
13,930,552 bytes (13,604.1 KB = 13.3 MB)
Contents/MonoBundle/runtimeconfig.bin:
1,896 bytes (1.9 KB = 0.0 MB)
Contents/PkgInfo:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
AppBundleSize: 32,590,412 bytes (31,826.6 KB = 31.1 MB)
AppBundleSize: 32,623,366 bytes (31,858.8 KB = 31.1 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)
718 bytes (0.7 KB = 0.0 MB)
Contents/MacOS/SizeTestApp:
29,501,112 bytes (28,809.7 KB = 28.1 MB)
29,534,088 bytes (28,841.9 KB = 28.2 MB)
Contents/MonoBundle/libSystem.Globalization.Native.dylib:
267,872 bytes (261.6 KB = 0.3 MB)
Contents/MonoBundle/libSystem.IO.Compression.Native.dylib:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
AppBundleSize: 12,531,409 bytes (12,237.7 KB = 12.0 MB)
AppBundleSize: 12,564,243 bytes (12,269.8 KB = 12.0 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,106 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:
12,528,384 bytes (12,234.8 KB = 11.9 MB)
12,561,240 bytes (12,266.8 KB = 12.0 MB)
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
AppBundleSize: 14,136,248 bytes (13,804.9 KB = 13.5 MB)
AppBundleSize: 14,152,698 bytes (13,821.0 KB = 13.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,152 bytes (1.1 KB = 0.0 MB)
1,130 bytes (1.1 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:
14,133,200 bytes (13,802.0 KB = 13.5 MB)
14,149,672 bytes (13,818.0 KB = 13.5 MB)
Loading
Loading