Skip to content

TlsSession discards peer-sent intermediates before external certificate validation #134567

Description

@wfurt

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.

Activity

  1. dotnet-policy-service commented on Sep 24, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @dotnet/ncl, @bartonjs, @vcsjones
    See info in area-owners.md if you want to be subscribed.

  2. self-assigned this
    on Sep 24, 2026
  3. added this to the 12.0.0 milestone on Sep 24, 2026
  4. added a commit that references this issue on Sep 30, 2026
    201d585
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions