Browse Source

fetch TLS client hello message from HTTP.SYS (#61494)

* setup for tls clinet hello exposure

* correctly retry access

* last minute changes

* fix warnings

* hook up tls client hello callback

* fix warnings & publish API

* minimal

* only go via callback if options has callback set; remove unused

* PR review

* address PR comments x1

* TTL & evict approach

* address comments 1

* periodic timer

* address comments x3

* TryAdd

* make a static field (just in case)

* Cache updates

* test

* whitespace

* clear array

* appcontext

* fb

* bp changes

* Update src/Servers/HttpSys/src/RequestProcessing/RequestContext.cs

Co-authored-by: Aditya Mandaleeka <[email protected]>

---------

Co-authored-by: Dmitrii Korolev <[email protected]>
Co-authored-by: Aditya Mandaleeka <[email protected]>
Brennan 10 months ago
parent
commit
ae651c893a
22 changed files with 845 additions and 39 deletions
  1. 19 0
      AspNetCore.sln
  2. 2 1
      src/Servers/HttpSys/HttpSysServer.slnf
  3. 123 0
      src/Servers/HttpSys/samples/TlsFeaturesObserve/HttpSys/HttpSysConfigurator.cs
  4. 97 0
      src/Servers/HttpSys/samples/TlsFeaturesObserve/HttpSys/Native.cs
  5. 69 0
      src/Servers/HttpSys/samples/TlsFeaturesObserve/Program.cs
  6. 10 0
      src/Servers/HttpSys/samples/TlsFeaturesObserve/Properties/launchSettings.json
  7. 28 0
      src/Servers/HttpSys/samples/TlsFeaturesObserve/Startup.cs
  8. 14 0
      src/Servers/HttpSys/samples/TlsFeaturesObserve/TlsFeaturesObserve.csproj
  9. 12 16
      src/Servers/HttpSys/src/HttpSysListener.cs
  10. 14 1
      src/Servers/HttpSys/src/HttpSysOptions.cs
  11. 1 0
      src/Servers/HttpSys/src/LoggerEventIds.cs
  12. 25 7
      src/Servers/HttpSys/src/NativeInterop/HttpApi.cs
  13. 5 0
      src/Servers/HttpSys/src/RequestProcessing/Request.cs
  14. 3 0
      src/Servers/HttpSys/src/RequestProcessing/RequestContext.Log.cs
  15. 106 13
      src/Servers/HttpSys/src/RequestProcessing/RequestContext.cs
  16. 6 0
      src/Servers/HttpSys/src/RequestProcessing/RequestContextOfT.cs
  17. 15 0
      src/Servers/HttpSys/src/RequestProcessing/TlsListener.Log.cs
  18. 144 0
      src/Servers/HttpSys/src/RequestProcessing/TlsListener.cs
  19. 1 0
      src/Servers/HttpSys/test/FunctionalTests/Microsoft.AspNetCore.Server.HttpSys.FunctionalTests.csproj
  20. 141 0
      src/Servers/HttpSys/test/FunctionalTests/TlsListenerTests.cs
  21. 1 0
      src/Shared/HttpSys/NativeInterop/UnsafeNativeMethods.cs
  22. 9 1
      src/Shared/HttpSys/RequestProcessing/NativeRequestContext.cs

+ 19 - 0
AspNetCore.sln

@@ -1784,6 +1784,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NotReferencedInWasmCodePack
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Components.WasmRemoteAuthentication", "src\Components\test\testassets\Components.WasmRemoteAuthentication\Components.WasmRemoteAuthentication.csproj", "{8A021D6D-7935-4AB3-BB47-38D4FF9B0D13}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TlsFeaturesObserve", "src\Servers\HttpSys\samples\TlsFeaturesObserve\TlsFeaturesObserve.csproj", "{98C71EC8-1303-F55D-4032-E6728971770E}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -10753,6 +10755,22 @@ Global
 		{8A021D6D-7935-4AB3-BB47-38D4FF9B0D13}.Release|x64.Build.0 = Release|Any CPU
 		{8A021D6D-7935-4AB3-BB47-38D4FF9B0D13}.Release|x86.ActiveCfg = Release|Any CPU
 		{8A021D6D-7935-4AB3-BB47-38D4FF9B0D13}.Release|x86.Build.0 = Release|Any CPU
+		{98C71EC8-1303-F55D-4032-E6728971770E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{98C71EC8-1303-F55D-4032-E6728971770E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{98C71EC8-1303-F55D-4032-E6728971770E}.Debug|arm64.ActiveCfg = Debug|Any CPU
+		{98C71EC8-1303-F55D-4032-E6728971770E}.Debug|arm64.Build.0 = Debug|Any CPU
+		{98C71EC8-1303-F55D-4032-E6728971770E}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{98C71EC8-1303-F55D-4032-E6728971770E}.Debug|x64.Build.0 = Debug|Any CPU
+		{98C71EC8-1303-F55D-4032-E6728971770E}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{98C71EC8-1303-F55D-4032-E6728971770E}.Debug|x86.Build.0 = Debug|Any CPU
+		{98C71EC8-1303-F55D-4032-E6728971770E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{98C71EC8-1303-F55D-4032-E6728971770E}.Release|Any CPU.Build.0 = Release|Any CPU
+		{98C71EC8-1303-F55D-4032-E6728971770E}.Release|arm64.ActiveCfg = Release|Any CPU
+		{98C71EC8-1303-F55D-4032-E6728971770E}.Release|arm64.Build.0 = Release|Any CPU
+		{98C71EC8-1303-F55D-4032-E6728971770E}.Release|x64.ActiveCfg = Release|Any CPU
+		{98C71EC8-1303-F55D-4032-E6728971770E}.Release|x64.Build.0 = Release|Any CPU
+		{98C71EC8-1303-F55D-4032-E6728971770E}.Release|x86.ActiveCfg = Release|Any CPU
+		{98C71EC8-1303-F55D-4032-E6728971770E}.Release|x86.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
@@ -11634,6 +11652,7 @@ Global
 		{F232B503-D412-45EE-8B31-EFD46B9FA302} = {AA5ABFBC-177C-421E-B743-005E0FD1248B}
 		{433F91E4-E39D-4EB0-B798-2998B3969A2C} = {6126DCE4-9692-4EE2-B240-C65743572995}
 		{8A021D6D-7935-4AB3-BB47-38D4FF9B0D13} = {6126DCE4-9692-4EE2-B240-C65743572995}
+		{98C71EC8-1303-F55D-4032-E6728971770E} = {49016328-4D32-46E4-A4D2-94686ED38EA2}
 	EndGlobalSection
 	GlobalSection(ExtensibilityGlobals) = postSolution
 		SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F}

+ 2 - 1
src/Servers/HttpSys/HttpSysServer.slnf

@@ -37,6 +37,7 @@
       "src\\Servers\\HttpSys\\samples\\QueueSharing\\QueueSharing.csproj",
       "src\\Servers\\HttpSys\\samples\\SelfHostServer\\SelfHostServer.csproj",
       "src\\Servers\\HttpSys\\samples\\TestClient\\TestClient.csproj",
+      "src\\Servers\\HttpSys\\samples\\TlsFeaturesObserve\\TlsFeaturesObserve.csproj",
       "src\\Servers\\HttpSys\\src\\Microsoft.AspNetCore.Server.HttpSys.csproj",
       "src\\Servers\\HttpSys\\test\\FunctionalTests\\Microsoft.AspNetCore.Server.HttpSys.FunctionalTests.csproj",
       "src\\Servers\\HttpSys\\test\\NonHelixTests\\Microsoft.AspNetCore.Server.HttpSys.NonHelixTests.csproj",
@@ -53,4 +54,4 @@
       "src\\WebEncoders\\src\\Microsoft.Extensions.WebEncoders.csproj"
     ]
   }
-}
+}

+ 123 - 0
src/Servers/HttpSys/samples/TlsFeaturesObserve/HttpSys/HttpSysConfigurator.cs

@@ -0,0 +1,123 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Net;
+using System.Runtime.InteropServices;
+
+namespace TlsFeaturesObserve.HttpSys;
+
+internal static class HttpSysConfigurator
+{
+    const uint HTTP_INITIALIZE_CONFIG = 0x00000002;
+    const uint ERROR_ALREADY_EXISTS = 183;
+
+    static readonly HTTPAPI_VERSION HttpApiVersion = new HTTPAPI_VERSION(1, 0);
+
+    internal static void ConfigureCacheTlsClientHello()
+    {
+        // Arbitrarily chosen port, but must match the port used in the web server. Via UrlPrefixes or launchsettings.
+        var ipPort = new IPEndPoint(new IPAddress([0, 0, 0, 0]), 6000);
+        var certThumbprint = "" /* your cert thumbprint here */;
+        var appId = Guid.NewGuid();
+        var sslCertStoreName = "My";
+
+        CallHttpApi(() => SetConfiguration(ipPort, certThumbprint, appId, sslCertStoreName));
+    }
+
+    static void SetConfiguration(IPEndPoint ipPort, string certThumbprint, Guid appId, string sslCertStoreName)
+    {
+        var sockAddrHandle = CreateSockaddrStructure(ipPort);
+        var pIpPort = sockAddrHandle.AddrOfPinnedObject();
+        var httpServiceConfigSslKey = new HTTP_SERVICE_CONFIG_SSL_KEY(pIpPort);
+
+        var hash = GetHash(certThumbprint);
+        var handleHash = GCHandle.Alloc(hash, GCHandleType.Pinned);
+        var configSslParam = new HTTP_SERVICE_CONFIG_SSL_PARAM
+        {
+            AppId = appId,
+            DefaultFlags = 0x00008000 /* HTTP_SERVICE_CONFIG_SSL_FLAG_ENABLE_CACHE_CLIENT_HELLO */,
+            DefaultRevocationFreshnessTime = 0,
+            DefaultRevocationUrlRetrievalTimeout = 15,
+            pSslCertStoreName = sslCertStoreName,
+            pSslHash = handleHash.AddrOfPinnedObject(),
+            SslHashLength = hash.Length,
+            pDefaultSslCtlIdentifier = null,
+            pDefaultSslCtlStoreName = sslCertStoreName
+        };
+
+        var configSslSet = new HTTP_SERVICE_CONFIG_SSL_SET
+        {
+            ParamDesc = configSslParam,
+            KeyDesc = httpServiceConfigSslKey
+        };
+
+        var pInputConfigInfo = Marshal.AllocCoTaskMem(
+            Marshal.SizeOf(typeof(HTTP_SERVICE_CONFIG_SSL_SET)));
+        Marshal.StructureToPtr(configSslSet, pInputConfigInfo, false);
+
+        var status = HttpSetServiceConfiguration(nint.Zero,
+            HTTP_SERVICE_CONFIG_ID.HttpServiceConfigSSLCertInfo,
+            pInputConfigInfo,
+            Marshal.SizeOf(configSslSet),
+            nint.Zero);
+
+        if (status == ERROR_ALREADY_EXISTS || status == 0) // already present or success
+        {
+            Console.WriteLine($"HttpServiceConfiguration is correct");
+        }
+        else
+        {
+            Console.WriteLine("Failed to HttpSetServiceConfiguration: " + status);
+        }
+    }
+
+    static byte[] GetHash(string thumbprint)
+    {
+        var length = thumbprint.Length;
+        var bytes = new byte[length / 2];
+        for (var i = 0; i < length; i += 2)
+        {
+            bytes[i / 2] = Convert.ToByte(thumbprint.Substring(i, 2), 16);
+        }
+
+        return bytes;
+    }
+
+    static GCHandle CreateSockaddrStructure(IPEndPoint ipEndPoint)
+    {
+        var socketAddress = ipEndPoint.Serialize();
+
+        // use an array of bytes instead of the sockaddr structure 
+        var sockAddrStructureBytes = new byte[socketAddress.Size];
+        var sockAddrHandle = GCHandle.Alloc(sockAddrStructureBytes, GCHandleType.Pinned);
+        for (var i = 0; i < socketAddress.Size; ++i)
+        {
+            sockAddrStructureBytes[i] = socketAddress[i];
+        }
+        return sockAddrHandle;
+    }
+
+    static void CallHttpApi(Action body)
+    {
+        const uint flags = HTTP_INITIALIZE_CONFIG;
+        var retVal = HttpInitialize(HttpApiVersion, flags, IntPtr.Zero);
+        body();
+    }
+
+// disabled warning since it is just a sample
+#pragma warning disable SYSLIB1054 // Use 'LibraryImportAttribute' instead of 'DllImportAttribute' to generate P/Invoke marshalling code at compile time
+    [DllImport("httpapi.dll", SetLastError = true)]
+    private static extern uint HttpInitialize(
+            HTTPAPI_VERSION version,
+            uint flags,
+            IntPtr pReserved);
+
+    [DllImport("httpapi.dll", SetLastError = true)]
+    public static extern uint HttpSetServiceConfiguration(
+        nint serviceIntPtr,
+        HTTP_SERVICE_CONFIG_ID configId,
+        nint pConfigInformation,
+        int configInformationLength,
+        nint pOverlapped);
+#pragma warning restore SYSLIB1054 // Use 'LibraryImportAttribute' instead of 'DllImportAttribute' to generate P/Invoke marshalling code at compile time
+}

+ 97 - 0
src/Servers/HttpSys/samples/TlsFeaturesObserve/HttpSys/Native.cs

@@ -0,0 +1,97 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
+using System.Text;
+
+namespace TlsFeaturesObserve.HttpSys;
+
+// Http.Sys types from https://learn.microsoft.com/windows/win32/api/http/
+
+[StructLayout(LayoutKind.Sequential, Pack = 2)]
+public struct HTTPAPI_VERSION
+{
+    public ushort HttpApiMajorVersion;
+    public ushort HttpApiMinorVersion;
+
+    public HTTPAPI_VERSION(ushort majorVersion, ushort minorVersion)
+    {
+        HttpApiMajorVersion = majorVersion;
+        HttpApiMinorVersion = minorVersion;
+    }
+}
+
+public enum HTTP_SERVICE_CONFIG_ID
+{
+    HttpServiceConfigIPListenList = 0,
+    HttpServiceConfigSSLCertInfo,
+    HttpServiceConfigUrlAclInfo,
+    HttpServiceConfigMax
+}
+
+[StructLayout(LayoutKind.Sequential)]
+public struct HTTP_SERVICE_CONFIG_SSL_SET
+{
+    public HTTP_SERVICE_CONFIG_SSL_KEY KeyDesc;
+    public HTTP_SERVICE_CONFIG_SSL_PARAM ParamDesc;
+}
+
+[StructLayout(LayoutKind.Sequential)]
+public struct HTTP_SERVICE_CONFIG_SSL_KEY
+{
+    public IntPtr pIpPort;
+
+    public HTTP_SERVICE_CONFIG_SSL_KEY(IntPtr pIpPort)
+    {
+        this.pIpPort = pIpPort;
+    }
+}
+
+[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+public struct HTTP_SERVICE_CONFIG_SSL_PARAM
+{
+    public int SslHashLength;
+    public IntPtr pSslHash;
+    public Guid AppId;
+    [MarshalAs(UnmanagedType.LPWStr)]
+    public string pSslCertStoreName;
+    public CertCheckModes DefaultCertCheckMode;
+    public int DefaultRevocationFreshnessTime;
+    public int DefaultRevocationUrlRetrievalTimeout;
+    [MarshalAs(UnmanagedType.LPWStr)]
+    public string pDefaultSslCtlIdentifier;
+    [MarshalAs(UnmanagedType.LPWStr)]
+    public string pDefaultSslCtlStoreName;
+    public uint DefaultFlags; // HTTP_SERVICE_CONFIG_SSL_FLAG
+}
+
+[Flags]
+public enum CertCheckModes : uint
+{
+    /// <summary>
+    /// Enables the client certificate revocation check.
+    /// </summary>
+    None = 0,
+
+    /// <summary>
+    /// Client certificate is not to be verified for revocation. 
+    /// </summary>
+    DoNotVerifyCertificateRevocation = 1,
+
+    /// <summary>
+    /// Only cached certificate is to be used the revocation check. 
+    /// </summary>
+    VerifyRevocationWithCachedCertificateOnly = 2,
+
+    /// <summary>
+    /// The RevocationFreshnessTime setting is enabled.
+    /// </summary>
+    EnableRevocationFreshnessTime = 4,
+
+    /// <summary>
+    /// No usage check is to be performed.
+    /// </summary>
+    NoUsageCheck = 0x10000
+}

+ 69 - 0
src/Servers/HttpSys/samples/TlsFeaturesObserve/Program.cs

@@ -0,0 +1,69 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Reflection;
+using System.Runtime.InteropServices;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http.Features;
+using Microsoft.AspNetCore.Server.HttpSys;
+using Microsoft.Extensions.Hosting;
+using TlsFeatureObserve;
+using TlsFeaturesObserve.HttpSys;
+
+HttpSysConfigurator.ConfigureCacheTlsClientHello();
+CreateHostBuilder(args).Build().Run();
+
+static IHostBuilder CreateHostBuilder(string[] args) =>
+    Host.CreateDefaultBuilder(args)
+        .ConfigureWebHost(webBuilder =>
+        {
+            webBuilder.UseStartup<Startup>()
+            .UseHttpSys(options =>
+            {
+                // If you want to use https locally: https://stackoverflow.com/a/51841893
+                options.UrlPrefixes.Add("https://*:6000"); // HTTPS
+
+                options.Authentication.Schemes = AuthenticationSchemes.None;
+                options.Authentication.AllowAnonymous = true;
+
+                var property = typeof(HttpSysOptions).GetProperty("TlsClientHelloBytesCallback", BindingFlags.NonPublic | BindingFlags.Instance);
+                var delegateType = property.PropertyType; // Get the exact delegate type
+
+                // Create a delegate of the correct type
+                var callbackDelegate = Delegate.CreateDelegate(delegateType, typeof(Holder).GetMethod(nameof(Holder.ProcessTlsClientHello), BindingFlags.Static | BindingFlags.Public));
+
+                property?.SetValue(options, callbackDelegate);
+            });
+        });
+
+public static class Holder
+{
+    public static void ProcessTlsClientHello(IFeatureCollection features, ReadOnlySpan<byte> tlsClientHelloBytes)
+    {
+        var httpConnectionFeature = features.Get<IHttpConnectionFeature>();
+
+        var myTlsFeature = new MyTlsFeature(
+            connectionId: httpConnectionFeature.ConnectionId,
+            tlsClientHelloLength: tlsClientHelloBytes.Length);
+
+        features.Set<IMyTlsFeature>(myTlsFeature);
+    }
+}
+
+public interface IMyTlsFeature
+{
+    string ConnectionId { get; }
+    int TlsClientHelloLength { get; }
+}
+
+public class MyTlsFeature : IMyTlsFeature
+{
+    public string ConnectionId { get; }
+    public int TlsClientHelloLength { get; }
+
+    public MyTlsFeature(string connectionId, int tlsClientHelloLength)
+    {
+        ConnectionId = connectionId;
+        TlsClientHelloLength = tlsClientHelloLength;
+    }
+}

+ 10 - 0
src/Servers/HttpSys/samples/TlsFeaturesObserve/Properties/launchSettings.json

@@ -0,0 +1,10 @@
+{
+  "profiles": {
+    "TlsFeaturesObserve": {
+      "commandName": "Project",
+      "launchBrowser": true,
+      "applicationUrl": "http://localhost:5000",
+      "nativeDebugging": true
+    }
+  }
+}

+ 28 - 0
src/Servers/HttpSys/samples/TlsFeaturesObserve/Startup.cs

@@ -0,0 +1,28 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Connections.Features;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Features;
+using Microsoft.AspNetCore.Server.HttpSys;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace TlsFeatureObserve;
+
+public class Startup
+{
+    public void Configure(IApplicationBuilder app)
+    {
+        app.Run(async (HttpContext context) =>
+        {
+            context.Response.ContentType = "text/plain";
+
+            var tlsFeature = context.Features.Get<IMyTlsFeature>();
+            await context.Response.WriteAsync("TlsClientHello data: " + $"connectionId={tlsFeature?.ConnectionId}; length={tlsFeature?.TlsClientHelloLength}");
+        });
+    }
+}

+ 14 - 0
src/Servers/HttpSys/samples/TlsFeaturesObserve/TlsFeaturesObserve.csproj

@@ -0,0 +1,14 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
+    <OutputType>Exe</OutputType>
+    <ServerGarbageCollection>true</ServerGarbageCollection>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <Reference Include="Microsoft.AspNetCore.Server.HttpSys" />
+    <Reference Include="Microsoft.Extensions.Hosting" />
+    <Reference Include="Microsoft.Extensions.Logging.Console" />
+  </ItemGroup>
+</Project>

+ 12 - 16
src/Servers/HttpSys/src/HttpSysListener.cs

@@ -5,6 +5,7 @@ using System.Buffers;
 using System.Diagnostics;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.HttpSys.Internal;
+using Microsoft.AspNetCore.Server.HttpSys.RequestProcessing;
 using Microsoft.Extensions.Logging;
 
 namespace Microsoft.AspNetCore.Server.HttpSys;
@@ -37,6 +38,7 @@ internal sealed partial class HttpSysListener : IDisposable
     private readonly UrlGroup _urlGroup;
     private readonly RequestQueue _requestQueue;
     private readonly DisconnectListener _disconnectListener;
+    private readonly TlsListener? _tlsListener;
 
     private readonly object _internalLock;
 
@@ -69,12 +71,14 @@ internal sealed partial class HttpSysListener : IDisposable
         try
         {
             _serverSession = new ServerSession();
-
             _requestQueue = new RequestQueue(options.RequestQueueName, options.RequestQueueMode, Logger);
-
             _urlGroup = new UrlGroup(_serverSession, _requestQueue, Logger);
 
             _disconnectListener = new DisconnectListener(_requestQueue, Logger);
+            if (options.TlsClientHelloBytesCallback is not null)
+            {
+                _tlsListener = new TlsListener(Logger, options.TlsClientHelloBytesCallback);
+            }
         }
         catch (Exception exception)
         {
@@ -82,6 +86,7 @@ internal sealed partial class HttpSysListener : IDisposable
             _requestQueue?.Dispose();
             _urlGroup?.Dispose();
             _serverSession?.Dispose();
+            _tlsListener?.Dispose();
             Log.HttpSysListenerCtorError(Logger, exception);
             throw;
         }
@@ -96,20 +101,10 @@ internal sealed partial class HttpSysListener : IDisposable
 
     internal ILogger Logger { get; private set; }
 
-    internal UrlGroup UrlGroup
-    {
-        get { return _urlGroup; }
-    }
-
-    internal RequestQueue RequestQueue
-    {
-        get { return _requestQueue; }
-    }
-
-    internal DisconnectListener DisconnectListener
-    {
-        get { return _disconnectListener; }
-    }
+    internal UrlGroup UrlGroup => _urlGroup;
+    internal RequestQueue RequestQueue => _requestQueue;
+    internal TlsListener? TlsListener => _tlsListener;
+    internal DisconnectListener DisconnectListener => _disconnectListener;
 
     public HttpSysOptions Options { get; }
 
@@ -262,6 +257,7 @@ internal sealed partial class HttpSysListener : IDisposable
         Debug.Assert(!_serverSession.Id.IsInvalid, "ServerSessionHandle is invalid in CloseV2Config");
 
         _serverSession.Dispose();
+        _tlsListener?.Dispose();
     }
 
     /// <summary>

+ 14 - 1
src/Servers/HttpSys/src/HttpSysOptions.cs

@@ -242,10 +242,23 @@ public class HttpSysOptions
     /// Configures request headers to use <see cref="Encoding.Latin1"/> encoding.
     /// </summary>
     /// <remarks>
-    /// Defaults to `false`, in which case <see cref="Encoding.UTF8"/> will be used. />.
+    /// Defaults to <c>false</c>, in which case <see cref="Encoding.UTF8"/> will be used. />.
     /// </remarks>
     public bool UseLatin1RequestHeaders { get; set; }
 
+    /// <summary>
+    /// A callback to be invoked to get the TLS client hello bytes.
+    /// Null by default.
+    /// </summary>
+    /// <remarks>
+    /// Works only if <c>HTTP_SERVICE_CONFIG_SSL_FLAG_ENABLE_CACHE_CLIENT_HELLO</c> flag is set on http.sys service configuration.
+    /// See <see href="https://learn.microsoft.com/windows/win32/api/http/nf-http-httpsetserviceconfiguration"/>
+    /// and <see href="https://learn.microsoft.com/windows/win32/api/http/ne-http-http_service_config_id"/>
+    /// </remarks>
+    internal TlsClientHelloCallback? TlsClientHelloBytesCallback { get; set; }
+
+    internal delegate void TlsClientHelloCallback(IFeatureCollection features, ReadOnlySpan<byte> clientHelloBytes);
+
     // Not called when attaching to an existing queue.
     internal void Apply(UrlGroup urlGroup, RequestQueue? requestQueue)
     {

+ 1 - 0
src/Servers/HttpSys/src/LoggerEventIds.cs

@@ -59,4 +59,5 @@ internal static class LoggerEventIds
     public const int AcceptCancelExpectationMismatch = 52;
     public const int AcceptObserveExpectationMismatch = 53;
     public const int RequestParsingError = 54;
+    public const int TlsListenerError = 55;
 }

+ 25 - 7
src/Servers/HttpSys/src/NativeInterop/HttpApi.cs

@@ -122,13 +122,30 @@ internal static unsafe partial class HttpApi
     }
 
     internal static SafeLibraryHandle? HttpApiModule { get; private set; }
-    internal static HttpGetRequestPropertyInvoker? HttpGetRequestProperty { get; private set; }
-    internal static HttpSetRequestPropertyInvoker? HttpSetRequestProperty { get; private set; }
-    [MemberNotNullWhen(true, nameof(HttpSetRequestProperty))]
+    private static HttpGetRequestPropertyInvoker? HttpGetRequestInvoker { get; set; }
+    private static HttpSetRequestPropertyInvoker? HttpSetRequestInvoker { get; set; }
+
+    internal static bool HttpGetRequestPropertySupported => HttpGetRequestInvoker is not null;
+    internal static bool HttpSetRequestPropertySupported => HttpSetRequestInvoker is not null;
+
+    internal static unsafe uint HttpGetRequestProperty(SafeHandle requestQueueHandle, ulong requestId, HTTP_REQUEST_PROPERTY propertyId,
+        void* qualifier, uint qualifierSize, void* output, uint outputSize, uint* bytesReturned, IntPtr overlapped)
+    {
+        return HttpGetRequestInvoker!(requestQueueHandle, requestId, propertyId, qualifier, qualifierSize, output, outputSize, bytesReturned, overlapped);
+    }
+
+    internal static unsafe uint HttpSetRequestProperty(SafeHandle requestQueueHandle, ulong requestId, HTTP_REQUEST_PROPERTY propertyId,
+        void* input, uint inputSize, IntPtr overlapped)
+    {
+        return HttpSetRequestInvoker!(requestQueueHandle, requestId, propertyId, input, inputSize, overlapped);
+    }
+
+    [MemberNotNullWhen(true, nameof(HttpSetRequestInvoker))]
     internal static bool SupportsTrailers { get; private set; }
-    [MemberNotNullWhen(true, nameof(HttpSetRequestProperty))]
+    [MemberNotNullWhen(true, nameof(HttpSetRequestInvoker))]
     internal static bool SupportsReset { get; private set; }
     internal static bool SupportsDelegation { get; private set; }
+    internal static bool SupportsClientHello { get; private set; }
 
     static HttpApi()
     {
@@ -147,11 +164,12 @@ internal static unsafe partial class HttpApi
         if (supported)
         {
             HttpApiModule = SafeLibraryHandle.Open(HTTPAPI);
-            HttpGetRequestProperty = HttpApiModule.GetProcAddress<HttpGetRequestPropertyInvoker>("HttpQueryRequestProperty", throwIfNotFound: false);
-            HttpSetRequestProperty = HttpApiModule.GetProcAddress<HttpSetRequestPropertyInvoker>("HttpSetRequestProperty", throwIfNotFound: false);
-            SupportsReset = HttpSetRequestProperty != null;
+            HttpGetRequestInvoker = HttpApiModule.GetProcAddress<HttpGetRequestPropertyInvoker>("HttpQueryRequestProperty", throwIfNotFound: false);
+            HttpSetRequestInvoker = HttpApiModule.GetProcAddress<HttpSetRequestPropertyInvoker>("HttpSetRequestProperty", throwIfNotFound: false);
+            SupportsReset = HttpSetRequestPropertySupported;
             SupportsTrailers = IsFeatureSupported(HTTP_FEATURE_ID.HttpFeatureResponseTrailers);
             SupportsDelegation = IsFeatureSupported(HTTP_FEATURE_ID.HttpFeatureDelegateEx);
+            SupportsClientHello = IsFeatureSupported((HTTP_FEATURE_ID)11 /* HTTP_FEATURE_ID.HttpFeatureCacheTlsClientHello */) && HttpGetRequestPropertySupported;
         }
     }
 

+ 5 - 0
src/Servers/HttpSys/src/RequestProcessing/Request.cs

@@ -9,10 +9,12 @@ using System.Security.Cryptography;
 using System.Security.Cryptography.X509Certificates;
 using System.Security.Principal;
 using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Features;
 using Microsoft.AspNetCore.HttpSys.Internal;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Primitives;
 using Microsoft.Net.Http.Headers;
+using static Microsoft.AspNetCore.Server.HttpSys.HttpSysOptions;
 
 namespace Microsoft.AspNetCore.Server.HttpSys;
 
@@ -362,6 +364,9 @@ internal sealed partial class Request
         SniHostName = sni.Hostname;
     }
 
+    internal bool GetAndInvokeTlsClientHelloCallback(IFeatureCollection features, TlsClientHelloCallback tlsClientHelloBytesCallback)
+        => RequestContext.GetAndInvokeTlsClientHelloMessageBytesCallback(features, tlsClientHelloBytesCallback);
+
     public X509Certificate2? ClientCertificate
     {
         get

+ 3 - 0
src/Servers/HttpSys/src/RequestProcessing/RequestContext.Log.cs

@@ -20,5 +20,8 @@ internal partial class RequestContext
 
         [LoggerMessage(LoggerEventIds.RequestParsingError, LogLevel.Debug, "Failed to parse request.", EventName = "RequestParsingError")]
         public static partial void RequestParsingError(ILogger logger, Exception exception);
+
+        [LoggerMessage(LoggerEventIds.RequestParsingError, LogLevel.Debug, "Failed to invoke QueryTlsClientHello; RequestId: {RequestId}; Win32 Error code: {Win32Error}", EventName = "TlsClientHelloRetrieveError")]
+        public static partial void TlsClientHelloRetrieveError(ILogger logger, ulong requestId, uint win32Error);
     }
 }

+ 106 - 13
src/Servers/HttpSys/src/RequestProcessing/RequestContext.cs

@@ -1,13 +1,18 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
+using System.Buffers;
 using System.Diagnostics;
 using System.Runtime.InteropServices;
 using System.Security.Authentication.ExtendedProtection;
 using System.Security.Principal;
 using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Features;
 using Microsoft.AspNetCore.HttpSys.Internal;
 using Microsoft.Extensions.Logging;
+using static Microsoft.AspNetCore.HttpSys.Internal.HttpApiTypes;
+using static Microsoft.AspNetCore.HttpSys.Internal.UnsafeNclNativeMethods;
+using static Microsoft.AspNetCore.Server.HttpSys.HttpSysOptions;
 
 namespace Microsoft.AspNetCore.Server.HttpSys;
 
@@ -234,30 +239,118 @@ internal partial class RequestContext : NativeRequestContext, IThreadPoolWorkIte
         }
     }
 
-    internal unsafe HttpApiTypes.HTTP_REQUEST_PROPERTY_SNI GetClientSni()
+    /// <summary>
+    /// Attempts to get the client hello message bytes from HTTP.sys and calls the user provided callback.
+    /// If not successful, will return false.
+    /// </summary>
+    internal unsafe bool GetAndInvokeTlsClientHelloMessageBytesCallback(IFeatureCollection features, TlsClientHelloCallback tlsClientHelloBytesCallback)
     {
-        if (HttpApi.HttpGetRequestProperty != null)
+        if (!HttpApi.SupportsClientHello)
+        {
+            // not supported, so we just return and don't invoke the callback
+            return false;
+        }
+
+        uint bytesReturnedValue = 0;
+        uint* bytesReturned = &bytesReturnedValue;
+        uint statusCode;
+
+        var requestId = PinsReleased ? Request.RequestId : RequestId;
+
+        // we will try with some "random" buffer size
+        var buffer = ArrayPool<byte>.Shared.Rent(512);
+        try
         {
-            var buffer = new byte[HttpApiTypes.SniPropertySizeInBytes];
             fixed (byte* pBuffer = buffer)
             {
-                var statusCode = HttpApi.HttpGetRequestProperty(
-                    Server.RequestQueue.Handle,
-                    RequestId,
-                    HttpApiTypes.HTTP_REQUEST_PROPERTY.HttpRequestPropertySni,
+                statusCode = HttpApi.HttpGetRequestProperty(
+                    requestQueueHandle: Server.RequestQueue.Handle,
+                    requestId,
+                    propertyId: (HTTP_REQUEST_PROPERTY)11 /* HTTP_REQUEST_PROPERTY.HttpRequestPropertyTlsClientHello  */,
                     qualifier: null,
                     qualifierSize: 0,
-                    (void*)pBuffer,
-                    (uint)buffer.Length,
-                    bytesReturned: null,
-                    IntPtr.Zero);
+                    output: pBuffer,
+                    outputSize: (uint)buffer.Length,
+                    bytesReturned: bytesReturned,
+                    overlapped: IntPtr.Zero);
 
-                if (statusCode == UnsafeNclNativeMethods.ErrorCodes.ERROR_SUCCESS)
+                if (statusCode is ErrorCodes.ERROR_SUCCESS)
                 {
-                    return Marshal.PtrToStructure<HttpApiTypes.HTTP_REQUEST_PROPERTY_SNI>((IntPtr)pBuffer);
+                    tlsClientHelloBytesCallback(features, buffer.AsSpan(0, (int)bytesReturnedValue));
+                    return true;
                 }
             }
         }
+        finally
+        {
+            ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
+        }
+
+        // if buffer supplied is too small, `bytesReturned` will have proper size
+        // so retry should succeed with the properly allocated buffer
+        if (statusCode is ErrorCodes.ERROR_MORE_DATA or ErrorCodes.ERROR_INSUFFICIENT_BUFFER)
+        {
+            try
+            {
+                var correctSize = (int)bytesReturnedValue;
+                buffer = ArrayPool<byte>.Shared.Rent(correctSize);
+
+                fixed (byte* pBuffer = buffer)
+                {
+                    statusCode = HttpApi.HttpGetRequestProperty(
+                        requestQueueHandle: Server.RequestQueue.Handle,
+                        requestId,
+                        propertyId: (HTTP_REQUEST_PROPERTY)11 /* HTTP_REQUEST_PROPERTY.HttpRequestPropertyTlsClientHello  */,
+                        qualifier: null,
+                        qualifierSize: 0,
+                        output: pBuffer,
+                        outputSize: (uint)buffer.Length,
+                        bytesReturned: bytesReturned,
+                        overlapped: IntPtr.Zero);
+
+                    if (statusCode is ErrorCodes.ERROR_SUCCESS)
+                    {
+                        tlsClientHelloBytesCallback(features, buffer.AsSpan(0, correctSize));
+                        return true;
+                    }
+                }
+            }
+            finally
+            {
+                ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
+            }
+        }
+
+        Log.TlsClientHelloRetrieveError(Logger, requestId, statusCode);
+        return false;
+    }
+
+    internal unsafe HTTP_REQUEST_PROPERTY_SNI GetClientSni()
+    {
+        if (!HttpApi.HttpGetRequestPropertySupported)
+        {
+            return default;
+        }
+
+        var buffer = new byte[HttpApiTypes.SniPropertySizeInBytes];
+        fixed (byte* pBuffer = buffer)
+        {
+            var statusCode = HttpApi.HttpGetRequestProperty(
+                Server.RequestQueue.Handle,
+                RequestId,
+                HTTP_REQUEST_PROPERTY.HttpRequestPropertySni,
+                qualifier: null,
+                qualifierSize: 0,
+                pBuffer,
+                (uint)buffer.Length,
+                bytesReturned: null,
+                IntPtr.Zero);
+
+            if (statusCode == ErrorCodes.ERROR_SUCCESS)
+            {
+                return Marshal.PtrToStructure<HTTP_REQUEST_PROPERTY_SNI>((IntPtr)pBuffer);
+            }
+        }
 
         return default;
     }

+ 6 - 0
src/Servers/HttpSys/src/RequestProcessing/RequestContextOfT.cs

@@ -48,6 +48,12 @@ internal sealed partial class RequestContext<TContext> : RequestContext where TC
                 context = application.CreateContext(Features);
                 try
                 {
+                    if (Server.Options.TlsClientHelloBytesCallback is not null && Server.TlsListener is not null
+                        && Request.IsHttps)
+                    {
+                        Server.TlsListener.InvokeTlsClientHelloCallback(Request.RawConnectionId, Features, Request.GetAndInvokeTlsClientHelloCallback);
+                    }
+
                     await application.ProcessRequestAsync(context);
                     await CompleteAsync();
                 }

+ 15 - 0
src/Servers/HttpSys/src/RequestProcessing/TlsListener.Log.cs

@@ -0,0 +1,15 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.AspNetCore.Server.HttpSys.RequestProcessing;
+
+internal sealed partial class TlsListener : IDisposable
+{
+    private static partial class Log
+    {
+        [LoggerMessage(LoggerEventIds.TlsListenerError, LogLevel.Error, "Error during closed connection cleanup.", EventName = "TlsListenerCleanupClosedConnectionError")]
+        public static partial void CleanupClosedConnectionError(ILogger logger, Exception exception);
+    }
+}

+ 144 - 0
src/Servers/HttpSys/src/RequestProcessing/TlsListener.cs

@@ -0,0 +1,144 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Concurrent;
+using System.Collections.ObjectModel;
+using Microsoft.AspNetCore.Http.Features;
+using Microsoft.Extensions.Logging;
+using static Microsoft.AspNetCore.Server.HttpSys.HttpSysOptions;
+
+namespace Microsoft.AspNetCore.Server.HttpSys.RequestProcessing;
+
+internal sealed partial class TlsListener : IDisposable
+{
+    private readonly ConcurrentDictionary<ulong, DateTimeOffset> _connectionTimestamps = new();
+    private readonly TlsClientHelloCallback _tlsClientHelloBytesCallback;
+    private readonly ILogger _logger;
+
+    private readonly PeriodicTimer _cleanupTimer;
+    private readonly Task _cleanupTask;
+    private readonly TimeProvider _timeProvider;
+
+    private readonly TimeSpan ConnectionIdleTime = TimeSpan.FromMinutes(5);
+    private readonly TimeSpan CleanupDelay = TimeSpan.FromSeconds(10);
+    internal readonly int CacheSizeLimit = 1_000_000;
+
+    // Internal for testing purposes
+    internal ReadOnlyDictionary<ulong, DateTimeOffset> ConnectionTimeStamps => _connectionTimestamps.AsReadOnly();
+
+    internal TlsListener(ILogger logger, TlsClientHelloCallback tlsClientHelloBytesCallback, TimeProvider? timeProvider = null)
+    {
+        if (AppContext.GetData("Microsoft.AspNetCore.Server.HttpSys.TlsListener.CacheSizeLimit") is int limit)
+        {
+            CacheSizeLimit = limit;
+        }
+
+        if (AppContext.GetData("Microsoft.AspNetCore.Server.HttpSys.TlsListener.ConnectionIdleTime") is int idleTime)
+        {
+            ConnectionIdleTime = TimeSpan.FromSeconds(idleTime);
+        }
+
+        if (AppContext.GetData("Microsoft.AspNetCore.Server.HttpSys.TlsListener.CleanupDelay") is int cleanupDelay)
+        {
+            CleanupDelay = TimeSpan.FromSeconds(cleanupDelay);
+        }
+
+        _logger = logger;
+        _tlsClientHelloBytesCallback = tlsClientHelloBytesCallback;
+
+        _timeProvider = timeProvider ?? TimeProvider.System;
+        _cleanupTimer = new PeriodicTimer(CleanupDelay, _timeProvider);
+        _cleanupTask = CleanupLoopAsync();
+    }
+
+    // Method looks weird because we want it to be testable by not directly requiring a Request object
+    internal void InvokeTlsClientHelloCallback(ulong connectionId, IFeatureCollection features,
+        Func<IFeatureCollection, TlsClientHelloCallback, bool> invokeTlsClientHelloCallback)
+    {
+        if (!_connectionTimestamps.TryAdd(connectionId, _timeProvider.GetUtcNow()))
+        {
+            // update TTL
+            _connectionTimestamps[connectionId] = _timeProvider.GetUtcNow();
+            return;
+        }
+
+        _ = invokeTlsClientHelloCallback(features, _tlsClientHelloBytesCallback);
+    }
+
+    internal async Task CleanupLoopAsync()
+    {
+        while (await _cleanupTimer.WaitForNextTickAsync())
+        {
+            try
+            {
+                var now = _timeProvider.GetUtcNow();
+
+                // Remove idle connections
+                foreach (var kvp in _connectionTimestamps)
+                {
+                    if (now - kvp.Value >= ConnectionIdleTime)
+                    {
+                        _connectionTimestamps.TryRemove(kvp.Key, out _);
+                    }
+                }
+
+                // Evict oldest items if above CacheSizeLimit
+                var currentCount = _connectionTimestamps.Count;
+                if (currentCount > CacheSizeLimit)
+                {
+                    var excessCount = currentCount - CacheSizeLimit;
+
+                    // Find the oldest items in a single pass
+                    var oldestTimestamps = new SortedSet<KeyValuePair<ulong, DateTimeOffset>>(TimeComparer.Instance);
+
+                    foreach (var kvp in _connectionTimestamps)
+                    {
+                        if (oldestTimestamps.Count < excessCount)
+                        {
+                            oldestTimestamps.Add(new KeyValuePair<ulong, DateTimeOffset>(kvp.Key, kvp.Value));
+                        }
+                        else if (kvp.Value < oldestTimestamps.Max.Value)
+                        {
+                            oldestTimestamps.Remove(oldestTimestamps.Max);
+                            oldestTimestamps.Add(new KeyValuePair<ulong, DateTimeOffset>(kvp.Key, kvp.Value));
+                        }
+                    }
+
+                    // Remove the oldest keys
+                    foreach (var item in oldestTimestamps)
+                    {
+                        _connectionTimestamps.TryRemove(item.Key, out _);
+                    }
+                }
+            }
+            catch (Exception ex)
+            {
+                Log.CleanupClosedConnectionError(_logger, ex);
+            }
+        }
+    }
+
+    public void Dispose()
+    {
+        _cleanupTimer.Dispose();
+        _cleanupTask.Wait();
+    }
+
+    private sealed class TimeComparer : IComparer<KeyValuePair<ulong, DateTimeOffset>>
+    {
+        public static TimeComparer Instance { get; } = new TimeComparer();
+
+        public int Compare(KeyValuePair<ulong, DateTimeOffset> x, KeyValuePair<ulong, DateTimeOffset> y)
+        {
+            // Compare timestamps first
+            int timestampComparison = x.Value.CompareTo(y.Value);
+            if (timestampComparison != 0)
+            {
+                return timestampComparison;
+            }
+
+            // Use the key as a tiebreaker to ensure uniqueness
+            return x.Key.CompareTo(y.Key);
+        }
+    }
+}

+ 1 - 0
src/Servers/HttpSys/test/FunctionalTests/Microsoft.AspNetCore.Server.HttpSys.FunctionalTests.csproj

@@ -32,6 +32,7 @@
     <Reference Include="Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets" />
     <Reference Include="Microsoft.Extensions.Hosting" />
     <Reference Include="System.Net.Http.WinHttpHandler" />
+    <Reference Include="Microsoft.Extensions.TimeProvider.Testing" />
   </ItemGroup>
 
   <PropertyGroup>

+ 141 - 0
src/Servers/HttpSys/test/FunctionalTests/TlsListenerTests.cs

@@ -0,0 +1,141 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Http.Features;
+using Microsoft.AspNetCore.Server.HttpSys.RequestProcessing;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Time.Testing;
+using Moq;
+using static Microsoft.AspNetCore.Server.HttpSys.HttpSysOptions;
+
+namespace Microsoft.AspNetCore.Server.HttpSys.FunctionalTests;
+
+public class TlsListenerTests
+{
+    [Fact]
+    public void AddsAndUpdatesConnectionTimestamps()
+    {
+        // Arrange
+        var logger = Mock.Of<ILogger>();
+        var timeProvider = new FakeTimeProvider();
+        var callbackInvoked = false;
+        var tlsListener = new TlsListener(logger, (_, __) => { callbackInvoked = true; }, timeProvider);
+
+        var features = Mock.Of<IFeatureCollection>();
+
+        // Act
+        tlsListener.InvokeTlsClientHelloCallback(connectionId: 1UL, features,
+            invokeTlsClientHelloCallback: (f, cb) => { cb(f, ReadOnlySpan<byte>.Empty); return true; });
+
+        var originalTime = timeProvider.GetUtcNow();
+
+        // Assert
+        Assert.True(callbackInvoked);
+        Assert.Equal(originalTime, Assert.Single(tlsListener.ConnectionTimeStamps).Value);
+
+        timeProvider.Advance(TimeSpan.FromSeconds(1));
+        callbackInvoked = false;
+        // Update the timestamp
+        tlsListener.InvokeTlsClientHelloCallback(connectionId: 1UL, features,
+            invokeTlsClientHelloCallback: (f, cb) => { cb(f, ReadOnlySpan<byte>.Empty); return true; });
+
+        // Callback should not be invoked again and the timestamp should be updated
+        Assert.False(callbackInvoked);
+        Assert.Equal(timeProvider.GetUtcNow(), Assert.Single(tlsListener.ConnectionTimeStamps).Value);
+        Assert.NotEqual(originalTime, timeProvider.GetUtcNow());
+    }
+
+    [Fact]
+    public async Task RemovesIdleConnections()
+    {
+        // Arrange
+        var logger = Mock.Of<ILogger>();
+        var timeProvider = new FakeTimeProvider();
+        using var tlsListener = new TlsListener(logger, (_, __) => { }, timeProvider);
+
+        var features = Mock.Of<IFeatureCollection>();
+
+        bool InvokeCallback(IFeatureCollection f, TlsClientHelloCallback cb)
+        {
+            cb(f, ReadOnlySpan<byte>.Empty);
+            return true;
+        }
+
+        // Act
+        tlsListener.InvokeTlsClientHelloCallback(connectionId: 1UL, features, InvokeCallback);
+
+        // 1 less minute than the idle time cleanup
+        timeProvider.Advance(TimeSpan.FromMinutes(4));
+        Assert.Single(tlsListener.ConnectionTimeStamps);
+
+        tlsListener.InvokeTlsClientHelloCallback(connectionId: 2UL, features, InvokeCallback);
+        Assert.Equal(2, tlsListener.ConnectionTimeStamps.Count);
+
+        // With the previous 4 minutes, this should be 5 minutes and remove the first connection
+        timeProvider.Advance(TimeSpan.FromMinutes(1));
+
+        var timeout = TimeSpan.FromSeconds(5);
+        while (timeout > TimeSpan.Zero)
+        {
+            // Wait for the cleanup loop to run
+            if (tlsListener.ConnectionTimeStamps.Count == 1)
+            {
+                break;
+            }
+            timeout -= TimeSpan.FromMilliseconds(100);
+            await Task.Delay(100);
+        }
+
+        // Assert
+        Assert.Single(tlsListener.ConnectionTimeStamps);
+        Assert.Contains(2UL, tlsListener.ConnectionTimeStamps.Keys);
+    }
+
+    [Fact]
+    public async Task EvictsOldestConnectionsWhenExceedingCacheSizeLimit()
+    {
+        // Arrange
+        var logger = Mock.Of<ILogger>();
+        var timeProvider = new FakeTimeProvider();
+        var tlsListener = new TlsListener(logger, (_, __) => { }, timeProvider);
+        var features = Mock.Of<IFeatureCollection>();
+
+        ulong i = 0;
+        for (; i < (ulong)tlsListener.CacheSizeLimit; i++)
+        {
+            tlsListener.InvokeTlsClientHelloCallback(i, features, (f, cb) => { cb(f, ReadOnlySpan<byte>.Empty); return true; });
+        }
+
+        timeProvider.Advance(TimeSpan.FromSeconds(5));
+
+        for (; i < (ulong)tlsListener.CacheSizeLimit + 3; i++)
+        {
+            tlsListener.InvokeTlsClientHelloCallback(i, features, (f, cb) => { cb(f, ReadOnlySpan<byte>.Empty); return true; });
+        }
+
+        // 'touch' first connection to update its timestamp
+        tlsListener.InvokeTlsClientHelloCallback(0, features, (f, cb) => { cb(f, ReadOnlySpan<byte>.Empty); return true; });
+
+        // Make sure the cleanup loop has run to evict items since we're above the cache size limit
+        timeProvider.Advance(TimeSpan.FromMinutes(1));
+
+        var timeout = TimeSpan.FromSeconds(5);
+        while (timeout > TimeSpan.Zero)
+        {
+            // Wait for the cleanup loop to run
+            if (tlsListener.ConnectionTimeStamps.Count == tlsListener.CacheSizeLimit)
+            {
+                break;
+            }
+            timeout -= TimeSpan.FromMilliseconds(100);
+            await Task.Delay(100);
+        }
+
+        Assert.Equal(tlsListener.CacheSizeLimit, tlsListener.ConnectionTimeStamps.Count);
+        Assert.Contains(0UL, tlsListener.ConnectionTimeStamps.Keys);
+        // 3 newest connections should be present
+        Assert.Contains(i - 1, tlsListener.ConnectionTimeStamps.Keys);
+        Assert.Contains(i - 2, tlsListener.ConnectionTimeStamps.Keys);
+        Assert.Contains(i - 3, tlsListener.ConnectionTimeStamps.Keys);
+    }
+}

+ 1 - 0
src/Shared/HttpSys/NativeInterop/UnsafeNativeMethods.cs

@@ -28,6 +28,7 @@ internal static unsafe partial class UnsafeNclNativeMethods
         internal const uint ERROR_HANDLE_EOF = 38;
         internal const uint ERROR_NOT_SUPPORTED = 50;
         internal const uint ERROR_INVALID_PARAMETER = 87;
+        internal const uint ERROR_INSUFFICIENT_BUFFER = 122;
         internal const uint ERROR_INVALID_NAME = 123;
         internal const uint ERROR_ALREADY_EXISTS = 183;
         internal const uint ERROR_MORE_DATA = 234;

+ 9 - 1
src/Shared/HttpSys/RequestProcessing/NativeRequestContext.cs

@@ -31,9 +31,11 @@ internal unsafe class NativeRequestContext : IDisposable
     private MemoryHandle _memoryHandle;
     private readonly int _bufferAlignment;
     private readonly bool _permanentlyPinned;
-    private bool _disposed;
     private IReadOnlyDictionary<int, ReadOnlyMemory<byte>>? _requestInfo;
 
+    private bool _disposed;
+    private bool _pinsReleased;
+
     [MemberNotNullWhen(false, nameof(_backingBuffer))]
     private bool PermanentlyPinned => _permanentlyPinned;
 
@@ -168,6 +170,11 @@ internal unsafe class NativeRequestContext : IDisposable
         }
     }
 
+    /// <summary>
+    /// Shows whether <see cref="ReleasePins"/> was already invoked on this native request context
+    /// </summary>
+    internal bool PinsReleased => _pinsReleased;
+
     // ReleasePins() should be called exactly once.  It must be called before Dispose() is called, which means it must be called
     // before an object (Request) which closes the RequestContext on demand is returned to the application.
     internal void ReleasePins()
@@ -177,6 +184,7 @@ internal unsafe class NativeRequestContext : IDisposable
         _memoryHandle.Dispose();
         _memoryHandle = default;
         _nativeRequest = null;
+        _pinsReleased = true;
     }
 
     public bool TryGetTimestamp(HttpSysRequestTimingType timestampType, out long timestamp)