Skip to content

Commit e612245

Browse files
Add support for MySQL VERIFY_CA SSL mode via tls-verify parameter
Implements VERIFY_CA SSL mode to verify certificate authority without hostname verification, matching MySQL client's --ssl-mode=VERIFY_CA. This addresses a long-standing limitation where users needed TLS with CA verification but couldn't use hostname verification due to: - Connecting via IP addresses instead of hostnames - Dynamic IPs or load-balanced MySQL instances - Certificates with SANs that don't match connection strings - Multiple hostnames for the same MySQL instance Adds new DSN parameter with two values: - identity: Full verification (CA + hostname) - default - ca: CA verification only (no hostname check) Works with both system CA pool and custom registered TLS configs: - ?tls=true&tls-verify=ca (system CA) - ?tls=custom&tls-verify=ca (custom CA) This is particularly important for users migrating to MySQL 8.0's caching_sha2_password authentication, which requires encrypted connections by default, making TLS support more critical. Implementation follows Go team's recommended pattern from golang/go issues #21971, #31791, #31792, #35467: using InsecureSkipVerify with custom VerifyPeerCertificate callback that performs CA validation via x509.Certificate.Verify() without hostname checking. Related: #899 See also: golang/go#31792, golang/go#24151, golang/go#21971, golang/go#28754, golang/go#31791, golang/go#35467
1 parent 76c00e3 commit e612245

File tree

7 files changed

+571
-2
lines changed

7 files changed

+571
-2
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ Diego Dupin <diego.dupin at gmail.com>
4343
Dirkjan Bussink <d.bussink at gmail.com>
4444
DisposaBoy <disposaboy at dby.me>
4545
Egor Smolyakov <egorsmkv at gmail.com>
46+
Ehsan Pourtorab <pourtorab.ehsan at gmail.com>
4647
Erwan Martin <hello at erwan.io>
4748
Evan Elias <evan at skeema.net>
4849
Evan Shaw <evan at vendhq.com>

README.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,41 @@ Valid Values: true, false, skip-verify, preferred, <name>
434434
Default: false
435435
```
436436

437-
`tls=true` enables TLS / SSL encrypted connection to the server. Use `skip-verify` if you want to use a self-signed or invalid certificate (server side) or use `preferred` to use TLS only when advertised by the server. This is similar to `skip-verify`, but additionally allows a fallback to a connection which is not encrypted. Neither `skip-verify` nor `preferred` add any reliable security. You can use a custom TLS config after registering it with [`mysql.RegisterTLSConfig`](https://godoc.org/github.com/go-sql-driver/mysql#RegisterTLSConfig).
437+
`tls=true` enables TLS / SSL encrypted connection to the server with full certificate verification (including hostname). Use `skip-verify` if you want to use a self-signed or invalid certificate (server-side) or use `preferred` to use TLS only when advertised by the server. This is similar to `skip-verify`, but additionally allows a fallback to a connection which is not encrypted. Neither `skip-verify` nor `preferred` add any reliable security. You can use a custom TLS config after registering it with [`mysql.RegisterTLSConfig`](https://godoc.org/github.com/go-sql-driver/mysql#RegisterTLSConfig).
438+
439+
**TLS Verification Modes:**
440+
441+
The `tls` parameter selects which CA certificates to use:
442+
- `tls=true`: Use system CA pool
443+
- `tls=<name>`: Use custom registered TLS config
444+
- `tls=skip-verify`: Accept any certificate (insecure)
445+
- `tls=preferred`: Attempt TLS, fall back to plaintext (insecure)
446+
447+
The `tls-verify` parameter controls how certificates are verified (works with both `tls=true` and custom configs):
448+
- `tls-verify=identity` (default): Verifies CA and hostname - Most secure, equivalent to MySQL's VERIFY_IDENTITY
449+
- `tls-verify=ca`: Verifies CA only, skips hostname check - Equivalent to MySQL's VERIFY_CA mode
450+
451+
**Examples:**
452+
```text
453+
?tls=true - System CA with full verification (default behavior)
454+
?tls=true&tls-verify=ca - System CA with CA-only verification
455+
?tls=custom - Custom CA with full verification (default behavior)
456+
?tls=custom&tls-verify=ca - Custom CA with CA-only verification
457+
```
458+
459+
##### `tls-verify`
460+
461+
```text
462+
Type: string
463+
Valid Values: identity, ca
464+
Default: identity
465+
```
466+
467+
Controls the TLS certificate verification level. This parameter works with the `tls` parameter:
468+
- `identity`: Full verification including hostname (default, most secure)
469+
- `ca`: CA verification only, without hostname checking (MySQL VERIFY_CA equivalent)
470+
471+
This parameter only applies when `tls=true` or `tls=<custom-config>`. It has no effect with `tls=skip-verify` or `tls=preferred`.
438472

439473

440474
##### `writeTimeout`

driver_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1520,6 +1520,16 @@ func TestTLS(t *testing.T) {
15201520
InsecureSkipVerify: true,
15211521
})
15221522
runTests(t, dsn+"&tls=custom-skip-verify", tlsTestReq)
1523+
1524+
// Test tls-verify parameter with system CA
1525+
runTests(t, dsn+"&tls=true&tls-verify=ca", tlsTestReq)
1526+
runTests(t, dsn+"&tls=true&tls-verify=identity", tlsTestReq)
1527+
1528+
// Test tls-verify parameter with custom TLS config
1529+
RegisterTLSConfig("custom-ca-verify", &tls.Config{
1530+
InsecureSkipVerify: true,
1531+
})
1532+
runTests(t, dsn+"&tls=custom-ca-verify&tls-verify=ca", tlsTestReq)
15231533
}
15241534

15251535
func TestReuseClosedConnection(t *testing.T) {

dsn.go

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ type Config struct {
4949
MaxAllowedPacket int // Max packet size allowed
5050
ServerPubKey string // Server public key name
5151
TLSConfig string // TLS configuration name
52+
TLSVerify string // TLS verification level: "identity" (default) or "ca"
5253
TLS *tls.Config // TLS configuration, its priority is higher than TLSConfig
5354
Timeout time.Duration // Dial timeout
5455
ReadTimeout time.Duration // I/O read timeout
@@ -195,21 +196,39 @@ func (cfg *Config) normalize() error {
195196
}
196197

197198
if cfg.TLS == nil {
199+
// Default TLSVerify to identity if not specified
200+
if cfg.TLSVerify == "" {
201+
cfg.TLSVerify = "identity"
202+
}
203+
198204
switch cfg.TLSConfig {
199205
case "false", "":
200206
// don't set anything
201207
case "true":
202-
cfg.TLS = &tls.Config{}
208+
// System CA pool
209+
if cfg.TLSVerify == "ca" {
210+
cfg.TLS = createVerifyCAConfig(nil, nil)
211+
} else {
212+
cfg.TLS = &tls.Config{}
213+
}
203214
case "skip-verify":
204215
cfg.TLS = &tls.Config{InsecureSkipVerify: true}
205216
case "preferred":
206217
cfg.TLS = &tls.Config{InsecureSkipVerify: true}
207218
cfg.AllowFallbackToPlaintext = true
208219
default:
220+
// Custom registered TLS config
209221
cfg.TLS = getTLSConfigClone(cfg.TLSConfig)
210222
if cfg.TLS == nil {
211223
return errors.New("invalid value / unknown config name: " + cfg.TLSConfig)
212224
}
225+
226+
// Apply tls-verify to custom config
227+
if cfg.TLSVerify == "ca" {
228+
// Preserve all settings from custom config, only modify verification behavior
229+
rootCAs := cfg.TLS.RootCAs
230+
cfg.TLS = createVerifyCAConfig(cfg.TLS, rootCAs)
231+
}
213232
}
214233
}
215234

@@ -370,6 +389,10 @@ func (cfg *Config) FormatDSN() string {
370389
writeDSNParam(&buf, &hasParam, "tls", url.QueryEscape(cfg.TLSConfig))
371390
}
372391

392+
if len(cfg.TLSVerify) > 0 && cfg.TLSVerify != "identity" {
393+
writeDSNParam(&buf, &hasParam, "tls-verify", cfg.TLSVerify)
394+
}
395+
373396
if cfg.WriteTimeout > 0 {
374397
writeDSNParam(&buf, &hasParam, "writeTimeout", cfg.WriteTimeout.String())
375398
}
@@ -658,6 +681,14 @@ func parseDSNParams(cfg *Config, params string) (err error) {
658681
cfg.TLSConfig = name
659682
}
660683

684+
// TLS verification level
685+
case "tls-verify":
686+
mode := strings.ToLower(value)
687+
if mode != "identity" && mode != "ca" {
688+
return fmt.Errorf("invalid tls-verify value: %s (must be 'identity' or 'ca')", value)
689+
}
690+
cfg.TLSVerify = mode
691+
661692
// I/O write Timeout
662693
case "writeTimeout":
663694
cfg.WriteTimeout, err = time.ParseDuration(value)

dsn_test.go

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ package mysql
1010

1111
import (
1212
"crypto/tls"
13+
"crypto/x509"
1314
"fmt"
1415
"net/url"
1516
"reflect"
17+
"strings"
1618
"testing"
1719
"time"
1820
)
@@ -80,6 +82,12 @@ var testDSNs = []struct {
8082
}, {
8183
"foo:bar@tcp(192.168.1.50:3307)/baz?timeout=10s&connectionAttributes=program_name:MySQLGoDriver%2FTest,program_version:1.2.3",
8284
&Config{User: "foo", Passwd: "bar", Net: "tcp", Addr: "192.168.1.50:3307", DBName: "baz", Loc: time.UTC, Timeout: 10 * time.Second, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, ConnectionAttributes: "program_name:MySQLGoDriver/Test,program_version:1.2.3"},
85+
}, {
86+
"user:password@tcp(localhost:5555)/dbname?tls=true&tls-verify=ca",
87+
&Config{User: "user", Passwd: "password", Net: "tcp", Addr: "localhost:5555", DBName: "dbname", Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, TLSConfig: "true", TLSVerify: "ca"},
88+
}, {
89+
"user:password@tcp(localhost:5555)/dbname?tls=true&tls-verify=identity",
90+
&Config{User: "user", Passwd: "password", Net: "tcp", Addr: "localhost:5555", DBName: "dbname", Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, TLSConfig: "true", TLSVerify: "identity"},
8391
},
8492
}
8593

@@ -429,6 +437,229 @@ func TestNormalizeTLSConfig(t *testing.T) {
429437
}
430438
}
431439

440+
func TestTLSVerifySystemCA(t *testing.T) {
441+
tests := []struct {
442+
name string
443+
dsn string
444+
}{
445+
{"ca with system CA", "tcp(example.com:1234)/?tls=true&tls-verify=ca"},
446+
{"identity with system CA (explicit)", "tcp(example.com:1234)/?tls=true&tls-verify=identity"},
447+
{"identity with system CA (default)", "tcp(example.com:1234)/?tls=true"},
448+
}
449+
450+
for _, tc := range tests {
451+
t.Run(tc.name, func(t *testing.T) {
452+
cfg, err := ParseDSN(tc.dsn)
453+
if err != nil {
454+
t.Error(err.Error())
455+
}
456+
if cfg.TLS == nil {
457+
t.Error("cfg.TLS should not be nil")
458+
}
459+
460+
if cfg.TLSVerify == "ca" {
461+
if !cfg.TLS.InsecureSkipVerify {
462+
t.Error("ca mode should have InsecureSkipVerify=true")
463+
}
464+
if cfg.TLS.VerifyPeerCertificate == nil {
465+
t.Error("ca mode should have VerifyPeerCertificate callback set")
466+
}
467+
// ca mode does not auto-set ServerName (hostname verification is skipped)
468+
// ServerName remains empty unless explicitly set
469+
if cfg.TLS.ServerName != "" {
470+
t.Errorf("ca mode with system CA should not have ServerName set, got %q", cfg.TLS.ServerName)
471+
}
472+
} else {
473+
// identity (default) should set ServerName
474+
if cfg.TLS.ServerName != "example.com" {
475+
t.Errorf("identity mode should set ServerName to 'example.com', got %q", cfg.TLS.ServerName)
476+
}
477+
if cfg.TLS.VerifyPeerCertificate != nil {
478+
t.Error("identity mode should not have VerifyPeerCertificate callback set")
479+
}
480+
}
481+
})
482+
}
483+
}
484+
485+
func TestTLSVerifyCustomConfig(t *testing.T) {
486+
// Register a custom TLS config
487+
customConfig := &tls.Config{
488+
MinVersion: tls.VersionTLS12,
489+
ServerName: "customServer",
490+
RootCAs: nil, // Use system CA pool for this test
491+
}
492+
RegisterTLSConfig("custom", customConfig)
493+
defer DeregisterTLSConfig("custom")
494+
495+
tests := []struct {
496+
name string
497+
dsn string
498+
}{
499+
{"ca with custom config", "tcp(example.com:1234)/?tls=custom&tls-verify=ca"},
500+
{"identity with custom config (explicit)", "tcp(example.com:1234)/?tls=custom&tls-verify=identity"},
501+
{"identity with custom config (default)", "tcp(example.com:1234)/?tls=custom"},
502+
}
503+
504+
for _, tc := range tests {
505+
t.Run(tc.name, func(t *testing.T) {
506+
cfg, err := ParseDSN(tc.dsn)
507+
if err != nil {
508+
t.Error(err.Error())
509+
}
510+
if cfg.TLS == nil {
511+
t.Error("cfg.TLS should not be nil")
512+
}
513+
514+
if cfg.TLSVerify == "ca" {
515+
if !cfg.TLS.InsecureSkipVerify {
516+
t.Error("ca mode should have InsecureSkipVerify=true")
517+
}
518+
if cfg.TLS.VerifyPeerCertificate == nil {
519+
t.Error("ca mode should have VerifyPeerCertificate callback set")
520+
}
521+
// ca mode should preserve custom config's ServerName for SNI
522+
if cfg.TLS.ServerName != "customServer" {
523+
t.Errorf("ca mode should preserve custom ServerName 'customServer', got %q", cfg.TLS.ServerName)
524+
}
525+
} else {
526+
// identity (default) should preserve custom config's ServerName
527+
if cfg.TLS.ServerName != "customServer" {
528+
t.Errorf("identity mode should preserve custom ServerName 'customServer', got %q", cfg.TLS.ServerName)
529+
}
530+
if cfg.TLS.VerifyPeerCertificate != nil {
531+
t.Error("identity mode should not have VerifyPeerCertificate callback set")
532+
}
533+
}
534+
})
535+
}
536+
}
537+
538+
func TestTLSVerifyBackwardsCompatibility(t *testing.T) {
539+
tests := []struct {
540+
name string
541+
dsn string
542+
expectTLSVerify string
543+
expectServerName string
544+
}{
545+
{"tls=true defaults to identity", "tcp(example.com:1234)/?tls=true", "identity", "example.com"},
546+
{"tls=false no TLS", "tcp(example.com:1234)/?tls=false", "identity", ""},
547+
{"tls=skip-verify unchanged", "tcp(example.com:1234)/?tls=skip-verify", "identity", ""},
548+
{"tls=preferred unchanged", "tcp(example.com:1234)/?tls=preferred", "identity", ""},
549+
}
550+
551+
for _, tc := range tests {
552+
t.Run(tc.name, func(t *testing.T) {
553+
cfg, err := ParseDSN(tc.dsn)
554+
if err != nil {
555+
t.Error(err.Error())
556+
}
557+
558+
if cfg.TLSVerify != tc.expectTLSVerify {
559+
t.Errorf("expected TLSVerify=%q, got %q", tc.expectTLSVerify, cfg.TLSVerify)
560+
}
561+
562+
if tc.expectServerName == "" {
563+
if cfg.TLS == nil {
564+
return // Expected no TLS
565+
}
566+
if cfg.TLS.ServerName != "" {
567+
t.Errorf("expected no ServerName, got %q", cfg.TLS.ServerName)
568+
}
569+
} else {
570+
if cfg.TLS == nil {
571+
t.Error("expected TLS config but got nil")
572+
return
573+
}
574+
if cfg.TLS.ServerName != tc.expectServerName {
575+
t.Errorf("expected ServerName=%q, got %q", tc.expectServerName, cfg.TLS.ServerName)
576+
}
577+
}
578+
})
579+
}
580+
}
581+
582+
func TestTLSVerifyInvalidValue(t *testing.T) {
583+
dsn := "tcp(example.com:1234)/?tls=true&tls-verify=invalid"
584+
_, err := ParseDSN(dsn)
585+
if err == nil {
586+
t.Error("expected error for invalid tls-verify value")
587+
}
588+
expectedMsg := "invalid value for tls-verify"
589+
if err != nil && !strings.Contains(err.Error(), expectedMsg) {
590+
t.Errorf("error message should contain %q, got: %v", expectedMsg, err)
591+
}
592+
}
593+
594+
func TestTLSVerifyPreservesCustomConfig(t *testing.T) {
595+
// Register a custom TLS config with various settings
596+
customConfig := &tls.Config{
597+
MinVersion: tls.VersionTLS12,
598+
MaxVersion: tls.VersionTLS13,
599+
ServerName: "customServer",
600+
CipherSuites: []uint16{tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256},
601+
NextProtos: []string{"h2", "http/1.1"},
602+
RootCAs: x509.NewCertPool(),
603+
}
604+
RegisterTLSConfig("custom-full", customConfig)
605+
defer DeregisterTLSConfig("custom-full")
606+
607+
dsn := "tcp(example.com:1234)/?tls=custom-full&tls-verify=ca"
608+
cfg, err := ParseDSN(dsn)
609+
if err != nil {
610+
t.Fatal(err)
611+
}
612+
613+
if cfg.TLS == nil {
614+
t.Fatal("cfg.TLS should not be nil")
615+
}
616+
617+
// Verify VERIFY_CA mode is enabled
618+
if !cfg.TLS.InsecureSkipVerify {
619+
t.Error("ca mode should have InsecureSkipVerify=true")
620+
}
621+
if cfg.TLS.VerifyPeerCertificate == nil {
622+
t.Error("ca mode should have VerifyPeerCertificate callback set")
623+
}
624+
625+
// Verify all custom settings are preserved
626+
if cfg.TLS.MinVersion != tls.VersionTLS12 {
627+
t.Errorf("MinVersion not preserved: got %v, want %v", cfg.TLS.MinVersion, tls.VersionTLS12)
628+
}
629+
if cfg.TLS.MaxVersion != tls.VersionTLS13 {
630+
t.Errorf("MaxVersion not preserved: got %v, want %v", cfg.TLS.MaxVersion, tls.VersionTLS13)
631+
}
632+
if cfg.TLS.ServerName != "customServer" {
633+
t.Errorf("ServerName not preserved: got %q, want 'customServer'", cfg.TLS.ServerName)
634+
}
635+
if len(cfg.TLS.CipherSuites) != 1 || cfg.TLS.CipherSuites[0] != tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 {
636+
t.Error("CipherSuites not preserved")
637+
}
638+
if len(cfg.TLS.NextProtos) != 2 || cfg.TLS.NextProtos[0] != "h2" || cfg.TLS.NextProtos[1] != "http/1.1" {
639+
t.Error("NextProtos not preserved")
640+
}
641+
if cfg.TLS.RootCAs == nil {
642+
t.Error("RootCAs not preserved")
643+
}
644+
}
645+
646+
func TestRegisterTLSConfigReservedKey(t *testing.T) {
647+
reservedKeys := []string{
648+
"true", "True", "TRUE",
649+
"false", "False", "FALSE",
650+
"skip-verify", "Skip-Verify", "SKIP-VERIFY",
651+
"preferred", "Preferred", "PREFERRED",
652+
}
653+
654+
for _, key := range reservedKeys {
655+
err := RegisterTLSConfig(key, &tls.Config{})
656+
if err == nil {
657+
t.Errorf("RegisterTLSConfig should reject reserved key %q", key)
658+
}
659+
DeregisterTLSConfig(key) // Clean up in case it was registered
660+
}
661+
}
662+
432663
func BenchmarkParseDSN(b *testing.B) {
433664
b.ReportAllocs()
434665

0 commit comments

Comments
 (0)