Fix | Perform ServerCertificate pin validation to verify with server provided cert#4445
Fix | Perform ServerCertificate pin validation to verify with server provided cert#4445cheenamalhotra wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
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 noServerCertificatepin 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. |
3a048c7 to
59524a6
Compare
59524a6 to
e5a2542
Compare
| if (string.IsNullOrEmpty(validationCertFileName) && policyErrors == SslPolicyErrors.None) | ||
| { | ||
| SqlClientEventSource.Log.TrySNITraceEvent(nameof(SniCommon), EventType.INFO, "Connection Id {0}, targetServerName {1}, SSL Server certificate not validated as PolicyErrors set to None.", args0: connectionId, args1: targetServerName); | ||
| return true; |
There was a problem hiding this comment.
Agreed. In this case, this method has explicitly validated the server cert, so the wording is actually the opposite. Maybe:
Server certificate has no policy errors and no pin file was supplied; skipping pin checks
| /// <summary> | ||
| /// Tests for <see cref="SniCommon.ValidateSslServerCertificate"/> to ensure | ||
| /// that when a caller supplies a <c>ServerCertificate</c> pin the driver enforces the | ||
| /// pin as an *additive* check on top of normal TLS validation, and never accepts a | ||
| /// server certificate on the basis of platform trust alone. | ||
| /// | ||
| /// The tests exercise the shared helper directly. | ||
| /// </summary> | ||
| public class SniCommonValidateSslServerCertificateTest |
There was a problem hiding this comment.
I think the PR description is inaccurate. My take is that ValidateSslServerCertificate is called by both of those transport flows, so we don't need to add any tests further up the call chain specific to TCP or NP.
| |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+)| |
There was a problem hiding this comment.
What is platform trust? Is it a broadly understood industry term? How is it different than the checks we perform in ValidateSslServerCertificate?
There was a problem hiding this comment.
When we check for PolicyErrors = None, that confirms Platform/Runtime on OS trusts the certificate.
| > [!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** |
There was a problem hiding this comment.
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.
| /// 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 |
There was a problem hiding this comment.
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?
| if (string.IsNullOrEmpty(validationCertFileName) && policyErrors == SslPolicyErrors.None) | ||
| { | ||
| SqlClientEventSource.Log.TrySNITraceEvent(nameof(SniCommon), EventType.INFO, "Connection Id {0}, targetServerName {1}, SSL Server certificate not validated as PolicyErrors set to None.", args0: connectionId, args1: targetServerName); | ||
| return true; |
There was a problem hiding this comment.
Agreed. In this case, this method has explicitly validated the server cert, so the wording is actually the opposite. Maybe:
Server certificate has no policy errors and no pin file was supplied; skipping pin checks
| } | ||
|
|
||
| string serverNameToValidate; | ||
| X509Certificate validationCertificate = null; |
There was a problem hiding this comment.
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.
| /// <summary> | ||
| /// Tests for <see cref="SniCommon.ValidateSslServerCertificate"/> to ensure | ||
| /// that when a caller supplies a <c>ServerCertificate</c> pin the driver enforces the | ||
| /// pin as an *additive* check on top of normal TLS validation, and never accepts a | ||
| /// server certificate on the basis of platform trust alone. | ||
| /// | ||
| /// The tests exercise the shared helper directly. | ||
| /// </summary> | ||
| public class SniCommonValidateSslServerCertificateTest |
There was a problem hiding this comment.
I think the PR description is inaccurate. My take is that ValidateSslServerCertificate is called by both of those transport flows, so we don't need to add any tests further up the call chain specific to TCP or NP.
| return request.CreateSelfSigned(notBefore, notAfter); | ||
| } | ||
|
|
||
| private static string WriteCertToTempFile(X509Certificate2 cert) |
There was a problem hiding this comment.
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, ...);
| connectionId: Guid.NewGuid(), | ||
| targetServerName: "server.contoso.com", | ||
| hostNameInCertificate: null, | ||
| serverCert: null, |
There was a problem hiding this comment.
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.
| } | ||
|
|
||
| if (!string.IsNullOrEmpty(validationCertFileName)) | ||
| try |
There was a problem hiding this comment.
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).
Description
Refines the managed SNI certificate-validation helper so that the
ServerCertificateconnection-string keyword behaves consistently with its documented "exact match" semantic.Address internal item AB#46371
Two small refinements in
SniCommon.ValidateSslServerCertificate:policyErrors == SslPolicyErrors.Nonenow also requiresvalidationCertFileNameto be null/empty. When the caller has supplied aServerCertificatepin, the helper always compares it against the presented server certificate. The no-pin fast path is unchanged.SSLCertificateAuthenticationExceptioninstead of continuing through chain/name checks — matching the caller's explicit intent to pin.The
TrustServerCertificate=trueshort-circuit on the transport handles is unchanged — when validation is disabled at that layer, the pin remains unconsulted, matching the existing semantic.Backwards compatibility
SSLCertificateAuthenticationExceptionat handshake, aligning runtime with the documented "exact match" contract.Testing
Added
SniCommonValidateSslServerCertificateTestcovering the helper directly (both TCP and NP transports flow through it):PolicyErrors.NonetruePolicyErrors.NonetruePolicyErrors.NoneAuthenticationExceptionPolicyErrors.NoneAuthenticationExceptionEach runs against both
"TCP"and"NP"transport labels via[Theory]/MemberData. Local run onnet9.0: 8 passed / 0 failed.Guidelines
Reviewed: