Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ internal enum RequestRetryType
/// <summary>
/// The proxy failed, so the request should be retried on the next proxy.
/// </summary>
RetryOnNextProxy
RetryOnNextProxy,

/// <summary>
/// The request received a session-based authentication challenge (e.g., NTLM or Negotiate) on HTTP/2 and should be retried on HTTP/1.1.
/// </summary>
RetryOnSessionAuthenticationChallenge
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ private async Task InjectNewHttp11ConnectionAsync(RequestQueue<HttpConnection>.Q

internal async ValueTask<HttpConnection> CreateHttp11ConnectionAsync(HttpRequestMessage request, bool async, CancellationToken cancellationToken)
{
(Stream stream, TransportContext? transportContext, Activity? activity, IPEndPoint? remoteEndPoint) = await ConnectAsync(request, async, cancellationToken).ConfigureAwait(false);
(Stream stream, TransportContext? transportContext, Activity? activity, IPEndPoint? remoteEndPoint) = await ConnectAsync(request, async, isForHttp2: false, cancellationToken).ConfigureAwait(false);
return await ConstructHttp11ConnectionAsync(async, stream, transportContext, request, activity, remoteEndPoint, cancellationToken).ConfigureAwait(false);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ internal sealed partial class HttpConnectionPool
private RequestQueue<Http2Connection?> _http2RequestQueue;

private bool _http2Enabled;
private bool _http2SessionAuthSeen;
private byte[]? _http2AltSvcOriginUri;
internal readonly byte[]? _http2EncodedAuthorityHostHeader;

Expand Down Expand Up @@ -184,7 +185,7 @@ private async Task InjectNewHttp2ConnectionAsync(RequestQueue<Http2Connection?>.
CancellationTokenSource cts = GetConnectTimeoutCancellationTokenSource(waiter);
try
{
(Stream stream, TransportContext? transportContext, Activity? activity, IPEndPoint? remoteEndPoint) = await ConnectAsync(queueItem.Request, true, cts.Token).ConfigureAwait(false);
(Stream stream, TransportContext? transportContext, Activity? activity, IPEndPoint? remoteEndPoint) = await ConnectAsync(queueItem.Request, true, isForHttp2: true, cts.Token).ConfigureAwait(false);

if (IsSecure)
{
Expand Down Expand Up @@ -286,6 +287,18 @@ private void HandleHttp2ConnectionFailure(HttpConnectionWaiter<Http2Connection?>
}
}

/// <summary>
/// Marks this pool as having seen a session-based authentication challenge on HTTP/2.
/// Future requests that allow downgrade (<see cref="HttpVersionPolicy.RequestVersionOrLower"/>)
/// will skip HTTP/2 and go directly to HTTP/1.1.
/// Requests that require HTTP/2 (e.g., <see cref="HttpVersionPolicy.RequestVersionExact"/>)
/// continue to use HTTP/2 as before.
/// </summary>
internal void OnSessionAuthenticationChallengeSeen()
{
_http2SessionAuthSeen = true;
}

private async Task HandleHttp11Downgrade(HttpRequestMessage request, Stream stream, TransportContext? transportContext, Activity? activity, IPEndPoint? remoteEndPoint, CancellationToken cancellationToken)
{
if (NetEventSource.Log.IsEnabled()) Trace("Server does not support HTTP2; disabling HTTP2 use and proceeding with HTTP/1.1 connection");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,8 @@ public async ValueTask<HttpResponseMessage> SendWithVersionDetectionAndRetryAsyn
// Use HTTP/2 if possible.
if (_http2Enabled &&
(request.Version.Major >= 2 || (request.VersionPolicy == HttpVersionPolicy.RequestVersionOrHigher && IsSecure)) &&
(request.VersionPolicy != HttpVersionPolicy.RequestVersionOrLower || IsSecure)) // prefer HTTP/1.1 if connection is not secured and downgrade is possible
(request.VersionPolicy != HttpVersionPolicy.RequestVersionOrLower || IsSecure) && // prefer HTTP/1.1 if connection is not secured and downgrade is possible
!(_http2SessionAuthSeen && request.VersionPolicy == HttpVersionPolicy.RequestVersionOrLower)) // skip HTTP/2 for downgradeable requests after session auth
{
if (!TryGetPooledHttp2Connection(request, out Http2Connection? connection, out http2ConnectionWaiter) &&
http2ConnectionWaiter != null)
Expand Down Expand Up @@ -534,6 +535,17 @@ public async ValueTask<HttpResponseMessage> SendWithVersionDetectionAndRetryAsyn
// Eat exception and try again on a lower protocol version.
request.Version = HttpVersion.Version11;
}
catch (HttpRequestException e) when (e.AllowRetry == RequestRetryType.RetryOnSessionAuthenticationChallenge)
{
// Server sent a session-based authentication challenge (Negotiate/NTLM) on HTTP/2.
// These authentication schemes require a persistent connection and don't work properly over HTTP/2.
// The pool flag was already set in Http2Connection.SendAsync so future downgradeable
// requests will go directly to HTTP/1.1. Retry this request on HTTP/1.1.
Debug.Assert(request.VersionPolicy == HttpVersionPolicy.RequestVersionOrLower);
Debug.Assert(_http2SessionAuthSeen);

request.Version = HttpVersion.Version11;
}
finally
{
// We never cancel both attempts at the same time. When downgrade happens, it's possible that both waiters are non-null,
Expand All @@ -545,7 +557,7 @@ public async ValueTask<HttpResponseMessage> SendWithVersionDetectionAndRetryAsyn
}
}

private async ValueTask<(Stream, TransportContext?, Activity?, IPEndPoint?)> ConnectAsync(HttpRequestMessage request, bool async, CancellationToken cancellationToken)
private async ValueTask<(Stream, TransportContext?, Activity?, IPEndPoint?)> ConnectAsync(HttpRequestMessage request, bool async, bool isForHttp2, CancellationToken cancellationToken)
{
Stream? stream = null;
IPEndPoint? remoteEndPoint = null;
Expand Down Expand Up @@ -606,7 +618,7 @@ public async ValueTask<HttpResponseMessage> SendWithVersionDetectionAndRetryAsyn
SslStream? sslStream = stream as SslStream;
if (sslStream == null)
{
sslStream = await ConnectHelper.EstablishSslConnectionAsync(GetSslOptionsForRequest(request), request, async, stream, cancellationToken).ConfigureAwait(false);
sslStream = await ConnectHelper.EstablishSslConnectionAsync(GetSslOptionsForRequest(request, isForHttp2), request, async, stream, cancellationToken).ConfigureAwait(false);
}
else
{
Expand Down Expand Up @@ -698,9 +710,11 @@ private async ValueTask<Stream> ConnectToTcpHostAsync(string host, int port, Htt
}
}

private SslClientAuthenticationOptions GetSslOptionsForRequest(HttpRequestMessage request)
private SslClientAuthenticationOptions GetSslOptionsForRequest(HttpRequestMessage request, bool isForHttp2)
{
if (_http2Enabled)
// Even if a request could use HTTP/2, we may have chosen to establish an HTTP/1.1 connection
// for it instead (e.g. when _http2SessionAuthSeen is set for downgradeable requests).
if (_http2Enabled && isForHttp2)
{
if (request.Version.Major >= 2 && request.VersionPolicy != HttpVersionPolicy.RequestVersionOrLower)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2063,7 +2063,35 @@ await Task.WhenAny(requestBodyTask, responseHeadersTask).ConfigureAwait(false) =
// Wait for the response headers to complete if they haven't already, propagating any exceptions.
await responseHeadersTask.ConfigureAwait(false);

return http2Stream.GetAndClearResponse();
HttpResponseMessage response = http2Stream.GetAndClearResponse();

// Check if this is a session-based authentication challenge (Negotiate/NTLM) on HTTP/2.
// These authentication schemes require a persistent connection and don't work properly over HTTP/2.
if (AuthenticationHelper.IsSessionAuthenticationChallenge(response))
{
// Mark the pool so future downgradeable requests go directly to HTTP/1.1.
// This is set regardless of whether we can retry this particular request,
// so that subsequent requests benefit from the downgrade.
_pool.OnSessionAuthenticationChallengeSeen();

// We can only safely retry if there's no request content, as we cannot guarantee
// that we can rewind arbitrary content streams.
// Additionally, we only retry if the version policy allows downgrade.
if (request.Content is null &&
request.VersionPolicy == HttpVersionPolicy.RequestVersionOrLower &&
!request.IsAuthDisabled())
{
if (NetEventSource.Log.IsEnabled())
{
Trace($"Received session-based authentication challenge on HTTP/2, request will be retried on HTTP/1.1.");
}

response.Dispose();
throw new HttpRequestException(HttpRequestError.UserAuthenticationError, SR.net_http_authconnectionfailure, null, RequestRetryType.RetryOnSessionAuthenticationChallenge);
}
}

return response;
}
catch (HttpIOException e)
{
Expand Down
Loading
Loading