Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
aed71f0
Certificate caching on WinHttpHandler to eliminate extra call to Cust…
liveans Jan 24, 2025
f08a569
Review feedback
liveans Feb 17, 2025
80ee04c
Merge branch 'main' into winhttp_servervalidationcallback_cache_certi…
liveans Feb 17, 2025
63de970
Review feedback
liveans Feb 17, 2025
00640e2
Framework compat + Review Feedback
liveans Feb 17, 2025
4ed25ff
Implement Timer to clear cache
liveans Feb 17, 2025
b15191d
Review feedback
liveans Feb 17, 2025
736944d
Review Feedback
liveans Feb 18, 2025
a8e05d0
Merge branch 'main' into winhttp_servervalidationcallback_cache_certi…
liveans Feb 18, 2025
548e6fd
Review feedback
liveans Feb 20, 2025
c365911
Fix RemoteExecutor issue and change delay to ms
liveans Feb 20, 2025
9e00762
Review feedback
liveans Feb 20, 2025
7f8540b
Apply suggestions from code review
liveans Feb 20, 2025
e0a9524
Merge branch 'main' into winhttp_servervalidationcallback_cache_certi…
liveans Feb 24, 2025
7d2bbbf
Merge branch 'main' into winhttp_servervalidationcallback_cache_certi…
liveans Mar 31, 2025
ae27025
Add ExecutionContext SuppressFlow
liveans Mar 31, 2025
61e7e88
Merge branch 'main' into winhttp_servervalidationcallback_cache_certi…
liveans Apr 4, 2025
adcce2b
Fix alignment issues
liveans Apr 7, 2025
2a6529f
Add offset docs for IPAddress parsing
liveans Apr 7, 2025
930d589
Merge branch 'winhttp_servervalidationcallback_cache_certificate_expe…
liveans Apr 7, 2025
3497e8f
Merge branch 'main' into winhttp_servervalidationcallback_cache_certi…
liveans Apr 7, 2025
12dfec6
Use RawDataMemory for lookup in Modern .NET
liveans Apr 8, 2025
23c2f94
Merge branch 'winhttp_servervalidationcallback_cache_certificate_expe…
liveans Apr 8, 2025
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 @@ -336,6 +336,14 @@ public struct WINHTTP_ASYNC_RESULT
public uint dwError;
}

[StructLayout(LayoutKind.Sequential)]
public unsafe struct WINHTTP_CONNECTION_INFO
{
public uint cbSize;
public uint __alignment;
public fixed byte LocalAddress[128];
public fixed byte RemoteAddress[128];
}

[StructLayout(LayoutKind.Sequential)]
public struct tcp_keepalive
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ System.Net.Http.WinHttpHandler</PackageDescription>
Link="Common\System\Runtime\ExceptionServices\ExceptionStackTrace.cs" />
<Compile Include="$(CommonPath)\System\Threading\Tasks\RendezvousAwaitable.cs"
Link="Common\System\Threading\Tasks\RendezvousAwaitable.cs" />
<Compile Include="System\Net\Http\CachedCertificateValue.cs" />
<Compile Include="System\Net\Http\NetEventSource.WinHttpHandler.cs" />
<Compile Include="System\Net\Http\NoWriteNoSeekStreamContent.cs" />
<Compile Include="System\Net\Http\WinHttpAuthHelper.cs" />
Expand Down
Original file line number Diff line number Diff line change
@@ -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 System;
using System.Collections.Generic;
using System.Text;

namespace System.Net.Http
{
internal sealed class CachedCertificateValue(byte[] rawCertificateData, long lastUsedTime)
{
public byte[] RawCertificateData { get; } = rawCertificateData;
public long LastUsedTime { get; set; } = lastUsedTime;
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
// 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.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Net.Http.Headers;
using System.Net.Security;
Expand Down Expand Up @@ -46,6 +48,8 @@ public class WinHttpHandler : HttpMessageHandler
private static readonly StringWithQualityHeaderValue s_gzipHeaderValue = new StringWithQualityHeaderValue("gzip");
private static readonly StringWithQualityHeaderValue s_deflateHeaderValue = new StringWithQualityHeaderValue("deflate");
private static readonly Lazy<bool> s_supportsTls13 = new Lazy<bool>(CheckTls13Support);
private static readonly TimeSpan s_cleanCachedCertificateTimeout = TimeSpan.FromMinutes(1);
private static readonly long s_staleTimeout = (long)s_cleanCachedCertificateTimeout.TotalSeconds * Stopwatch.Frequency;

[ThreadStatic]
private static StringBuilder? t_requestHeadersBuilder;
Expand Down Expand Up @@ -93,9 +97,27 @@ private Func<
private volatile int _disposed;
private SafeWinHttpHandle? _sessionHandle;
private readonly WinHttpAuthHelper _authHelper = new WinHttpAuthHelper();
private readonly Timer? _certificateCleanupTimer;
private bool _isTimerRunning;
private ConcurrentDictionary<IPAddress, CachedCertificateValue> _cachedCertificates = new();

public WinHttpHandler()
{
if (WinHttpRequestCallback.CertificateCachingAppContextSwitchEnabled)
{
WeakReference<WinHttpHandler> thisRef = new(this);
_certificateCleanupTimer = new Timer(
static s =>
{
if (((WeakReference<WinHttpHandler>)s!).TryGetTarget(out WinHttpHandler? thisRef))
{
thisRef.ClearStaleCertificates();
}
},
thisRef,
Timeout.Infinite,
Timeout.Infinite);
}
}

#region Properties
Expand Down Expand Up @@ -541,9 +563,10 @@ protected override void Dispose(bool disposing)
{
if (Interlocked.CompareExchange(ref _disposed, 1, 0) == 0)
{
if (disposing && _sessionHandle != null)
if (disposing)
{
_sessionHandle.Dispose();
_sessionHandle?.Dispose();
_certificateCleanupTimer?.Dispose();
}
}

Expand Down Expand Up @@ -1649,7 +1672,8 @@ private void SetStatusCallback(
Interop.WinHttp.WINHTTP_CALLBACK_FLAG_ALL_COMPLETIONS |
Interop.WinHttp.WINHTTP_CALLBACK_FLAG_HANDLES |
Interop.WinHttp.WINHTTP_CALLBACK_FLAG_REDIRECT |
Interop.WinHttp.WINHTTP_CALLBACK_FLAG_SEND_REQUEST;
Interop.WinHttp.WINHTTP_CALLBACK_FLAG_SEND_REQUEST |
Interop.WinHttp.WINHTTP_CALLBACK_STATUS_CONNECTED_TO_SERVER;

IntPtr oldCallback = Interop.WinHttp.WinHttpSetStatusCallback(
requestHandle,
Expand Down Expand Up @@ -1735,5 +1759,87 @@ private RendezvousAwaitable<int> InternalReceiveResponseHeadersAsync(WinHttpRequ

return state.LifecycleAwaitable;
}

internal bool GetCertificateFromCache(IPAddress ipAddress, [MaybeNullWhen(false)] out byte[] rawCertificateBytes)
{
if (_cachedCertificates.TryGetValue(ipAddress, out CachedCertificateValue? cachedValue))
{
cachedValue.LastUsedTime = Stopwatch.GetTimestamp();
rawCertificateBytes = cachedValue.RawCertificateData;
return true;
}

rawCertificateBytes = null;
return false;
}

internal bool TryAddCertificateToCache(IPAddress address, byte[] rawCertificateData)
{
bool result = _cachedCertificates.TryAdd(address, new CachedCertificateValue(rawCertificateData, Stopwatch.GetTimestamp()));
if (result)
{
EnsureCleanupTimerRunning();
}
return result;
}

internal bool TryRemoveCertificateFromCache(IPAddress address)
{
bool result = _cachedCertificates.TryRemove(address, out CachedCertificateValue? _);
if (result)
{
StopCleanupTimerIfEmpty();
}
return result;
}

private void ChangeCleanerTimer(TimeSpan timeout)
{
Debug.Assert(_certificateCleanupTimer != null);
if (_certificateCleanupTimer!.Change(timeout, Timeout.InfiniteTimeSpan))
{
lock (_lockObject)
{
_isTimerRunning = timeout != Timeout.InfiniteTimeSpan;
}
}
}

private void ClearStaleCertificates()
{
Debug.Assert(_certificateCleanupTimer != null);

foreach (KeyValuePair<IPAddress, CachedCertificateValue> kvPair in _cachedCertificates)
{
if (IsStale(kvPair.Value.LastUsedTime))
{
_cachedCertificates.TryRemove(kvPair.Key, out CachedCertificateValue? _);
}
}

ChangeCleanerTimer(_cachedCertificates.IsEmpty ? Timeout.InfiniteTimeSpan : s_cleanCachedCertificateTimeout);

static bool IsStale(long lastUsedTime)
{
long now = Stopwatch.GetTimestamp();
return (now - lastUsedTime) > s_staleTimeout;
}
}

private void EnsureCleanupTimerRunning()
{
if (!_cachedCertificates.IsEmpty && !_isTimerRunning)
{
ChangeCleanerTimer(s_cleanCachedCertificateTimeout);
}
}

private void StopCleanupTimerIfEmpty()
{
if (_cachedCertificates.IsEmpty && _isTimerRunning)
{
ChangeCleanerTimer(Timeout.InfiniteTimeSpan);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Buffers.Binary;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net.Security;
using System.Net.Sockets;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Security.Cryptography.X509Certificates;

using System.Threading;
using SafeWinHttpHandle = Interop.WinHttp.SafeWinHttpHandle;

namespace System.Net.Http
Expand All @@ -20,6 +24,8 @@ internal static class WinHttpRequestCallback
public static Interop.WinHttp.WINHTTP_STATUS_CALLBACK StaticCallbackDelegate =
new Interop.WinHttp.WINHTTP_STATUS_CALLBACK(WinHttpCallback);

public static bool CertificateCachingAppContextSwitchEnabled { get; } = AppContext.TryGetSwitch("System.Net.Http.UseWinHttpCertificateCaching", out bool enabled) && enabled;

public static void WinHttpCallback(
IntPtr handle,
IntPtr context,
Expand Down Expand Up @@ -56,6 +62,14 @@ private static void RequestCallback(
{
switch (internetStatus)
{
case Interop.WinHttp.WINHTTP_CALLBACK_STATUS_CONNECTED_TO_SERVER:
if (CertificateCachingAppContextSwitchEnabled)
{
IPAddress connectedToIPAddress = IPAddress.Parse(Marshal.PtrToStringUni(statusInformation)!);
OnRequestConnectedToServer(state, connectedToIPAddress);
}
return;

case Interop.WinHttp.WINHTTP_CALLBACK_STATUS_HANDLE_CLOSING:
OnRequestHandleClosing(state);
return;
Expand Down Expand Up @@ -121,6 +135,21 @@ private static void RequestCallback(
}
}

private static void OnRequestConnectedToServer(WinHttpRequestState state, IPAddress connectedIPAddress)
{
Debug.Assert(state != null);
Debug.Assert(state.Handler != null);

if (state.Handler.TryRemoveCertificateFromCache(connectedIPAddress))
{
if (NetEventSource.Log.IsEnabled()) NetEventSource.Info(null, $"Removed cached certificate for {connectedIPAddress}");
}
else
{
if (NetEventSource.Log.IsEnabled()) NetEventSource.Info(null, $"No cached certificate for {connectedIPAddress} to remove");
}
}

private static void OnRequestHandleClosing(WinHttpRequestState state)
{
Debug.Assert(state != null, "OnRequestSendRequestComplete: state is null");
Expand Down Expand Up @@ -231,6 +260,7 @@ private static void OnRequestRedirect(WinHttpRequestState state, Uri redirectUri
private static void OnRequestSendingRequest(WinHttpRequestState state)
{
Debug.Assert(state != null, "OnRequestSendingRequest: state is null");
Debug.Assert(state.Handler != null, "OnRequestSendingRequest: state.Handler is null");
Debug.Assert(state.RequestMessage != null, "OnRequestSendingRequest: state.RequestMessage is null");
Debug.Assert(state.RequestMessage.RequestUri != null, "OnRequestSendingRequest: state.RequestMessage.RequestUri is null");

Expand Down Expand Up @@ -279,6 +309,53 @@ private static void OnRequestSendingRequest(WinHttpRequestState state)
var serverCertificate = new X509Certificate2(certHandle);
Interop.Crypt32.CertFreeCertificateContext(certHandle);

IPAddress? ipAddress = null;
if (CertificateCachingAppContextSwitchEnabled)
{
unsafe
{
Interop.WinHttp.WINHTTP_CONNECTION_INFO connectionInfo;
Interop.WinHttp.WINHTTP_CONNECTION_INFO* pConnectionInfo = &connectionInfo;
uint infoSize = (uint)sizeof(Interop.WinHttp.WINHTTP_CONNECTION_INFO);
if (NetEventSource.Log.IsEnabled()) NetEventSource.Info(state, $"sizeof(WINHTTP_CONNECTION_INFO)={infoSize}");
if (Interop.WinHttp.WinHttpQueryOption(
state.RequestHandle,
// This option is available on Windows XP SP2 and later; Windows 2003 with SP1 and later.
Interop.WinHttp.WINHTTP_OPTION_CONNECTION_INFO,
(IntPtr)pConnectionInfo,
ref infoSize))
{
ReadOnlySpan<byte> remoteAddressSpan = new ReadOnlySpan<byte>(connectionInfo.RemoteAddress, 128);
AddressFamily addressFamily = (AddressFamily)(remoteAddressSpan[0] + (remoteAddressSpan[1] << 8));
ipAddress = addressFamily switch
{
AddressFamily.InterNetwork => new IPAddress(BinaryPrimitives.ReadUInt32LittleEndian(remoteAddressSpan.Slice(4))),
AddressFamily.InterNetworkV6 => new IPAddress(remoteAddressSpan.Slice(8, 16).ToArray()),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit.
I think we should also try to get ScopeID for IPv6 to be 100% correct. LLA are rare but possible.

_ => null
};
Debug.Assert(ipAddress != null, "AddressFamily is not supported");
if (NetEventSource.Log.IsEnabled()) NetEventSource.Info(state, $"ipAddress: {ipAddress}");

}
else
{
int lastError = Marshal.GetLastWin32Error();
if (NetEventSource.Log.IsEnabled()) NetEventSource.Error(state, $"Error getting WINHTTP_OPTION_CONNECTION_INFO, {lastError}");
}
}

if (ipAddress is not null && state.Handler.GetCertificateFromCache(ipAddress, out byte[]? rawCertData) && rawCertData.SequenceEqual(serverCertificate.RawData))
{
if (NetEventSource.Log.IsEnabled()) NetEventSource.Info(state, $"Skipping certificate validation. ipAddress: {ipAddress}, Thumbprint: {serverCertificate.Thumbprint}");
serverCertificate.Dispose();
return;
}
else
{
if (NetEventSource.Log.IsEnabled()) NetEventSource.Info(state, $"Certificate validation is required! IPAddress = {ipAddress}, Thumbprint: {serverCertificate.Thumbprint}");
}
}

X509Chain? chain = null;
SslPolicyErrors sslPolicyErrors;
bool result = false;
Expand All @@ -298,6 +375,10 @@ private static void OnRequestSendingRequest(WinHttpRequestState state)
serverCertificate,
chain,
sslPolicyErrors);
if (CertificateCachingAppContextSwitchEnabled && result && ipAddress is not null)
{
_ = state.Handler.TryAddCertificateToCache(ipAddress, serverCertificate.RawData);
}
}
catch (Exception ex)
{
Expand Down
Loading
Loading