Skip to content

Commit 81d6701

Browse files
ptarjanclaude
andcommitted
Address review: reuse SendWithAuthAsync and add proxy test matrix
- Move proxy pre-auth logic into SendWithAuthAsync by handling the isProxyAuth case in the preAuthenticate block, instead of duplicating SetBasicAuthToken in SendWithProxyAuthAsync. Now SendWithProxyAuthAsync simply passes preAuthenticate: GlobalHttpSettings.SocketsHttpHandler.ProxyPreAuthenticate. - Guard the PreAuthCredentials cache logic with !isProxyAuth since proxy pre-auth doesn't use the per-pool credential cache. - Add parameterized test ProxyAuth_ProxyAndRequestProtocolCombinations_SentProactively covering 4 proxy/request protocol combinations: HTTP proxy + HTTP request, HTTP proxy + HTTPS request (CONNECT tunnel), HTTPS proxy + HTTP request, HTTPS proxy + HTTPS request (CONNECT tunnel). - Fix existing ProxyAuth_ExplicitWebProxyCredentials_SentProactively to stay within RemoteExecutor's 3-arg limit by encoding credentials in the proxy URI. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 055325b commit 81d6701

2 files changed

Lines changed: 137 additions & 33 deletions

File tree

src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/AuthenticationHelper.cs

Lines changed: 32 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -212,29 +212,44 @@ private static ValueTask<HttpResponseMessage> InnerSendAsync(HttpRequestMessage
212212

213213
private static async ValueTask<HttpResponseMessage> SendWithAuthAsync(HttpRequestMessage request, Uri authUri, bool async, ICredentials credentials, bool preAuthenticate, bool isProxyAuth, bool doRequestAuth, HttpConnectionPool pool, CancellationToken cancellationToken)
214214
{
215-
// If preauth is enabled and this isn't proxy auth, try to get a basic credential from the
216-
// preauth credentials cache, and if successful, set an auth header for it onto the request.
215+
// If preauth is enabled, try to set a Basic auth header proactively on the first request.
217216
// Currently we only support preauth for Basic.
218217
NetworkCredential? preAuthCredential = null;
219218
Uri? preAuthCredentialUri = null;
220219
if (preAuthenticate)
221220
{
222-
Debug.Assert(pool.PreAuthCredentials != null);
223-
(Uri uriPrefix, NetworkCredential credential)? preAuthCredentialPair;
224-
lock (pool.PreAuthCredentials)
221+
if (isProxyAuth)
225222
{
226-
// Just look for basic credentials. If in the future we support preauth
227-
// for other schemes, this will need to search in order of precedence.
228-
Debug.Assert(pool.PreAuthCredentials.GetCredential(authUri, NegotiateScheme) == null);
229-
Debug.Assert(pool.PreAuthCredentials.GetCredential(authUri, NtlmScheme) == null);
230-
Debug.Assert(pool.PreAuthCredentials.GetCredential(authUri, DigestScheme) == null);
231-
preAuthCredentialPair = pool.PreAuthCredentials.GetCredential(authUri, BasicScheme);
223+
// For proxy pre-authentication, get Basic credentials directly from the
224+
// supplied proxy credentials. This is needed for proxies that don't send 407
225+
// challenges but instead drop or reject unauthenticated connections.
226+
NetworkCredential? credential = credentials.GetCredential(authUri, BasicScheme);
227+
if (credential != null && credential != CredentialCache.DefaultNetworkCredentials)
228+
{
229+
preAuthCredential = credential;
230+
SetBasicAuthToken(request, credential, isProxyAuth: true);
231+
}
232232
}
233-
234-
if (preAuthCredentialPair != null)
233+
else
235234
{
236-
(preAuthCredentialUri, preAuthCredential) = preAuthCredentialPair.Value;
237-
SetBasicAuthToken(request, preAuthCredential, isProxyAuth);
235+
// For request pre-authentication, look up credentials from the preauth cache.
236+
Debug.Assert(pool.PreAuthCredentials != null);
237+
(Uri uriPrefix, NetworkCredential credential)? preAuthCredentialPair;
238+
lock (pool.PreAuthCredentials)
239+
{
240+
// Just look for basic credentials. If in the future we support preauth
241+
// for other schemes, this will need to search in order of precedence.
242+
Debug.Assert(pool.PreAuthCredentials.GetCredential(authUri, NegotiateScheme) == null);
243+
Debug.Assert(pool.PreAuthCredentials.GetCredential(authUri, NtlmScheme) == null);
244+
Debug.Assert(pool.PreAuthCredentials.GetCredential(authUri, DigestScheme) == null);
245+
preAuthCredentialPair = pool.PreAuthCredentials.GetCredential(authUri, BasicScheme);
246+
}
247+
248+
if (preAuthCredentialPair != null)
249+
{
250+
(preAuthCredentialUri, preAuthCredential) = preAuthCredentialPair.Value;
251+
SetBasicAuthToken(request, preAuthCredential, isProxyAuth);
252+
}
238253
}
239254
}
240255

@@ -299,7 +314,7 @@ await TrySetDigestAuthToken(request, challenge.Credential, digestResponse, isPro
299314
SetBasicAuthToken(request, challenge.Credential, isProxyAuth);
300315
response = await InnerSendAsync(request, async, isProxyAuth, doRequestAuth, pool, cancellationToken).ConfigureAwait(false);
301316

302-
if (preAuthenticate)
317+
if (preAuthenticate && !isProxyAuth)
303318
{
304319
switch (response.StatusCode)
305320
{
@@ -359,19 +374,7 @@ await TrySetDigestAuthToken(request, challenge.Credential, digestResponse, isPro
359374

360375
public static ValueTask<HttpResponseMessage> SendWithProxyAuthAsync(HttpRequestMessage request, Uri proxyUri, bool async, ICredentials proxyCredentials, bool doRequestAuth, HttpConnectionPool pool, CancellationToken cancellationToken)
361376
{
362-
// When enabled via AppContext switch or environment variable, send Basic auth proactively
363-
// on the first request. This is needed for proxies that don't send 407 challenges but instead
364-
// drop or reject unauthenticated connections (e.g., some HTTPS CONNECT tunnel proxies).
365-
if (GlobalHttpSettings.SocketsHttpHandler.ProxyPreAuthenticate)
366-
{
367-
NetworkCredential? credential = proxyCredentials.GetCredential(proxyUri, BasicScheme);
368-
if (credential != null && credential != CredentialCache.DefaultNetworkCredentials)
369-
{
370-
SetBasicAuthToken(request, credential, isProxyAuth: true);
371-
}
372-
}
373-
374-
return SendWithAuthAsync(request, proxyUri, async, proxyCredentials, preAuthenticate: false, isProxyAuth: true, doRequestAuth, pool, cancellationToken);
377+
return SendWithAuthAsync(request, proxyUri, async, proxyCredentials, preAuthenticate: GlobalHttpSettings.SocketsHttpHandler.ProxyPreAuthenticate, isProxyAuth: true, doRequestAuth, pool, cancellationToken);
375378
}
376379

377380
public static ValueTask<HttpResponseMessage> SendWithRequestAuthAsync(HttpRequestMessage request, bool async, ICredentials credentials, bool preAuthenticate, HttpConnectionPool pool, CancellationToken cancellationToken)

src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.ProactiveProxyAuth.cs

Lines changed: 105 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -278,24 +278,125 @@ await LoopbackServer.CreateServerAsync(async (proxyServer, proxyUri) =>
278278
await connection.SendResponseAsync(HttpStatusCode.OK).ConfigureAwait(false);
279279
});
280280

281-
await RemoteExecutor.Invoke(async (proxyUriString, username, password, useVersionString) =>
281+
// Encode proxy URI with embedded credentials so we stay within RemoteExecutor's 3-arg limit
282+
string proxyUriWithCreds = $"http://{ExpectedUsername}:{ExpectedPassword}@{proxyUri.Host}:{proxyUri.Port}";
283+
284+
await RemoteExecutor.Invoke(async (proxyUriString, useVersionString) =>
282285
{
286+
var proxyUriParsed = new Uri(proxyUriString);
283287
using HttpClientHandler handler = CreateHttpClientHandler(useVersionString);
284-
handler.Proxy = new WebProxy(new Uri(proxyUriString))
288+
handler.Proxy = new WebProxy(new Uri($"http://{proxyUriParsed.Host}:{proxyUriParsed.Port}"))
285289
{
286-
Credentials = new NetworkCredential(username, password)
290+
Credentials = new NetworkCredential(
291+
proxyUriParsed.UserInfo.Split(':')[0],
292+
proxyUriParsed.UserInfo.Split(':')[1])
287293
};
288294

289295
using HttpClient client = CreateHttpClient(handler, useVersionString);
290296
using HttpResponseMessage response = await client.GetAsync("http://destination.test/");
291297

292298
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
293-
}, proxyUri.ToString(), ExpectedUsername, ExpectedPassword, UseVersion.ToString(),
299+
}, proxyUriWithCreds, UseVersion.ToString(),
294300
new RemoteInvokeOptions { StartInfo = psi }).DisposeAsync();
295301

296302
await serverTask;
297303
});
298304
}
305+
306+
/// <summary>
307+
/// Tests proactive proxy auth across 4 proxy/request protocol combinations:
308+
/// HTTP proxy + HTTP request, HTTP proxy + HTTPS request (CONNECT tunnel),
309+
/// HTTPS proxy + HTTP request, HTTPS proxy + HTTPS request (CONNECT tunnel).
310+
/// Verifies that the Proxy-Authorization header is present on the first request to the proxy.
311+
/// </summary>
312+
[ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))]
313+
[InlineData(false, false)] // HTTP proxy + HTTP request
314+
[InlineData(false, true)] // HTTP proxy + HTTPS request (CONNECT tunnel)
315+
[InlineData(true, false)] // HTTPS proxy + HTTP request
316+
[InlineData(true, true)] // HTTPS proxy + HTTPS request (CONNECT tunnel)
317+
public async Task ProxyAuth_ProxyAndRequestProtocolCombinations_SentProactively(bool proxyUseSsl, bool requestUseSsl)
318+
{
319+
const string ExpectedUsername = "matrixuser";
320+
const string ExpectedPassword = "matrixpass";
321+
string expectedToken = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{ExpectedUsername}:{ExpectedPassword}"));
322+
323+
var proxyOptions = new LoopbackServer.Options { UseSsl = proxyUseSsl };
324+
325+
await LoopbackServer.CreateServerAsync(async (proxyServer, proxyUri) =>
326+
{
327+
var psi = new ProcessStartInfo();
328+
psi.Environment.Add(ProxyPreAuthEnvVar, "1");
329+
330+
Task serverTask = proxyServer.AcceptConnectionAsync(async connection =>
331+
{
332+
// Read the first request sent to the proxy
333+
List<string> lines = await connection.ReadRequestHeaderAsync().ConfigureAwait(false);
334+
335+
// Verify the Proxy-Authorization header is present on the first request
336+
string? authHeader = null;
337+
foreach (string line in lines)
338+
{
339+
if (line.StartsWith("Proxy-Authorization:", StringComparison.OrdinalIgnoreCase))
340+
{
341+
authHeader = line;
342+
break;
343+
}
344+
}
345+
346+
Assert.NotNull(authHeader);
347+
Assert.Contains("Basic", authHeader);
348+
Assert.Contains(expectedToken, authHeader);
349+
350+
if (requestUseSsl)
351+
{
352+
// For HTTPS request, the proxy received a CONNECT request.
353+
// Verify it's a CONNECT method.
354+
Assert.StartsWith("CONNECT", lines[0]);
355+
356+
// Send 200 to establish the tunnel
357+
await connection.SendResponseAsync(HttpStatusCode.OK).ConfigureAwait(false);
358+
359+
// Now the client will negotiate TLS through the tunnel.
360+
// Wrap the connection's stream in SSL to act as the destination server.
361+
var sslConnection = await LoopbackServer.Connection.CreateAsync(
362+
null, connection.Stream, new LoopbackServer.Options { UseSsl = true });
363+
await sslConnection.ReadRequestHeaderAndSendResponseAsync(HttpStatusCode.OK).ConfigureAwait(false);
364+
}
365+
else
366+
{
367+
// For HTTP request, the proxy received a plain GET request.
368+
Assert.StartsWith("GET", lines[0]);
369+
await connection.SendResponseAsync(HttpStatusCode.OK).ConfigureAwait(false);
370+
}
371+
});
372+
373+
string requestScheme = requestUseSsl ? "https" : "http";
374+
375+
// Encode proxy URI with embedded credentials so we stay within RemoteExecutor's 3-arg limit
376+
string proxyScheme = proxyUseSsl ? "https" : "http";
377+
string proxyUriWithCreds = $"{proxyScheme}://{ExpectedUsername}:{ExpectedPassword}@{proxyUri.Host}:{proxyUri.Port}";
378+
379+
await RemoteExecutor.Invoke(async (proxyUriString, reqScheme, useVersionString) =>
380+
{
381+
var proxyUriParsed = new Uri(proxyUriString);
382+
using HttpClientHandler handler = CreateHttpClientHandler(useVersionString);
383+
handler.Proxy = new WebProxy(new Uri($"{proxyUriParsed.Scheme}://{proxyUriParsed.Host}:{proxyUriParsed.Port}"))
384+
{
385+
Credentials = new NetworkCredential(
386+
proxyUriParsed.UserInfo.Split(':')[0],
387+
proxyUriParsed.UserInfo.Split(':')[1])
388+
};
389+
390+
using HttpClient client = CreateHttpClient(handler, useVersionString);
391+
using HttpResponseMessage response = await client.GetAsync($"{reqScheme}://destination.test/");
392+
393+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
394+
}, proxyUriWithCreds, requestScheme, UseVersion.ToString(),
395+
new RemoteInvokeOptions { StartInfo = psi }).DisposeAsync();
396+
397+
await serverTask;
398+
}, proxyOptions);
399+
}
299400
}
300401

301402
public sealed class ProactiveProxyAuthTest_Http11 : ProactiveProxyAuthTest

0 commit comments

Comments
 (0)