fix(msdsn): preserve connection settings in DSN URLs - #465
sary (sary8) wants to merge 6 commits into
Conversation
Config.URL() kept its own list of parameters to emit while Parse grew
new ones, so a Parse -> URL().String() -> Parse round trip silently
reset the settings the serializer had never been told about. An explicit
encrypt=false was the worst case: parseTLS trusts the server
certificate when no encrypt parameter is present, so the round trip
turned certificate verification off.
Write a setting that has a Config field from that field, and write it
whenever the field disagrees with the value reparsing the URL without it
would produce. That makes an edit to a Config survive whether or not the
connection string named the setting, which a "was the parameter
supplied" test cannot do for the settings Parse fills in regardless -
AppName from a constant, KeepAlive from 30s, MultiSubnetFailover from
true. Where the field holds something the connection string grammar
cannot spell, drop the parameter rather than emit a URL that Parse
rejects or one that reads back as something else: negative and
sub-second durations go through the same check, dial timeout included,
since DialTimeout is documented as negative to disable. A packet size
outside the TDS range is not in that category: Parse and the login path
clamp to the same bounds, so the size the connection would actually use
is written instead of the raw value.
trustservercertificate is written asymmetrically, and against the
runtime's notion of trust rather than the field's.
Config.TrustServerCertificate is not read at connect time - getTLSConn
is - so serializing the field is the only way setting it can reach a
connection, and the only standard the serializer can be held to is what
the connection would actually do. A nil tls.Config is not "no opinion"
there: getTLSConn replaces it with SetupTLS("", "", false, ...), so such
a Config verifies. A verification callback counts as verification even
alongside InsecureSkipVerify, which is the combination SetupTLS uses for
a servercertificate pin and for a common name containing a colon, and
the one a caller uses for their own check. A verifying value is written
whenever it differs from what reparsing would produce, so that turning
verification on survives; a trusting one only when the connection string
asked for it, so that serializing a Config can never be the thing that
turns verification off. Strict encryption is left out: parseTLS forces
the value to false there whatever the parameter said.
hostnameincertificate is written from HostInCertificateProvided alone. A
ServerName that merely differs from Host is not evidence of an edit: the
two also diverge when a caller moves Host and leaves the tls.Config
alone, which failoverPartnerParams does, and writing the old name there
would check the certificate of a server the connection is no longer
going to. It would also set HostInCertificateProvided on the way back
in, which is the flag connect() reads to decide whether to retarget
ServerName after a routing redirect. A servercertificate pin that is
written is a second exception: parseTLS rejects the two parameters
together, and the pin compares raw bytes rather than checking a name, so
there is nothing for a certificate name to say. It has to be a pin that
is actually written, not one the connection string once named - a caller
who removed the pin and chose a name has a config that checks that name,
and the stale parameter says nothing about it.
certificate and servercertificate travel while they are still true about
what the connection does with the certificate - not while they are the
whole truth. A caller can add a check no connection string can spell,
and the parameter is then incomplete rather than wrong. Dropping it for
that costs more than it saves: nothing else in the grammar names a pin
or a private CA, so a URL without the parameter verifies against system
roots, which accepts every certificate a public CA has issued for the
host. Trading an incomplete truth for that is a wider connection, not a
safer one.
So the pin travels while a VerifyPeerCertificate is there at all.
crypto/tls calls one on every handshake, after any chain check and
whether or not verification is on, and nothing else on the config takes
that away: a pool is read only where a chain is built, and a
VerifyConnection is a second check rather than a replacement. Requiring
the rest of the shape setupTLSServerCertificateOnly builds would drop
the pin in those cases, and the chain it would be traded for is to
system roots, since the pin path sets no RootCAs.
Verification turned back on beside the pin is the one shape written so
that Parse refuses it. Such a Config accepts only a certificate that
passes both the chain check and the byte comparison, since crypto/tls
runs the chain first and the pin after, and no connection string
produces that: the pin path turns verification off. Either half on its
own accepts certificates the Config rejects - the pin alone accepts the
self-signed certificate a pin usually names, which the chain would
refuse, and the chain alone accepts every certificate a public CA has
issued for the host. Each half is a true statement about the Config, so
the URL names both, and parseTLS rejects the pair with an error that
says which two it cannot combine. That is the only output that accepts
nothing the Config would not, at the cost of a DSN that does not read
back; it is a decision, and it is written this way so that the cost is
an error rather than a wider connection.
A root pool is different because it is read only where a chain is built
against it, so with InsecureSkipVerify on the file names nothing the
connection enforces. Where a chain is built, the file is read back and
the pool compared, so the parameter travels only while it still
describes the roots in use; a caller who swapped the pool has left it
describing certificates the connection no longer trusts. An extra
callback beside the pool is not a reason to stop believing the pool. The
exception to all of that is the common name path: SetupTLS forks on the
name it checks against, and a colon in it routes the file through
setupTLSCommonName, which builds its callback out of that same file and
turns verification off so the callback can do the checking, so there the
file names the check even with no chain.
What cannot be told apart is a callback a caller swapped in over the
shape SetupTLS left behind, since Go function values cannot be compared;
that limit is recorded on URL() and pinned by a test rather than left to
be found. Nothing else carriedVerbatim holds is affected: fedauth,
authenticator and the krb5- settings have nothing to do with TLS, and
dropping one because the tls.Config changed would pick a different
authentication method.
Where the parameter has stopped being true there is nothing to write,
and the reader chains to system roots. That is honest but not safe: a
reader can see that no certificate was
named, and writing a stale file name instead would state something about
the Config with nothing to give it away - but the system pool accepts
more than a private one, so this direction can end up accepting a
certificate the Config itself would reject. There is no third answer
inside the grammar, readCertificate taking a .pem or .der path and
nothing else, and main writes neither parameter in any case, so it lands
in the same place. The tests assert the policy the reader is left with
rather than only that a parameter went missing.
The same file naming means a DSN is a reference and not a snapshot:
Parse reads the file again, so a servercertificate pin follows the file
rather than the bytes setupTLSServerCertificateOnly captured in its
callback, which live in a closure URL() cannot read. Following the file
is the narrower of the two available answers, since dropping the
parameter lands on system roots and a rotated file holds what the
operator now means. certificate differs only because its pool can be
read back out of the tls.Config and compared.
A tls.Config restriction the grammar has no word for is dropped -
MaxVersion, a cipher suite list, a callback of the caller's own - and a
Config restricted by one reparses as one that is not; that is recorded
on URL() and pinned by a test. A MinVersion
is different, because tlsmin exists and only lacked a spelling for
versions above the highest it names: on today's crypto/tls such a floor
is a Config no handshake can satisfy, and dropping the parameter or
rounding it down to a name both came back as a Config that connects. So
URL() writes such a floor as its protocol number, 0x0305, and Parse
reads 0x and exactly four hex digits back as that number, only above the
highest name. The bound matters: crypto/tls keeps TLS 1.0 and 1.1
out only while MinVersion is zero, so a number read into the named range
would give a string that meant the default yesterday - 0x0300, 0x0301 -
a looser meaning today. Those, and anything not exactly the spelling,
still read as the default, and a test against a server that speaks
nothing newer than TLS 1.1 shows none of them loosens the floor.
Two settings need the parameter as well as the field, because their
default is ambient rather than a constant. workstation id is filled from
the local host name, so an explicit value that happens to match it still
has to be written. epa enabled falls back to MSSQL_USE_EPA: a connection
string that never named it was already asking whichever process reads
it, so the round trip keeps asking rather than freezing this process's
answer into the URL. What survives is the explicit choice the issue
reports as lost, and a value a caller set that this process's
environment disagrees with; one that happens to match the environment is
indistinguishable from the ambient default and is left to the reader. An
environment value Parse would reject is not a default to compare against
at all, so the field is written in that case rather than leaving behind
a URL that cannot be read back.
Carry the settings that have no Config field - the certificate paths and
the fedauth, authenticator and krb5- settings that azuread and
integratedauth read for themselves - from a list rather than by copying
Config.Parameters. azuread also reads systemtoken, clientassertion and
userassertion out of that map, and url.URL.Redacted() masks only the
userinfo password, so copying every retained parameter would publish
those credentials in the string Go offers as the safe one to log. An
unrecognized parameter is dropped instead, losing a setting rather than
a secret, and a test reads msdsn's own parameter declaration out of the
source - constants and the names the parser spells inline - so that
adding one there without deciding how to serialize it fails. The keys
azuread and integratedauth declare for themselves are string literals in
those packages and are not covered by that guard; they default to being
dropped.
What azuread then does with the loss is its own and differs by workflow.
On-behalf-of has no other source for its assertion and fails naming it.
Azure Pipelines falls back to SYSTEM_ACCESSTOKEN, as it already does for
a connection string that never named a token, so where that is set a
round trip carries on with the ambient token in place of the one the
connection string named. Both are asserted.
change password is the one setting with a Config field that is still not
written, for the same reason as the three above and because a password
change is a login-time operation that replaying a serialized DSN should
not reissue.
The combinations rather than the individual settings were what broke
here, so a sweep covers them. TestConfigURLPreservesCertificatePolicy
walks every encrypt, trustservercertificate, certificate and
hostnameincertificate combination a connection string can carry -
including a name with a colon in it, which is the only one that reaches
setupTLSCommonName - crossed with the edits a caller can make
afterwards, and compares what the connection would actually do with the
certificate: which checks run, what a chain is built against - the pool
compared, not merely whether one is set - and under which name. Each of
those parts is there because a coarser version let a defect through.
Where an edit leaves a policy no connection string produces, the sweep
requires that the narrowest anchor still nameable survives rather than
waiving the comparison: a pin names one certificate and a private pool
names one CA, while a chain to system roots accepts every certificate a
public CA has issued for the host, so falling past one of those to the
next accepts more than the Config ever did. Only an anchor with no
spelling at all leaves the ordinary chain as the answer, and a chain
beside a pin - where each half is nameable and the conjunction is not -
has to be refused rather than halved.
Fixes microsoft#455
There was a problem hiding this comment.
🟡 Changes recommended
Certificate fallback can widen trust, and some reachable configurations produce URLs that Parse rejects.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR expands DSN URL serialization to preserve connection, TLS, authentication, and certificate settings across round trips while avoiding credential leakage.
Changes:
- Serializes additional configuration and retained parameters.
- Preserves TLS minimum versions and certificate policies.
- Adds extensive round-trip and Azure AD coverage.
- Requires resolution of certificate fallback and unparsable URL behavior.
File summaries
| File | Description |
|---|---|
msdsn/conn_str.go |
Expands URL serialization and TLS handling. |
msdsn/conn_str_roundtrip_test.go |
Adds comprehensive round-trip tests. |
msdsn/conn_str_go112.go |
Supports numeric TLS version serialization. |
msdsn/conn_str_go112pre.go |
Supports numeric TLS versions for older builds. |
azuread/configuration_test.go |
Tests federated authentication preservation and secret omission. |
Review details
- Files reviewed: 4/5 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
A Config with no tls.Config, TrustServerCertificate at its zero value and no parameter behind it has no view on trust at all. Parse never produces that shape, since it builds a tls.Config whenever encryption is on, so it only comes from a Config built by hand, and for those the zero value has always meant the parser's default rather than a choice to verify. The first commit wrote trustservercertificate=false for it, on the grounds that getTLSConn verifies for a nil tls.Config. That is what the driver's own integration harness builds from HOST and DATABASE before round-tripping it through URL(), so every AppVeyor job failed with "certificate signed by unknown authority" against the self-signed server there. Say nothing for that shape instead, so the reader applies its default as it did on main; a Config that does hold a view, through a tls.Config, the field or the parameter, is still written as before. Parsed Configs are unaffected: a DSN that names encrypt leaves the field false with a tls.Config present, and one that names trustservercertificate supplies the parameter, so the gate only ever opens for a hand-built Config. TestConfigURLWithNilParameters encoded the old behaviour and now asserts the new one.
|
AppVeyor failed on the first commit: every integration test hit 2f3d0a4 restores that for a |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #465 +/- ##
==========================================
+ Coverage 77.91% 82.81% +4.90%
==========================================
Files 35 35
Lines 7222 7222
==========================================
+ Hits 5627 5981 +354
+ Misses 1332 982 -350
+ Partials 263 259 -4 🚀 New features to boost your workflow:
|
Inside if p.NoTraceID the value can only be true, and inside !p.MultiSubnetFailover only false, so strconv.FormatBool there formats a constant and makes a reader trace the guard to see it. Write the literal.
There was a problem hiding this comment.
🔵 Needs a closer look
A moderate DER certificate handling issue remains, and the TLS trust-policy changes require human review.
Review details
Suppressed comments (1)
msdsn/conn_str.go:797
readCertificateexplicitly accepts.derfiles, but this comparison always feeds the returned bytes toAppendCertsFromPEM, which returns false for DER. Consequently a parsedcertificate=/path/file.deris omitted byURL()and the reparsed DSN falls back to system roots, changing the effective trust policy (and widening it). Reconstruct the comparison pool using the same PEM/DER handling asSetupTLS(and ensure the setup path itself preserves DER roots), then add a DER round-trip case.
fromFile := x509.NewCertPool()
if !fromFile.AppendCertsFromPEM(pemBytes) {
return false
- Files reviewed: 4/5 changed files
- Comments generated: 0 new
- Review effort level: Lite
readDERFile re-encodes a .der file as PEM, so the pool comparison that decides whether certificate is written sees the same bytes SetupTLS did. Nothing exercised that spelling; a review claimed the comparison would reject DER and drop the file, so the case is pinned.
|
Refuted, for the suppressed comment on |
There was a problem hiding this comment.
🔵 Needs a closer look
The unresolved TLS callback and certificate-policy behavior requires maintainer review.
Review details
Suppressed comments (1)
msdsn/conn_str.go:758
- A caller can replace the callback that
SetupTLSinstalled for a parsedservercertificatewith their ownVerifyPeerCertificatefunction while leavingParameters[ServerCertificate]unchanged. This branch treats any non-nil callback as proof that the retained file is still enforced, soURL()emits the stale pin and reparsing installs a new pin callback, changing the caller's effective TLS policy (and potentially rejecting certificates the editedConfigaccepts). The same reachability is exercised by the new sweep's callback-edit case, but its assertion only checks that some anchor remains, so it does not catch this policy change. Please track whether the callback is the driver-installed pin (or otherwise surface this unrepresentable state) instead of using non-nil as that distinction.
if name == ServerCertificate {
// crypto/tls calls VerifyPeerCertificate on every handshake, after any
// chain check and whether or not verification is on, so while one is
// there the file still names a certificate the connection compares
// against. Nothing else on the config takes that away: a pool is read
// only where a chain is built, a VerifyConnection is a second check
// rather than a replacement, and verification turned back on adds the
// chain rather than removing the pin.
//
// Requiring the rest of the shape setupTLSServerCertificateOnly builds
// would drop the pin in each of those cases, and this parameter is the
// only thing in the grammar that can name one. What it would be traded
// for is a chain to system roots - the pin path sets no RootCAs - so the
// URL would accept every certificate a public CA has issued for the host
// in place of the single certificate named here.
return p.TLSConfig.VerifyPeerCertificate != nil
- Files reviewed: 4/5 changed files
- Comments generated: 0 new
- Review effort level: Lite
A caller who replaces the VerifyPeerCertificate the pin path installed and leaves the parameter behind cannot be told apart from the pin, since Go function values cannot be compared, so the file travels and the pin is rebuilt from it. certificate already had a test recording that limit; give the pin the same, and say so on the branch that decides it.
|
Refuted, for the suppressed comment on |
There was a problem hiding this comment.
🟡 Changes recommended
Critical TLS behavior and test compilation issues must be fixed before approval.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 4/5 changed files
- Comments generated: 2
- Review effort level: Lite
getTLSConn never reads Config.TrustServerCertificate, so the comment now says what the field is for here: a veto only. A Config is described as trusting only if the tls.Config trusts and the field agrees, so a field set to false by hand can narrow the reparsed Config and never widen it.
|
There was an error handling pipeline event c1c3417e-395f-4977-8742-11c4b95cb118. |
Fixes #455.
Config.URL()kept its own list of what to write whileParsegrew new parameters, so aParse -> URL().String() -> Parseround trip reset whatever the serializer had never been toldabout. The worst case is an explicit
encrypt=false. It came back with certificateverification off, because
parseTLSreads a missingencryptas a reason to trust the server:The fix writes every setting that has a
Configfield from that field, whenever it differsfrom what reparsing the URL without it would produce. Settings with no field (the certificate
paths,
fedauth,authenticator,krb5-*) are carried from an allowlist rather than bycopying
Parameters, because a copy would passsystemtoken,clientassertionanduserassertionthroughRedacted()unmasked; it masks only the userinfo password. Thecertificate rules follow what
crypto/tlsactually does with thetls.Configrather than anyone field on it.
What changes for users
URL()writes more parameters. Signature unchanged; a downstream golden-string test will seethe difference. fix: expose TrustServerCertificate in msdsn.Config and URL round-trip #312 and fix: preserve strict encryption in DSN URLs #452 changed this output the same way and shipped as
fix:. Insidethe repo the callers are the integration harness (
makeConnStrintds_test.go) and theNewConnectorexample, both of which build aConfigby hand and round-trip it.Configbuilt as a struct literal serializes the zero-valued fields whose parser defaultsdiffer (
AppName: "",KeepAlive: 0,MultiSubnetFailover: false) instead of picking upthose defaults, which is what such a
Configalready connected with. TLS is the exception:a
Configwith notls.Config,TrustServerCertificateat its zero value and no parameterhas no view on trust, so nothing is written and it reparses to the parser's default (trust),
exactly as on
main. The first push wrotetrustservercertificate=falsethere and everyAppVeyor job failed with "certificate signed by unknown authority", because the harness
builds its
Configthat way against a self-signed SQL Server; the second commit fixes it.tlsminis preserved downward (1.0,1.1), and gains one spelling:0xand four hexdigits, read only above the highest version
tlsminhas a name for (TLS 1.3 on supportedbuilds), so a floor it cannot name round-trips instead of being dropped (decision 8).
servercertificatepin with verification turned back on, nowproduces a DSN
Parserefuses, because every alternative widens what it accepts (decision 6).URL()now reads thecertificatefile,os.Hostname()andMSSQL_USE_EPAon every call.SYSTEM_ACCESSTOKENrather than to SQLauthentication, since
fedauthnow travels andsystemtokendeliberately does not(decision 7).
Windows: mildly affected.
defaultWorkstation()callsos.Hostname(), theauthenticatorandkrb5-*settings now carried are consumed byintegratedauth, andpipe/protocolserialization feeds the Windows-only
namedpipeparser. None of that is platform-specific code,and none of it runs in the Linux matrix, so AppVeyor is the check.
Decisions
Three I would like a maintainer to make:
trustservercertificateis written from what the handshake does, not from the field. Averifying value whenever it differs from what reparsing would produce; a trusting value only
when the connection string asked for it. A round trip can therefore never turn verification
off. The one carve-out is a hand-built
Configwith no view on TLS at all, which stays onthe parser's default as it always has (see above). This is a judgment call about TLS
defaults, so I want a human on it.
Parserefuses it.crypto/tlsruns the chain first and the pin after, so the
Configaccepts only what passes both. Thepin alone accepts the self-signed certificate a pin usually names; the chain alone accepts
every public-CA certificate for the host. Naming both, which
parseTLSrejects, is the onlyoutput that accepts nothing the
Configwould not. Alternatives: the pin alone, or the chainalone.
tlsminlearns0x+ four hex digits, above the highest name only. The one addition tothe grammar. The bound matters:
crypto/tlskeeps TLS 1.0/1.1 out only whileMinVersioniszero, so a number read into the named range would loosen a floor a DSN has always had. A test
drives a TLS 1.0-1.1-only server to show none of
0x0300,0x0301,0x300,0x1does.Alternative: round down to 1.3, a documented widening.
Five I made and want a maintainer to see:
epa enabled: an explicit choice travels; a valueMSSQL_USE_EPAsupplied is left for thereading environment to supply again.
TestEveryParameterIsClassified) will make feat: add SQL Server 2025 VECTOR type support #307 fail, once it isrebased onto this change, until
vectortypesupportis classified. That is one map entry andone sample value.
RootCAs, amissing file). That is honest, since the URL states nothing untrue, but not safe, since the
system pool accepts more than a private one. The alternative is writing a stale file name a
reader cannot notice.
mainwritescertificatein no case, so it lands here too.servercertificatepin follows thefile if it is rotated between
ParseandURL(). No inline spelling exists to write instead.systemtokenis dropped. This is theone path this PR makes newly reachable, since
maindroppedfedauthtoo. On-behalf-of hasno such fallback and fails naming
userassertion. Both are asserted.Tests
msdsn/conn_str_roundtrip_test.gois new;azuread/configuration_test.gogains two tests.There is one case per row of the issue, and a 3780-combination sweep over every
encryptxtrustservercertificatexcertificate/servercertificatexhostnameincertificateaconnection string can carry, crossed with fifteen edits a caller can make afterwards, compared
on what the connection would actually do with the certificate. Where a claim rests on
crypto/tlsbehaviour the test runs a real handshake. I reverted every rule one at a time toconfirm a test catches it.
msdsncoverage 90.9% to 95.2%,URL()84.6% to 98.1%.Validation skipped: no local SQL Server here, so every integration test skipped and integration
coverage is left to CI.
Out of scope
While checking that a synonym could not produce a duplicate query key I found that
adoSynonymsis applied bysplitConnectionStringbut not bysplitConnectionStringURLorsplitConnectionStringOdbc, sosqlserver://host?app=x&connect+timeout=7silently ignoresboth settings while the ADO spelling honours them. Filed separately as #464 and left out of
this diff.
#307 inserts into
URL()at the same anchor as the block added here, so whichever lands secondwants a trivial both-keep resolution. #368's only overlap with this change in
msdsn/conn_str.gois anadoSynonymsentry, so it should not conflict.The rest is the full reasoning, for whoever wants to check it.
How each setting is written
A setting with a field on
Configis written from that field, whenever the field disagreeswith the value reparsing the URL without it would produce. An edit to a
Configthereforesurvives whether or not the connection string named the setting, which a "was the parameter
supplied" test cannot do, because
Parsefills several fields in regardless:AppNamefrom aconstant,
KeepAlivefrom 30s,MultiSubnetFailoverfrom true, andTrustServerCertificatefrom the absence of
encrypt. Where the field holds something the grammar cannot spell (anegative or sub-second duration, read-only intent with no database), the parameter is dropped
rather than emitted, because
Parsewould reject the URL or read it back as something else.dial timeoutgoes through the same check, which is slightly beyond the issue's table:DialTimeoutis documented as negative to disable, and that value used to serialize todial timeout=-1, whichParserejects.A packet size is not in that category. Zero is the sentinel for the driver's own default, so it
stays out; anything else is written as the size the connection would actually use, because
Parseand the login path clamp to the same TDS range. Dropping an out-of-range value wouldhave come back as the 4096 default instead of the 512 or 32767 a direct connection uses.
Two settings need the parameter as well as the field, because their default is ambient
rather than a constant.
workstation idis filled from the local host name, so an explicit value that happens to matchit is indistinguishable from an unset one by comparison alone; without the presence test it
would come back as the reading machine's name.
epa enabledfalls back toMSSQL_USE_EPA. The contract I settled on is: an explicit choicetravels with the URL; a value the environment supplied is left for the reading environment to
supply again. A connection string that never named the setting was already asking whichever
process read it, so freezing this process's answer into the URL would invent a decision nobody
made. What has to survive is the explicit choice, the loss the issue reports,
and any value a caller set on the
Config, which is why the comparison is against theenvironment rather than against
false. If you would rather the URL always pinned theeffective value, that is a one-line change, but it puts
epa enabledin every URL andbreaks the two existing whole-
Configround-trip tests.encryptis written only when the connection string supplied it:EncryptionOffis also thezero value, and emitting it for every unconfigured
Configwould turn a default into achoice, which the issue warns against. That costs nothing, because
EncryptionOffis alreadywhat a URL without the parameter reparses to; the half of the distinction that decides whether
the certificate is verified rides on
trustservercertificate, which follows the rule above.Settings with no field (the certificate paths, and the
fedauth,authenticatorandkrb5-*settingsazureadandintegratedauthread out ofParameters) are carried from alist,
carriedVerbatim, not by copyingParameters.Why a list and not a copy
Copying every retained parameter closes the same rows with less code, and I tried that first.
It publishes credentials:
azureadreadssystemtoken,clientassertionanduserassertionout of the same map, and
url.URL.Redacted()masks only the userinfo password, not queryparameters, so those would come through
Redacted()unmasked.An unrecognized parameter is therefore dropped. It is a real cost, and it leaves the issue's closing
paragraph (a new parser setting can be forgotten by the serializer) technically true. The
trade is not free: a dropped setting can itself be a security loss, and
epa enabledabove isexactly that. What makes it the right way round is that the failure is visible and recoverable,
while a leaked credential is neither, with one qualification: where the consumer has an ambient
source for the secret, as
azureaddoes forsystemtoken, the loss is a substitution ratherthan a failure; see decision 7.
TestEveryParameterIsClassifiedpins the drift down: it reads theparameter constants out of
conn_str.gowithgo/astand fails if one is not classified asemitted, carried in the URL's structure, or deliberately dropped. It also collects the names the
parser reaches for with a string literal rather than a constant,
columnencryptionis the onlyone today, and a guard that missed it would miss the kind of name it exists for.
This will make #307 fail, once it is rebased onto this change, until
vectortypesupportisclassified. That is a map entry plus a sample value in
TestClassifiedQueryParametersAreEmitted, which checks the classification against whatURL()really writes. I ran it: both failures name what to add. #307 already collideswith this change inside
URL(), so its author is in this code either way, but it is still atripwire on somebody else's branch. I can drop it if you prefer.
The decisions, in full
trustservercertificate.Config.TrustServerCertificateis not read at connect time, soserializing it is the only way setting it can reach a connection. I made writing it
asymmetric, verifying values always, trusting ones only when the connection string asked,
so a round trip can never turn verification off. This is a call I want a maintainer to
make. Details below.
epa enabled. An explicit choice travels with the URL; a valueMSSQL_USE_EPAsuppliedis left for the reading environment to supply again. Tell me if you would rather the URL
always pinned the effective value.
TestEveryParameterIsClassifiedwill make feat: add SQL Server 2025 VECTOR type support #307 fail, once it isrebased onto this change, until
vectortypesupportis classified. I can drop it or move itto a follow-up
test:PR.RootCAswith a pool they built, or whosecertificatefile has gone, gets a URL with nocertificatein it, and a reader chains to system roots. It is honest, since it statesnothing untrue about the
Config, but not safe, because the system pool acceptsmore than a private one does. The alternative is writing the stale file name, which describes
an anchor the connection has stopped using and which a reader cannot notice. I picked
honesty. The other option, or having
URL()emit somethingParserefuses so that it failsclosed, are both easy to switch to.
Parsereads the file again, so aservercertificatepin follows the file rather than the bytes captured when theConfigwasbuilt: rotate the file between
ParseandURL()and the round trip pins the newcertificate.
readCertificatetakes a.pemor.derpath and nothing else, so there is noinline form to write instead, and dropping the parameter would land on system roots, which
can accept public-CA certificates the pinned configuration would reject. Documented and
tested rather than fixed.
Configis written so thatParserefuses it. Aservercertificatepin withordinary verification turned back on accepts only a certificate that passes both the chain
check and the byte comparison. No connection string produces that, because the pin path
turns verification off, and either half alone accepts certificates the
Configrejects: the pinalone accepts the self-signed certificate a pin usually names, which the chain would refuse;
the chain alone accepts every certificate a public CA has issued for the host. So the URL
names both, and
parseTLSrejects the pair with an error naming them. It is the only outputthat accepts nothing the
Configwould not, at the cost of a DSN that does not read back.I went back and forth between the two halves before settling here, so I would rather a
maintainer chose: this, the pin alone, or the chain alone.
systemtokenis a secret and is notwritten (see below), and
azureadthen readsSYSTEM_ACCESSTOKEN, as it already does forany connection string that never named a token. So a round trip of a DSN that named token A,
in an environment holding token B, carries on with B. On-behalf-of has no such fallback and
fails naming
userassertion. Both are asserted. Whether a serializer that cannot carry anexplicit secret should let the consumer substitute an ambient one is a judgment call; the
alternative is to also drop
fedauthfor that workflow so the round trip fails.tlsminlearns one spelling, so that a version floor round-trips whether or not it has aname.
tlsminnames TLS 1.0 to 1.3; aMinVersionabove those had no spelling, and ontoday's
crypto/tlssuch a floor is aConfigno handshake can satisfy. Dropping theparameter came back as the TLS 1.2 default and rounding it down came back as TLS 1.3, either
way a
Configthat connects where the original refused. SoURL()writes such a floor asits protocol number,
tlsmin=0x0305, andParsereads0xand exactly four hex digits backas that number, only above the highest version it has a name for. That bound is the
security-relevant part:
crypto/tlskeeps TLS 1.0 and 1.1 out only whileMinVersioniszero, so a number read into the named range would give a string that meant the default
yesterday (
0x0300,0x0301) a looser meaning today. Those, and anything that is notexactly the spelling (
0x300,0x1,0X0305,12), still read as the default, and a testdrives a server that speaks nothing newer than TLS 1.1 to show none of them loosens the
floor. This is the only grammar change in this PR, and it is here because nothing else
avoids both a wider connection and an error. If you would rather
tlsminstayed names-only, the fallback is rounding down to 1.3, which I can restore anddocument as a widening.
MaxVersionandCipherSuiteshave no parameter at all and aresimply gone after a round trip; carrying them would mean new parameters, which I have not
added, and a test pins that they are lost.
Decisions 4, 5 and 6 narrow or refuse where the grammar cannot follow the
Config, and on allthree
mainsilently widened further, since it writescertificateandservercertificateinno case at all. Decision 7 is different, and the only place this PR makes something newly
reachable:
maindroppedfedauthas well, so no round trip ever reached the Azure Pipelinesworkflow, whereas preserving
fedauthwhile omitting its secret makes that workflow's existingenvironment fallback observable across a round trip for the first time. These are limits of the
grammar, and I would rather state them here than have them found in review.
The certificate rules
trustservercertificateis written against the runtime's notion of trust, not thefield's, and I want a maintainer to sanity-check that.
Config.TrustServerCertificateis not read atconnect time (
getTLSConnis), so serializing the field is the only way setting it can reacha connection, and the only standard the serializer can be held to is what the connection would
actually do. A nil
tls.Configis not "no opinion" there:getTLSConnreplaces it withSetupTLS("", "", false, …), so such aConfigverifies. A verification callback counts asverification even alongside
InsecureSkipVerify, which is the combinationSetupTLSitselfuses for a
servercertificatepin and for a common name containing a colon, and the one acaller uses for their own check.
On top of that: a verifying value is written whenever it differs from what reparsing would
produce, which closes the issue's headline case and lets a caller turn verification on; a
trusting value only when the connection string asked for it, so a round trip can never be
the thing that turns verification off.
encrypt=strictis skipped entirely, becauseparseTLSforces the value to false there whatever the parameter said.One deliberate cost: a
trustservercertificate=truesupplied next to aservercertificatepin does not survive, because the pin's callback makes the config "verifying" by the rule
above. The pin governs the handshake either way, so what the connection does is unchanged,
but it is a parameter the connection string named, so it is worth stating.
hostnameincertificateis written fromHostInCertificateProvidedalone, not fromServerNamediffering fromHost. The two also diverge when a caller movesHostand leavesthe
tls.Configalone (failoverPartnerParamsdoes this), and writing the old namethere would check the certificate of a server the connection is no longer going to, and would
come back with
HostInCertificateProvidedset, which is the flagconnect()reads to decidewhether to retarget
ServerNameafter a routing redirect. The cost is that aServerNamesetstraight onto the
tls.Configdoes not travel; setHostInCertificateProvidedalongside it ifit should, except next to a
servercertificatepin that is actually written, where theparameter is skipped because
parseTLSrejects the two together and the pin compares raw bytesrather than checking a name. It has to be a pin that is written, not one the connection string
once named: a caller who removed the pin and chose a name has a
Configthat checks that name,and the stale parameter says nothing about it (
TestConfigURLWritesTheNameChosenAfterAPinIsRemoved).certificateandservercertificatetravel while they are still true about what theconnection does with the certificate, not while they are the whole truth. A caller can add a
check no connection string can spell, and the parameter is then incomplete rather than wrong.
Dropping it for that costs more than it saves: nothing else in the grammar names a pin or a
private CA, so a URL without the parameter verifies against system roots, which accepts every
certificate a public CA has issued for the host. Trading an incomplete truth for that is a
wider connection, not a safer one.
The pin travels while a
VerifyPeerCertificateis there at all.crypto/tlscalls oneon every handshake, after any chain check and whether or not verification is on, and nothing
else on the config takes that away: a pool is read only where a chain is built, and a
VerifyConnectionis a second check rather than a replacement. Requiring the rest of the shapesetupTLSServerCertificateOnlybuilds would drop the pin in those cases, and the chain itwould be traded for is to system roots, because the pin path sets no
RootCAs.Verification turned back on beside the pin is the only shape written so that
Parserefusesit (decision 6).
crypto/tlsruns the chain first and the pin after, so thatConfigacceptsonly a certificate that passes both, and each half on its own accepts certificates it rejects.
The URL names the pin and the name the chain checks, and
parseTLSrejects the pair.TestConfigURLRefusesToWriteAChainAndAPinruns a real handshake against the pinned self-signedserver to show the two halves disagree: the
Configrefuses it, the pin alone accepts it.A root pool is different, because it is read only where a chain is built against it: with
InsecureSkipVerifyon the file names nothing the connection enforces. Where a chain is built,URL()reads the file back and compares the pool, so the parameter travels only while it stilldescribes the roots in use; a caller who swapped the pool has left it describing certificates
the connection no longer trusts. An extra callback beside the pool is not a reason to stop
believing the pool.
The exception to both is the common name path.
SetupTLSforks on the name it checksagainst, and a colon in it routes the file through
setupTLSCommonName, which builds itscallback out of that same file and turns verification off so the callback can do the checking;
there the file names the check even though no chain is built. A callback a caller swapped in
over that shape is indistinguishable from the one
SetupTLSbuilt, because Go function valuesare not comparable; that limit is noted on
URL()and pinned byTestConfigURLCannotTellACommonNameCallbackApart.Where the parameter has stopped being true there is nothing to write, and the reader chains to
system roots. Honest, but not safe. A reader can see that no certificate was named, whereas writing a
stale file name instead would state something about the
Configwith nothing to give it away.But a private pool accepts less than the system pool does, so this direction can end up
accepting a publicly issued certificate for the host that the
Configitself would reject.TestConfigURLCannotCarryAPoolTheCallerBuiltasserts that outcome, andTestConfigURLDropsACertificateItCannotReadBackchecks what the reader is left with too. This is decision 4 above.And the parameters name a file, not its contents.
Parsereads the file again, so aservercertificatepin follows the file rather than the bytessetupTLSServerCertificateOnlycaptured in its callback (those live in a closure
URL()cannot read), and there is no inlinespelling to write instead. Following the file is the narrower of the two available answers:
dropping the parameter lands on system roots, and on the ordinary reason for a file to change, a
rotated certificate, the file holds what the operator now means.
TestConfigURLNamesACertificateFileRatherThanItsContentspins it.certificatediffers onlybecause its pool can be read back out of the
tls.Configand compared. This is decision 5above.
None of that touches the rest of
carriedVerbatim.fedauth,authenticatorand thekrb5-*settings have nothing to do with TLS, and dropping one because the
tls.Configchanged wouldpick a different authentication method.
change passwordis deliberately not serialized. It is the only setting with aConfigfield that is still dropped: a login-time credential, and replaying a serialized DSN should
not reissue a password change. That leaves one row of the table unfixed by choice.
Log surface. No credential reaches the query, and
Redacted()is unchanged in what itmasks. But a serialized DSN now also carries local filesystem paths (
certificate,clientcertpath,krb5-keytabfile, ...), an Azureresource idandadditionallyallowedtenants. Infrastructure identifiers, not secrets, and dropping them breaksauthentication.
Every test
msdsn/conn_str_roundtrip_test.gois new;azuread/configuration_test.gogains two tests.Every regression case here fails against the pre-fix serializer; I checked by running the new
tests against the old
URL(). Every rule inretainedCertificateParameterAppliesand thehostnameincertificateemission has been revertedone at a time to confirm a test catches it.
TestConfigURLRoundTripPreservesSettings: one case per setting in the issue, assertingeffective configuration rather than URL spelling. The encryption cases assert
TrustServerCertificateandTLSConfig.InsecureSkipVerify, not the enum, because the enumsurvived already and the verification behavior did not.
TestConfigURLRoundTripPreservesCertificateTrust: generates a certificate at run time; thepin case calls the restored
VerifyPeerCertificate, accepting the configured certificateand rejecting another. A nil check would have passed against a pool that loaded nothing.
TestConfigURLCarriesAnExplicitEpaChoiceandTestConfigURLLeavesAnAmbientEpaValueToTheReader: the two halves of the contract above,across
MSSQL_USE_EPAcombinations at serialize and parse time, including an unreadablevalue, which is the case that otherwise produces a URL
Parserejects.TestConfigURLKeepsWorkstationIDMatchingTheLocalHost: the other ambient default.TestConfigURLLeavesAZeroValueTLSChoiceToTheReader: the harness's ownConfig, built fromHOST and DATABASE with no view on TLS, round-trips to the parser's default rather than to a
verifying DSN; a
tls.Configon the sameConfigis a view and is written. This is theAppVeyor failure.
TestConfigURLKeepsAuthenticationSettingsWhenTheTLSConfigIsCleared: the mistake that is easyto make while tightening the certificate rules. Clearing a
tls.Configsays nothing about howthe caller authenticates, and
integratedauthfalls back to its platform default withoutauthenticator.TestConfigURLDropsACertificateItCannotReadBack: the file behind the parameter is gone orholds nothing a pool will take. Each case asserts what the reader is left with, not only that
the parameter went missing.
TestConfigURLCannotCarryAPoolTheCallerBuiltandTestConfigURLNamesACertificateFileRatherThanItsContents: decisions 4 and 5, assertedexplicitly. The second rotates the certificate
behind a pin between
ParseandURL()and calls the rebuilt pin on both certificates.TestConfigURLKeepsAnAnchorItCanStillName: the three ways a caller adds a check that cannottravel, each asserting that the anchor underneath it does. One calls the restored pin on the
pinned certificate and on another; two compare the restored pool against the file.
TestConfigURLRefusesToWriteAChainAndAPin: decision 6. A real TLS handshake against thepinned self-signed server: the
Configrefuses it, the pin alone accepts it, and the URL namesboth halves so that
Parserefuses the pair.TestConfigURLWritesTheNameChosenAfterAPinIsRemoved: a staleservercertificateinParametersmust not suppress the name a caller chose after taking the pin out.TestConfigURLKeepsAPinBesideAPoolTheHandshakeIgnores: the same point for aRootCAsthehandshake never reads.
TestConfigURLDropsACertificateItNoLongerNames: the other half, a parameter that has stoppedbeing true, including a pool the caller swapped under a check of their own, which is the case
where a callback must not answer before the pool comparison runs.
TestConfigURLCarriesCertificateSettingsWithEncryptionOff: with no TLS there is notls.Configto read the settings out of, and the parsed text is the only record they have.TestConfigURLCannotTellACommonNameCallbackApart: the limit above, asserted so thatchanging it has to be deliberate.
TestFedAuthSurvivesConfigURLRoundTrip(azuread): these settings have no field onmsdsn.Config, so this is the layer where losing them matters:validateParameterssees nofedauthand falls back to SQL authentication.TestFedAuthSecretsDoNotSurviveConfigURLRoundTrip(azuread): the other side of the tradebelow, asserted per workflow:
ActiveDirectoryOnBehalfOffails naminguserassertion;ActiveDirectoryAzurePipelinesfails namingsystemtokenwhenSYSTEM_ACCESSTOKENis unset, and carries on with the environment's token when it is set(decision 7). It is there so that anyone who "fixes" the loss by copying
Parameterswholesalehas to delete a test that says why not.
TestConfigURLOmitsSecretParameters:systemtoken,clientassertion,userassertionandchange passwordreach neitherString()norRedacted(). This is the test that catchesthe copy-everything design.
TestConfigURLPreservesCertificatePolicy: the sweep, and the most useful thing here. Everyencrypt×trustservercertificate×certificate/servercertificate×hostnameincertificatecombination a connection string can carry (including a name with acolon in it, the only one that reaches
setupTLSCommonName), crossed with fifteenedits a caller can make afterwards, compared on what the connection would actually do with the
certificate: which checks run, what a chain is built against (the pool compared, not merely
whether one is set), and under which name. 3780 combinations. Every part of that is there
because a coarser version let something through: asking only "does it verify" missed system
roots turning into a private CA; ignoring the chain flag missed "chain and a pin" turning into
the pin alone, since
crypto/tlsrunsVerifyPeerCertificateonly after ordinaryverification has succeeded; treating any non-nil pool as the same pool missed a swap; leaving
colon names out of the matrix missed
setupTLSCommonNameentirely; and counting a poolthat no chain is built against made throwing a pin away for system roots look correct. Where
an edit leaves a policy no connection string produces, the sweep requires that the narrowest
anchor still nameable survives rather than waiving the comparison: a pin names one
certificate and a private pool names one CA, while a chain to system roots accepts every
certificate a public CA has issued for the host, so falling past one of those to the next
accepts more than the
Configever did. Only an anchor with no spelling at all leaves theordinary chain as the answer. I checked it catches each fix by reverting them one at a time.
TestConfigURLCarriesAVersionFloorItCannotName: decision 8. AMinVersionabove whattlsminnames fails a real handshake with "no supported versions", is written astlsmin=0x0305, comes back unchanged, and the handshake after fails for the samereason.
TestParseReadsTheVersionFloorURLWritespins the spelling's edges (exactly four hexdigits, above the highest name only), and
TestParseDoesNotReadANumberIntoTheNamedRangedrives a TLS 1.0–1.1-only server to show that
0x0300,0x0301,0x300and0x1stillnegotiate nothing below TLS 1.2, as they did before.
TestConfigURLCannotCarryACeilingOrCipherSuites: aMaxVersionand a cipher suite list aregone after a round trip, asserted explicitly.
TestConfigURLOmitsUnrepresentableValues: negative and sub-second durations includingdial timeout, packet size zero, read-only intent with the database cleared; each assertsthe parameter is absent and the URL still parses.
TestConfigURLWritesThePacketSizeTheConnectionWouldUse:Parseclamps to the TDS range, soa size outside it only reaches
URL()from a hand-builtConfig. Writing the clamped sizerather than dropping it is what keeps the round trip agreeing with a direct connection, which
clamps the same way.
TestConfigURLNeverAdvertisesTrustOnStrictEncryption: strict forces verification whateverthe parameter says, so the URL should not advertise a trust setting this driver ignores and
another client might not.
TestConfigURLPrefersEditedFields: edits after parsing, including settings the connectionstring never mentioned, a field cleared back to empty, and
TLSConfig.ServerName, whichdecides which name the certificate is checked against.
TestConfigURLOmitsUnsuppliedSettings,TestConfigURLWithNilParameters,TestConfigURLIsIdempotent,TestEveryParameterIsClassified,TestClassifiedQueryParametersAreEmitted,TestTLSVersionToString,TestTLSMinParameter,TestWholeSeconds.msdsncoverage goes from 90.9% to 95.2% andURL()from 84.6% to 98.1%, measured against aclean export of
main. Every statement added here is covered; the only uncovered statement leftin
URL()is pre-existing (the protocol-mismatchpanic), plus one deliberate gap: thecarriedVerbatimloop skips a name something above it has already written, which nothing doestoday. It is there because a duplicate key is a hard
Parsefailure rather than a merge, so theday one of those names gains a field the failure would be an unreadable DSN.
TestConfigURLNeverWritesAKeyTwiceasserts the property it protects.Validation notes
go build ./...and the unit suites (./msdsn,./internal/...,./integratedauth,./azuread) are clean.gofmt -lis clean on the five changed files. Note thatgo fmt ./...as written in
pr-workflow.instructions.mdrewrites ten unrelated committed files that have trailingwhitespace, so I ran it against the diff only.
go vet ./...reports onlyintegratedauth/winsspi/winsspi.go:24:36: possible misuse of unsafe.Pointer, which is onmainbefore this change and which.github/copilot-instructions.mdalready notes.tlsVersionToStringandhighestNamedTLSVersionare added to both build-tagged files, andTLSVersionFromStringin both learns the numeric spelling above that bound, so the!go1.12variant, which the
go 1.25floor ingo.modno longer builds, is kept in step; there thebound would be TLS 1.2, the highest name it has.