-
Notifications
You must be signed in to change notification settings - Fork 4.8k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[WinHTTP] Certificate caching on WinHttpHandler to eliminate extra call to Custom Certificate Validation #111791
base: main
Are you sure you want to change the base?
Conversation
…om Certificate Validation
Tagging subscribers to this area: @dotnet/ncl |
/azp run runtime-libraries-coreclr outerloop-windows |
Azure Pipelines successfully started running 1 pipeline(s). |
@@ -20,6 +23,8 @@ internal static class WinHttpRequestCallback | |||
public static Interop.WinHttp.WINHTTP_STATUS_CALLBACK StaticCallbackDelegate = | |||
new Interop.WinHttp.WINHTTP_STATUS_CALLBACK(WinHttpCallback); | |||
|
|||
private static ConcurrentDictionary<IPAddress, string> s_cachedCertificates = new(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should be specific to each WinHttpHandler
instance as cert validation callbacks may be different (either an instance collection, or the handler being included as the key).
We should also be clearing old entries from this to avoid a memory leak.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NIT: Also consider to invalidate instance level cache if validation callback is changed. I know it is not recommended to change handler after it has been wired to HttpClient but if it is possible we might consider to handle it.
} | ||
} | ||
|
||
if (ipAddress is not null && s_cachedCertificates.TryGetValue(ipAddress, out string? thumbprint) && thumbprint == serverCertificate.Thumbprint) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it possible to obtain the thumbprint from the certHandle
without allocating the cert?
If so, this could be moved up to avoid some more overhead.
Otherwise, this is also fine assuming that building the chain is the bulk of wasted compute.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
and thumbprint use obsolete SHA-1. We should avoid it IMHO for new code. We may try to hash the whole cert content e.g. raw bytes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suppose we shouldn't need hashing here at all. We have the certs, we can just always do a SequenceEqual
on the raw bytes.
ref infoSize)) | ||
{ | ||
ReadOnlySpan<byte> RemoteAddressSpan = new ReadOnlySpan<byte>(connectionInfo.RemoteAddress, 128); | ||
AddressFamily addressFamily = (AddressFamily)BitConverter.ToInt16(RemoteAddressSpan.ToArray(), 0); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AddressFamily addressFamily = (AddressFamily)BitConverter.ToInt16(RemoteAddressSpan.ToArray(), 0); | |
AddressFamily addressFamily = (AddressFamily)BitConverter.ToInt16(RemoteAddressSpan); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh I guess this is due to Framework? :/
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In that case we can at least do .Slice(0, 2).ToArray()
instead to avoid allocating the full 128 bytes
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can it be just Unsafe.ReadUnaligned<short>(ref MemoryMarshal.GetReference(remoteAddressSpan))
? (Assuming we don't need to also check endianness here.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not to use simple bit manipulation: RemoteAddressSpan[0] + (RemoteAddressSpan[0]<<8)
normally I would recommend BinaryPrimitives.ReadInt16LittleEndian
but I not sure we can afford package dependency here for framework.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The important thing is to avoid unnecessary allocations, it doesn't really matter how. (The code I quoted is BinaryPrimitives.ReadInt16LittleEndian
's implementation.)
(IntPtr)pConnectionInfo, | ||
ref infoSize)) | ||
{ | ||
ReadOnlySpan<byte> RemoteAddressSpan = new ReadOnlySpan<byte>(connectionInfo.RemoteAddress, 128); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Local variables should be lower case: remoteAddressSpan
AddressFamily.InterNetwork => new IPAddress(BinaryPrimitives.ReadUInt32LittleEndian(RemoteAddressSpan.Slice(4))), | ||
AddressFamily.InterNetworkV6 => new IPAddress(RemoteAddressSpan.Slice(8, 16).ToArray()), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since this is running per-request, can we try to avoid the IPAddress
allocations (eg. by using a custom struct as a dictionary key), or if we think they are insignificant, run some benchmarks to prove it?
ref infoSize)) | ||
{ | ||
ReadOnlySpan<byte> RemoteAddressSpan = new ReadOnlySpan<byte>(connectionInfo.RemoteAddress, 128); | ||
AddressFamily addressFamily = (AddressFamily)BitConverter.ToInt16(RemoteAddressSpan.ToArray(), 0); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should avoid the ToArray()
allocation. Shouldn't we deal with endiannes here too and use BinaryPrimitives
?
else | ||
{ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On what Windows versions is WINHTTP_OPTION_CONNECTION_INFO
supported? Can we disable WinHttpQueryOption
queries forever if it fails once?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Or run it against a fake handle to create a feature flag: https://github.com/dotnet/runtime/blob/f1901a00dfcfbcf0a4f8b6aa7459fec30846d1ab/src/libraries/System.Net.Http.WinHttpHandler/src/System/Net/Http/WinHttpTrailersHelper.cs
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think there is a windows version which doesn't support this feature and we support as .NET, this should be pretty safe.
WINHTTP_OPTION_CONNECTION_INFO says that this Applies to: Windows XP with SP2 and later; Windows 2003 with SP1 and later.
So we're safe here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Might worth to add a comment.
AddressFamily.InterNetworkV6 => new IPAddress(RemoteAddressSpan.Slice(8, 16).ToArray()), | ||
_ => null | ||
}; | ||
if (ipAddress != null && NetEventSource.Log.IsEnabled()) NetEventSource.Info(state, $"ipAddress: {ipAddress}"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we really expect non-IP address families here? Or can we just do Debug.Assert()
for it and avoid further checks?
@@ -56,6 +61,11 @@ private static void RequestCallback( | |||
{ | |||
switch (internetStatus) | |||
{ | |||
case Interop.WinHttp.WINHTTP_CALLBACK_STATUS_CONNECTED_TO_SERVER: | |||
IPAddress connectedToIPAddress = IPAddress.Parse(Marshal.PtrToStringUni(statusInformation)!); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This may be problematic in general as there can be many virtual servers and certificates on single IP address.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you're talking about the same ip-port pair, yes, but we can extend this solution to cover sni-port too, the only thing we can do at that moment is, once we have a connection to this ip address, we still need to clear all cache for this ip address, because we can't extract more info other than ip address in this callback.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is a (host, port)
pair really sufficient to identify connections? In SocketsHttpHandler
our connection key includes much more info.
Lines 520 to 529 in bfa0276
internal readonly struct HttpConnectionKey : IEquatable<HttpConnectionKey> | |
{ | |
public readonly HttpConnectionKind Kind; | |
public readonly string? Host; | |
public readonly int Port; | |
public readonly string? SslHostName; // null if not SSL | |
public readonly Uri? ProxyUri; | |
public readonly string Identity; | |
public HttpConnectionKey(HttpConnectionKind kind, string? host, int port, string? sslHostName, Uri? proxyUri, string identity) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is very nice for Prove of concept, but there are few things which needs to be considered before we decide on feasibility of this approach:
- decide if unbounded cache can be an memory leak issue (I have hard time to imagine use where it would)
- consider different cache key to handle virtual servers infra
ref infoSize)) | ||
{ | ||
ReadOnlySpan<byte> RemoteAddressSpan = new ReadOnlySpan<byte>(connectionInfo.RemoteAddress, 128); | ||
AddressFamily addressFamily = (AddressFamily)BitConverter.ToInt16(RemoteAddressSpan.ToArray(), 0); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not to use simple bit manipulation: RemoteAddressSpan[0] + (RemoteAddressSpan[0]<<8)
normally I would recommend BinaryPrimitives.ReadInt16LittleEndian
but I not sure we can afford package dependency here for framework.
@@ -20,6 +23,8 @@ internal static class WinHttpRequestCallback | |||
public static Interop.WinHttp.WINHTTP_STATUS_CALLBACK StaticCallbackDelegate = | |||
new Interop.WinHttp.WINHTTP_STATUS_CALLBACK(WinHttpCallback); | |||
|
|||
private static ConcurrentDictionary<IPAddress, string> s_cachedCertificates = new(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NIT: Also consider to invalidate instance level cache if validation callback is changed. I know it is not recommended to change handler after it has been wired to HttpClient but if it is possible we might consider to handle it.
} | ||
} | ||
|
||
if (ipAddress is not null && s_cachedCertificates.TryGetValue(ipAddress, out string? thumbprint) && thumbprint == serverCertificate.Thumbprint) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If cache key stays as IPAddress and not some more specific unique connection hash/id, there is change that IP of virtual servers will cause huge degradation in perf. For example if give IP serve http://foo/ and http://bar/ client which will alternate between foo and bar it will always cross invalidate foo and bar caches. For example if in this setup every call will be like:
1) http://foo/ensure-connected
2) http://bar/resource/verb?op=x
It will always call validation callback.
I believe we shall seek for cache key which has better affinity to particular endpoint, for example hash(request_host+cert_thumbprint).
It's a quick proof of concept.