runtime: Regression in SocketsHttpHandler when establishing connection with Negotiate
Description
This is a continuation of https://github.com/dotnet/runtime/issues/56159#issuecomment-896357274, where we reported that we were observing ObjectDisposedException
when using SocketsHttpHandler
for authenticated connections. We’ve understood the circumstances leading to the issue a little better now, and have a simple reproducer as well.
We observed the issue when using a .NET Core-based client against a custom web server implementation supporting Kerberos authentication. Depending on the presence of two headers in the initial 401 Unauthorized
response - Connection: close
and Content-Length
- we were observing different errors with SocketsHttpHandler
:
- If
Connection: close
is present, then everything is OK withSocketsHttpHandler
(regardless ofContent-Length
). - If
Connection: close
is absent, andContent-Length
is also absent, thenSocketsHttpHandler
throws anObjectDisposedException
. (I realize that a correct web server implementation should return aContent-Length
orTransfer-Encoding: chunked
for HTTP methods which have payloads defined, but it doesn’t seem correct that the runtime would surface an ODE under these circumstances.)Content-Length
is present, then we getHttpRequestException: Authentication failed because the connection could not be reused.
.
Neither of these are problematic with WinHttpHandler
- either with .NET Framework 4.8, or .NET Core 3.1 (SocketsHttpHandler
disabled via environment variable).
HTTP Server code for reproducer
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace BadHttpServer
{
public static class Program
{
public static void Main(string[] _)
{
const string crLf = "\r\n";
const int port = 4444;
var listener = new TcpListener(IPAddress.Parse("127.0.0.1"), port);
listener.Start();
Console.WriteLine($"Listening on {port}...");
while (true)
{
var client = listener.AcceptTcpClient();
var buffer = new byte[10240];
var stream = client.GetStream();
var length = stream.Read(buffer, 0, buffer.Length);
var receivedMessage = Encoding.UTF8.GetString(buffer, 0, length);
Console.WriteLine($"Received: {receivedMessage}");
var responseMessage =
receivedMessage.Contains("Authorization: Negotiate", StringComparison.OrdinalIgnoreCase)
? "HTTP/1.1 200 OK" + crLf
+ "Content-Type: text/plain" + crLf
+ crLf
+ "Successful"
: "HTTP/1.1 401 Unauthorized" + crLf
+ "Content-Type: text/plain" + crLf
+ "WWW-Authenticate: Negotiate" + crLf
//+ "Content-Length: 12" + crLf
//+ "Connection: close" + crLf
+ crLf
+ "Unauthorized" + crLf;
stream.Write(Encoding.UTF8.GetBytes(responseMessage));
client.Close();
Console.WriteLine($"Responded: {responseMessage}");
}
}
}
}
HTTP Client code for reproducer
using System;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
namespace SadHttpClient
{
public static class Program
{
public static async Task Main(string[] _)
{
const string badServer = "http://localhost:4444";
var handler = new HttpClientHandler {UseDefaultCredentials = true};
var httpClient = new HttpClient(handler);
Console.WriteLine($"Runtime Path: {Path.GetDirectoryName(typeof(object).Assembly.Location)}");
Console.Write($"Press q to exit, or any other key to poke {badServer}");
while (Console.ReadKey(true).KeyChar != 'q')
{
try
{
Console.WriteLine();
Console.WriteLine(await httpClient.GetStringAsync(badServer));
}
catch (Exception ex)
{
Console.WriteLine(ex);
}
Console.Write($"\n\nPress q to exit, or any other key to poke {badServer}");
}
}
}
}
Configuration
- The code was tested on Windows 10 1909 64-bit.
- The server for the reproducer targeted
net5.0
(although we’ve managed to repro the issue with other non-.NET web server implementations). - The client targeted various .NET runtimes (
net48
,netcoreapp3.1
(with/withoutSocketsHttpHandler
),net5.0
).
Regression?
Yes. This is not a problem with WinHttpHandler
(either in .NET Framework 4.8, or in .NET Core 3.1 with the environment variable set to disable SocketsHttpHandler
).
Other information
I’ve included outputs / stack traces below from a few applicable scenarios (collapsed for brevity).
Curl.exe output with both Content-Length and Connection: close missing.
PS C:\Users\redacted> curl.exe -vvv --negotiate -u : "http://localhost:4444/"
* Trying ::1...
* TCP_NODELAY set
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 4444 (#0)
> GET / HTTP/1.1
> Host: localhost:4444
> User-Agent: curl/7.55.1
> Accept: */*
>
< HTTP/1.1 401 Unauthorized
< Content-Type: text/plain
< WWW-Authenticate: Negotiate
* no chunk, no close, no size. Assume close to signal end
<
* Closing connection 0
* Issue another request to this URL: 'http://localhost:4444/'
* Hostname localhost was found in DNS cache
* Trying ::1...
* TCP_NODELAY set
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 4444 (#1)
* Server auth using Negotiate with user ''
> GET / HTTP/1.1
> Host: localhost:4444
> Authorization: Negotiate <SNIP>
> User-Agent: curl/7.55.1
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Type: text/plain
* no chunk, no close, no size. Assume close to signal end
<
Successful* Closing connection 1
Output from .NET Framework / WinHttpHandler client with both Content-Length and Connection: close missing.
Runtime Path: C:\Windows\Microsoft.NET\Framework64\v4.0.30319
Press q to exit, or any other key to poke http://localhost:4444
Successful
Stack Trace with both Content-Length and Connection: close missing when using SocketsHttpHandler.
Runtime Path: C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.9
Press q to exit, or any other key to poke http://localhost:4444
System.Net.Http.HttpRequestException: An error occurred while sending the request.
---> System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'System.Net.Sockets.NetworkStream'.
at System.Net.Sockets.NetworkStream.<ThrowIfDisposed>g__ThrowObjectDisposedException|63_0()
at System.Net.Sockets.NetworkStream.WriteAsync(ReadOnlyMemory`1 buffer, CancellationToken cancellationToken)
at System.Net.Http.HttpConnection.WriteToStreamAsync(ReadOnlyMemory`1 source, Boolean async)
at System.Net.Http.HttpConnection.FlushAsync(Boolean async)
at System.Net.Http.HttpConnection.SendAsyncCore(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)
--- End of inner exception stack trace ---
at System.Net.Http.HttpConnection.SendAsyncCore(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)
at System.Net.Http.AuthenticationHelper.SendWithNtAuthAsync(HttpRequestMessage request, Uri authUri, Boolean async, ICredentials credentials, Boolean isProxyAuth, HttpConnection connection, HttpConnectionPool connectionPool, CancellationToken cancellationToken)
at System.Net.Http.HttpConnectionPool.SendWithRetryAsync(HttpRequestMessage request, Boolean async, Boolean doRequestAuth, CancellationToken cancellationToken)
at System.Net.Http.AuthenticationHelper.SendWithAuthAsync(HttpRequestMessage request, Uri authUri, Boolean async, ICredentials credentials, Boolean preAuthenticate, Boolean isProxyAuth, Boolean doRequestAuth, HttpConnectionPool pool, CancellationToken cancellationToken)
at System.Net.Http.RedirectHandler.SendAsync(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)
at System.Net.Http.HttpClient.SendAsyncCore(HttpRequestMessage request, HttpCompletionOption completionOption, Boolean async, Boolean emitTelemetryStartStop, CancellationToken cancellationToken)
at System.Net.Http.HttpClient.GetStringAsyncCore(HttpRequestMessage request, CancellationToken cancellationToken)
at SadHttpClient.Program.Main(String[] _) in C:\local\SadHttpClient\Program.cs:line 24
Stack Trace with Content-Length set, but Connection: close missing when using SocketsHttpHandler.
Runtime Path: C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.9
Press q to exit, or any other key to poke http://localhost:4444
System.Net.Http.HttpRequestException: Authentication failed because the connection could not be reused.
at System.Net.Http.HttpConnection.DrainResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken)
at System.Net.Http.AuthenticationHelper.SendWithNtAuthAsync(HttpRequestMessage request, Uri authUri, Boolean async, ICredentials credentials, Boolean isProxyAuth, HttpConnection connection, HttpConnectionPool connectionPool, CancellationToken cancellationToken)
at System.Net.Http.HttpConnectionPool.SendWithRetryAsync(HttpRequestMessage request, Boolean async, Boolean doRequestAuth, CancellationToken cancellationToken)
at System.Net.Http.AuthenticationHelper.SendWithAuthAsync(HttpRequestMessage request, Uri authUri, Boolean async, ICredentials credentials, Boolean preAuthenticate, Boolean isProxyAuth, Boolean doRequestAuth, HttpConnectionPool pool, CancellationToken cancellationToken)
at System.Net.Http.RedirectHandler.SendAsync(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)
at System.Net.Http.HttpClient.SendAsyncCore(HttpRequestMessage request, HttpCompletionOption completionOption, Boolean async, Boolean emitTelemetryStartStop, CancellationToken cancellationToken)
at System.Net.Http.HttpClient.GetStringAsyncCore(HttpRequestMessage request, CancellationToken cancellationToken)
at SadHttpClient.Program.Main(String[] _) in C:\local\SadHttpClient\Program.cs:line 24
About this issue
- Original URL
- State: open
- Created 3 years ago
- Comments: 23 (17 by maintainers)
Re this:
The core response handling logic treats this as if Connection: close was specified, which is the desired behavior – it’s technically not valid per RFC, but most clients are lenient here and just assume it means Connection: close.
But the authentication logic is specifically checking for the Connection: close header, and when it doesn’t find it, it assumes the connection won’t be closed. So the core response logic ends up closing the connection, which the auth logic isn’t expecting, and it tries to use the connection again, leading to ODE.
We should probably change this so that the auth logic handles this the same way as the core request logic – that is, assume that if Content-Length and Transfer-Encoding: chunked are both missing, then this implicitly means Connection: close.
Thanks, the data and code you provided is very useful. If we need more info we will let you know.
I I’m planning to take a look @geoffkizer and at least reproduce the problem. The ODE is haunting us for long time and this should be easy to turn into enterprise-auth test. We can perhaps tackle #31133 together.