Description
TlsSession captures the peer-sent certificate chain for external validation by reading X509Chain.ChainElements, but the PAL that produces the chain populates X509Chain.ChainPolicy.ExtraStore and never calls Build(). ChainElements is therefore always empty, the snapshot is never taken, and the chain holding the real data is disposed immediately afterwards.
The result is that a caller performing external certificate validation on a TlsSession never sees the intermediates the peer sent, where the equivalent SslStream connection does.
Where
TlsSession.CaptureRemoteCertificateForExternalValidation() in src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs:
X509Chain? chain = null;
_externalPendingCert = CertificateValidationPal.GetRemoteCertificate(
_securityContext, ref chain, _options.CertificateChainPolicy);
if (chain is not null)
{
if (chain.ChainElements.Count > 1) // always 0: the chain was never built
{
X509Certificate2Collection intermediates = new X509Certificate2Collection();
for (int i = 1; i < chain.ChainElements.Count; i++)
{
intermediates.Add(new X509Certificate2(chain.ChainElements[i].Certificate));
}
_externalRemoteCertificates = intermediates;
}
chain.Dispose(); // ExtraStore contents are discarded here
}
That call resolves to the overload with retrieveChainCertificates: true:
// CertificateValidationPal.cs
internal static X509Certificate2? GetRemoteCertificate(SafeDeleteContext? securityContext, ref X509Chain? chain, X509ChainPolicy? chainPolicy) =>
GetRemoteCertificate(securityContext, retrieveChainCertificates: true, ref chain, chainPolicy);
and both PALs deposit the peer chain into ChainPolicy.ExtraStore, not into ChainElements:
// CertificateValidationPal.Unix.cs
chain.ChainPolicy.ExtraStore.Add(chainCert);
// CertificateValidationPal.Windows.cs
UnmanagedCertificateContext.GetRemoteCertificatesFromStoreContext(remoteContext, chain.ChainPolicy.ExtraStore);
Neither builds the chain, so ChainElements is empty on both platforms.
Why it is observable
Validation is driven by the caller, but AcceptWithDefaultValidation() still constructs the X509Chain handed to the user's RemoteCertificateValidationCallback, and seeds it from the snapshot:
if (_externalRemoteCertificates is { Count: > 0 } intermediates)
chain.ChainPolicy.ExtraStore.AddRange(intermediates);
Empty snapshot → empty ExtraStore → the callback sees no intermediates. GetRemoteCertificates() returns the same field, so a caller doing fully manual validation via SetRemoteCertificateValidationResult is equally affected, and there is no way to recover the intermediates from the public surface.
Practical impact: a server that relies on peer-supplied intermediates rather than locally installed ones cannot build the chain, and any callback inspecting ExtraStore behaves differently from the SslStream path.
Reproduction
Measured with ASP.NET Core's HttpsConnectionMiddlewareTests.ServerCertificateChainInExtraStore, which asserts Assert.NotEmpty(chain.ChainPolicy.ExtraStore) inside a server-side RemoteCertificateValidationCallback, run against a Kestrel endpoint backed by TlsBufferSession instead of SslStream.
Same client certificate and same chain, on 11.0.0-rc.2.26471.109, Linux:
| TLS layer |
chain.ChainPolicy.ExtraStore.Count |
chain.ChainElements.Count |
SslStream |
3 |
5 |
TlsBufferSession |
0 |
5 |
Suggested fix
Read the property the PAL actually fills. This is platform-agnostic, since Unix and Windows both use ExtraStore:
if (chain is not null)
{
if (chain.ChainPolicy.ExtraStore.Count > 0)
{
X509Certificate2Collection intermediates = new X509Certificate2Collection();
foreach (X509Certificate2 cert in chain.ChainPolicy.ExtraStore)
{
intermediates.Add(new X509Certificate2(cert));
}
_externalRemoteCertificates = intermediates;
}
chain.Dispose();
}
One detail worth confirming: the current code starts at index 1 to skip the leaf, which is right for ChainElements but not necessarily for ExtraStore. On Unix the store holds only the peer-sent chain; the SChannel path should be checked for whether it also includes the leaf, and de-duplicated against _externalPendingCert if so.
Description
TlsSessioncaptures the peer-sent certificate chain for external validation by readingX509Chain.ChainElements, but the PAL that produces the chain populatesX509Chain.ChainPolicy.ExtraStoreand never callsBuild().ChainElementsis therefore always empty, the snapshot is never taken, and the chain holding the real data is disposed immediately afterwards.The result is that a caller performing external certificate validation on a
TlsSessionnever sees the intermediates the peer sent, where the equivalentSslStreamconnection does.Where
TlsSession.CaptureRemoteCertificateForExternalValidation()insrc/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs:That call resolves to the overload with
retrieveChainCertificates: true:and both PALs deposit the peer chain into
ChainPolicy.ExtraStore, not intoChainElements:Neither builds the chain, so
ChainElementsis empty on both platforms.Why it is observable
Validation is driven by the caller, but
AcceptWithDefaultValidation()still constructs theX509Chainhanded to the user'sRemoteCertificateValidationCallback, and seeds it from the snapshot:Empty snapshot → empty
ExtraStore→ the callback sees no intermediates.GetRemoteCertificates()returns the same field, so a caller doing fully manual validation viaSetRemoteCertificateValidationResultis equally affected, and there is no way to recover the intermediates from the public surface.Practical impact: a server that relies on peer-supplied intermediates rather than locally installed ones cannot build the chain, and any callback inspecting
ExtraStorebehaves differently from theSslStreampath.Reproduction
Measured with ASP.NET Core's
HttpsConnectionMiddlewareTests.ServerCertificateChainInExtraStore, which assertsAssert.NotEmpty(chain.ChainPolicy.ExtraStore)inside a server-sideRemoteCertificateValidationCallback, run against a Kestrel endpoint backed byTlsBufferSessioninstead ofSslStream.Same client certificate and same chain, on
11.0.0-rc.2.26471.109, Linux:chain.ChainPolicy.ExtraStore.Countchain.ChainElements.CountSslStreamTlsBufferSessionSuggested fix
Read the property the PAL actually fills. This is platform-agnostic, since Unix and Windows both use
ExtraStore:One detail worth confirming: the current code starts at index 1 to skip the leaf, which is right for
ChainElementsbut not necessarily forExtraStore. On Unix the store holds only the peer-sent chain; the SChannel path should be checked for whether it also includes the leaf, and de-duplicated against_externalPendingCertif so.