Skip to content

Fix | Perform ServerCertificate pin validation to verify with server provided cert - #4445

Open
cheenamalhotra wants to merge 4 commits into
mainfrom
dev/cheena/server-cert-fix
Open

Fix | Perform ServerCertificate pin validation to verify with server provided cert#4445
cheenamalhotra wants to merge 4 commits into
mainfrom
dev/cheena/server-cert-fix

Conversation

@cheenamalhotra

@cheenamalhotra cheenamalhotra commented Jul 14, 2026

Copy link
Copy Markdown
Member

Description

Refines the managed SNI certificate-validation helper so that the ServerCertificate connection-string keyword behaves consistently with its documented "exact match" semantic.

Address internal item AB#46371

Two small refinements in SniCommon.ValidateSslServerCertificate. The existing design is unchanged: an exact ServerCertificate match still satisfies certificate validation.

  1. Short-circuit narrowed to the pin-less path. The early-return on policyErrors == SslPolicyErrors.None now also requires validationCertFileName to be null/empty. When the caller has supplied a ServerCertificate, the helper always compares it against the presented server certificate — previously it was skipped entirely whenever the platform reported no policy errors. The no-pin fast path is unchanged.
  2. Load failure fails closed. If the file can't be loaded/parsed, the helper now throws SSLCertificateAuthenticationException naming the configured path, instead of silently discarding the option and falling back to host-name validation — matching the caller's explicit intent.

Additionally, two defects surfaced in review:

  • The loaded X509Certificate is now disposed via a narrow using scope (it owns unmanaged resources).
  • A null server certificate now throws AuthenticationException rather than a NullReferenceException from the raw-bytes comparison.

The TrustServerCertificate=true short-circuit on the transport handles is unchanged — when validation is disabled at that layer, ServerCertificate remains unconsulted, matching the existing semantic.

Backwards compatibility

  • No ServerCertificate supplied → identical behavior.
  • ServerCertificate supplied, well-formed, and matches → identical behavior, including when the presented certificate would otherwise fail chain validation (for example a self-signed or private-CA certificate). This scenario deliberately continues to work.
  • ServerCertificate supplied and the file is missing/unreadable, or it doesn't match the server cert → now throws SSLCertificateAuthenticationException at handshake, aligning runtime with the documented "exact match" contract. Both cases previously risked accepting a connection the caller intended to restrict.

Testing

Added SniCommonValidateSslServerCertificateTest, which exercises SniCommon.ValidateSslServerCertificate directly. Both the TCP and Named Pipes transports call into this same helper, so no transport-specific parameterization is needed.

Scenario Expected
No cert configured + PolicyErrors.None returns true
Match + PolicyErrors.None returns true
Match + PolicyErrors.RemoteCertificateChainErrors returns true (exact match satisfies validation)
Mismatch + PolicyErrors.None throws AuthenticationException
Mismatch + PolicyErrors.RemoteCertificateChainErrors throws AuthenticationException
Configured + null server cert + PolicyErrors.RemoteCertificateNotAvailable throws AuthenticationException
Configured + null server cert + PolicyErrors.None throws AuthenticationException
Unreadable file + PolicyErrors.None throws AuthenticationException

Local run on net9.0: 8 passed / 0 failed.

Guidelines

Reviewed:

@cheenamalhotra
cheenamalhotra requested a review from a team as a code owner July 14, 2026 00:43
Copilot AI lite review requested due to automatic review settings July 14, 2026 00:43
@github-project-automation github-project-automation Bot moved this to To triage in SqlClient Board Jul 14, 2026
@cheenamalhotra cheenamalhotra added Hotfix 7.0.3 PRs targeting main that should be backported to release/7.0 branch for next release. Hotfix 6.1.7 PRs targeting main that should be backported to release/6.1 branch for future hotfix labels Jul 14, 2026
@cheenamalhotra cheenamalhotra added this to the 7.1.0-preview3 milestone Jul 14, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR refines Managed SNI’s TLS server-certificate validation so the ServerCertificate connection-string keyword behaves as an explicit certificate pin (and updates docs/tests to reflect the intended semantics).

Changes:

  • Tightens the “policyErrors == None” fast path so it only applies when no ServerCertificate pin is provided.
  • Changes pin-load failures to fail closed by throwing an authentication exception.
  • Adds unit tests for pin vs. no-pin behavior and updates documentation to clarify “additive” pinning semantics.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ManagedSni/SniCommon.netcore.cs Adjusts certificate-validation control flow for pin handling and fail-closed behavior.
src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/ManagedSni/SniCommonValidateSslServerCertificateTest.cs Adds unit coverage for SniCommon.ValidateSslServerCertificate pin/no-pin scenarios.
doc/snippets/Microsoft.Data.SqlClient/SqlConnectionStringBuilder.xml Documents ServerCertificate as an additive check (pin + standard validation).
doc/snippets/Microsoft.Data.SqlClient/SqlConnection.xml Expands connection-string keyword docs to clarify additive pinning and fail-closed behavior.

@cheenamalhotra
cheenamalhotra force-pushed the dev/cheena/server-cert-fix branch from 3a048c7 to 59524a6 Compare July 14, 2026 00:58
Copilot AI review requested due to automatic review settings July 14, 2026 01:04
@cheenamalhotra
cheenamalhotra force-pushed the dev/cheena/server-cert-fix branch from 59524a6 to e5a2542 Compare July 14, 2026 01:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 6 changed files in this pull request and generated 3 comments.

Files not reviewed (1)
  • src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs: Generated file

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 6 changed files in this pull request and generated 2 comments.

Files not reviewed (1)
  • src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs: Generated file

|Failover Partner SPN<br /><br /> -or-<br /><br /> FailoverPartnerSPN|N/A|The SPN for the failover partner. The default value is an empty string, which causes SqlClient to use the default, driver-generated SPN.<br /><br /> (Only available in v5.0+)|
|Host Name In Certificate<br /><br /> -or-<br /><br />HostNameInCertificate|N/A|The host name to use when validating the server certificate. When not specified, the server name from the Data Source is used for certificate validation.<br /><br /> (Only available in v5.0+)|
|Server Certificate<br /><br /> -or-<br /><br />ServerCertificate|N/A|The path to a certificate file to match against the SQL Server TLS/SSL certificate. The accepted certificate formats are PEM, DER, and CER. If specified, the SQL Server certificate is checked by verifying if the ServerCertificate provided is an exact match.<br /><br /> (Only available in v5.1+)|
|Server Certificate<br /><br /> -or-<br /><br />ServerCertificate|N/A|The path to a certificate file to match against the SQL Server TLS/SSL certificate. The accepted certificate formats are PEM, DER, and CER. If specified, the SQL Server certificate is checked by verifying if the ServerCertificate provided is an exact match.<br /><br />This check is **additive** to normal certificate validation, not a replacement for it. When the driver validates the server certificate, the presented certificate must both pass chain-and-name validation **and** match the certificate loaded from this path exactly. If the file cannot be loaded or parsed, the TLS handshake fails; the connection is never silently accepted on the basis of platform trust alone.<br /><br />Certificate validation itself can be disabled by `TrustServerCertificate=true` (except with `Encrypt=strict`, where validation is always performed). When validation is disabled, `ServerCertificate` is not consulted.<br /><br /> (Only available in v5.1+)|

@paulmedynski paulmedynski Jul 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What is platform trust? Is it a broadly understood industry term? How is it different than the checks we perform in ValidateSslServerCertificate?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch — "platform trust" was unnecessary jargon. Reworded to "chain-and-name validation", which is exactly what SslStream performs and reports back to us via SslPolicyErrors. ValidateSslServerCertificate does not re-do that work; it interprets the reported SslPolicyErrors and then applies the additional ServerCertificate pin check.

> [!NOTE]
> `ServerCertificate` is **additive** to normal certificate validation, not a replacement for it. When the driver validates the server certificate, the presented certificate must:
>
> 1. Pass chain-and-name validation, **and**

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Interesting, so it isn't possible to supply a self-signed server cert pin and use it to validate? I guess you could also load the server cert into the store and then the chain-and-name would validate as well, but that seems like an extra hoop to jump through. This is all existing behaviour though, so we're stuck with it I guess.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Correct — this is pre-existing behavior and unchanged here. The pin only narrows which certificate is acceptable; it does not establish trust. A self-signed server cert still has to be trusted (installed in the OS store) or the connection must use TrustServerCertificate=true. I have added a sentence to both doc snippets making that explicit so users are not surprised.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Following up: keeping the existing behavior here. An exact match does satisfy validation, so a self-signed server cert supplied via ServerCertificate works today without loading it into the store — I had briefly changed that and have reverted it, since it would have been a breaking change for exactly this scenario.

/// pin is supplied, the presented server certificate must be a byte-for-byte match of the
/// pinned certificate. The pin is <b>additive</b>: it does not replace chain / name
/// validation. The presented certificate must both match the pin (when supplied) and
/// pass the platform's chain / name checks (i.e. <paramref name="policyErrors"/> must

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why must policyErrors be None? Do we mean that chain/name checks are only performed when policyErrors is None? What do we do for other values of policyErrors?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Reworded the doc comment. policyErrors is the result of the chain/name validation SslStream already performed before invoking this callback — we do not perform those checks ourselves. None simply means SslStream found no chain or name problem. The docs now describe the two ordered steps: (1) inspect every set policyErrors flag and fail with a descriptive AuthenticationException unless it can be resolved (a name mismatch may still be satisfied by HostNameInCertificate), and (2) apply the ServerCertificate pin check on top.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We can narrow the scope of this variable to inside the try block on line 95:

try
{
    using X509Certificate validationCertificate = ...;

    // Do span checks...
}
catch (Exception e)
{
    // Load failed...
    throw ...;
}

I think this also eliminates the try-finally on line 91.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done. Extracted ValidateCertificatePin and LoadValidationCertificate, so the certificate is now a using declaration in a narrow scope and the outer try/finally is gone. Splitting the load into its own method also keeps the load-failure catch from swallowing the mismatch AuthenticationException.

return request.CreateSelfSigned(notBefore, notAfter);
}

private static string WriteCertToTempFile(X509Certificate2 cert)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You could avoid the try/finally in each test if this became a disposable class:

private class TempCertFile : IDisposable
{
  public string Path { get; }

  public TempCertFile(X509Certificate2 cert)
  {
    string tempPath = ...
    File.WriteAllBytes(tempPath, cert...);
    Path = tempPath;
  }

  public void Dispose()
  {
    File.Delete(Path);
  }
}

Then in the tests:

using TempCertFile pinFile = new(pinCert);

ValidateSslServerCertificate(..., pinFile.Path, ...);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done — added a TempCertFile : IDisposable helper; every test now uses using TempCertFile pinFile = new(cert); and the try/finally blocks are gone.

connectionId: Guid.NewGuid(),
targetServerName: "server.contoso.com",
hostNameInCertificate: null,
serverCert: null,

@paulmedynski paulmedynski Jul 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What should happen if serverCert isn't null, but policyErrors is RemoveCertificateNotAvailable? Logically I think we're saying that we don't expect a serverCert for certain policy error values, but the method docs don't talk about that relationship, and I'm not sure the method enforces it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The relationship is now documented on the method, and the ordering change makes it moot for the normal path: RemoteCertificateNotAvailable is handled in step 1 and always throws, regardless of whether serverCert happens to be non-null. For the inverse (a pin supplied with serverCert == null and policyErrors == None, which should not happen), ValidateCertificatePin now has an explicit null guard that throws AuthenticationException instead of NREing. Both cases have regression tests.

}

if (!string.IsNullOrEmpty(validationCertFileName))
try

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If this new pin checking is additive, then why are we performing it before all of the existing policy error checks? I think it would make more sense to perform this after the existing checks (i.e. below line 190).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed — reordered. The pin check now runs after all SslPolicyErrors handling and after the messageBuilder.Length > 0 throw, so the additive semantics are explicit in the control flow: policy errors always fail first, and the pin can only add a further failure, never rescue one. The existing MatchingPin_ChainErrors_Throws test still covers the invariant.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Reverting this — I had reordered the pin check to run after the policy-error handling, but that turned out to be a real breaking change and is beyond the intended scope of this PR. Under the existing design an exact ServerCertificate match satisfies validation, which is what makes Encrypt=Mandatory;ServerCertificate=<path> work today against a self-signed or private-CA server certificate without TrustServerCertificate=true. Making the check additive would break those users. The design is now unchanged from main; this PR keeps only the two intended fixes: (1) the policyErrors == None fast path no longer skips a configured ServerCertificate, and (2) a ServerCertificate that cannot be loaded/parsed fails closed rather than being silently ignored.

@github-project-automation github-project-automation Bot moved this from To triage to Waiting for customer in SqlClient Board Jul 14, 2026
@github-actions

Copy link
Copy Markdown

This pull request has been marked as stale due to inactivity for more than 30 days.

If you would like to keep this pull request open, please provide an update or respond to any comments. Otherwise, it will be closed automatically in 7 days.

@github-actions github-actions Bot added the Stale The Issue or PR has become stale and will be automatically closed shortly if no activity occurs. label Aug 14, 2026
@cheenamalhotra cheenamalhotra removed the Stale The Issue or PR has become stale and will be automatically closed shortly if no activity occurs. label Aug 14, 2026
- Perform ServerCertificate pin validation after chain/name policy error
  handling, making the additive semantics explicit in control flow.
- Extract ValidateCertificatePin/LoadValidationCertificate helpers so the
  loaded pin certificate lives in a narrow 'using' scope (no try/finally).
- Guard against a null server certificate in the pin path.
- Clarify method docs on the policyErrors relationship and reword the
  no-pin fast-path trace message.
- Replace ambiguous 'platform trust' wording in docs with chain-and-name
  validation, and note that pinning does not itself confer trust.
- Add TempCertFile disposable test helper and a null-server-cert regression
  test for the policyErrors == None case.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 14, 2026 05:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 6 changed files in this pull request and generated no new comments.

Files not reviewed (1)
  • src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs: Generated file
Suppressed comments (1)

src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ManagedSni/SniCommon.netcore.cs:119

  • The trace format string for the RemoteCertificateChainErrors path uses incorrect placeholder indices (it prints the connection id where targetServerName should be, and targetServerName where the policy error should be). This makes TLS troubleshooting logs misleading.
                    SqlClientEventSource.Log.TrySNITraceEvent(nameof(SniCommon), EventType.ERR, "Connection Id {0}, targetServerName {0}, SslPolicyError {1}, SSL Policy certificate chain has errors.", args0: connectionId, args1: targetServerName, args2: policyErrors);

…rror

Restores the original semantic where an exact ServerCertificate match
satisfies certificate validation. The only behavior changes remain:

- The policyErrors == None fast path now also requires that no
  ServerCertificate was supplied, so a configured certificate is always
  compared against the one presented by the server.
- A ServerCertificate that cannot be loaded/parsed now fails the
  connection instead of being silently ignored.

Also keeps the non-design fixes: the loaded certificate is disposed via a
narrow using scope, and a null server certificate throws
AuthenticationException instead of NullReferenceException.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 14, 2026 06:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 6 changed files in this pull request and generated no new comments.

Files not reviewed (1)
  • src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs: Generated file
Suppressed comments (1)

src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ManagedSni/SniCommon.netcore.cs:121

  • The log format string for the RemoteCertificateChainErrors branch uses incorrect placeholders (it repeats {0} for targetServerName and never prints policyErrors). This produces misleading traces when diagnosing TLS failures.
                if (policyErrors.HasFlag(SslPolicyErrors.RemoteCertificateChainErrors))
                {
                    SqlClientEventSource.Log.TrySNITraceEvent(nameof(SniCommon), EventType.ERR, "Connection Id {0}, targetServerName {0}, SslPolicyError {1}, SSL Policy certificate chain has errors.", args0: connectionId, args1: targetServerName, args2: policyErrors);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Hotfix 6.1.7 PRs targeting main that should be backported to release/6.1 branch for future hotfix Hotfix 7.0.3 PRs targeting main that should be backported to release/7.0 branch for next release.

Projects

Status: Waiting for customer

Development

Successfully merging this pull request may close these issues.

4 participants