Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Diego Dupin <diego.dupin at gmail.com>
Dirkjan Bussink <d.bussink at gmail.com>
DisposaBoy <disposaboy at dby.me>
Egor Smolyakov <egorsmkv at gmail.com>
Ehsan Pourtorab <pourtorab.ehsan at gmail.com>
Erwan Martin <hello at erwan.io>
Evan Elias <evan at skeema.net>
Evan Shaw <evan at vendhq.com>
Expand Down
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,41 @@ Valid Values: true, false, skip-verify, preferred, <name>
Default: false
```

`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).
`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).

**TLS Verification Modes:**

The `tls` parameter selects which CA certificates to use:
- `tls=true`: Use system CA pool
- `tls=<name>`: Use custom registered TLS config
- `tls=skip-verify`: Accept any certificate (insecure)
- `tls=preferred`: Attempt TLS, fall back to plaintext (insecure)

The `tls-verify` parameter controls how certificates are verified (works with both `tls=true` and custom configs):
- `tls-verify=identity` (default): Verifies CA and hostname - Most secure, equivalent to MySQL's VERIFY_IDENTITY
- `tls-verify=ca`: Verifies CA only, skips hostname check - Equivalent to MySQL's VERIFY_CA mode

**Examples:**
```text
?tls=true - System CA with full verification (default behavior)
?tls=true&tls-verify=ca - System CA with CA-only verification
?tls=custom - Custom CA with full verification (default behavior)
?tls=custom&tls-verify=ca - Custom CA with CA-only verification
```

##### `tls-verify`

```text
Type: string
Valid Values: identity, ca
Default: identity
```

Controls the TLS certificate verification level. This parameter works with the `tls` parameter:
- `identity`: Full verification including hostname (default, most secure)
- `ca`: CA verification only, without hostname checking (MySQL VERIFY_CA equivalent)

This parameter only applies when `tls=true` or `tls=<custom-config>`. It has no effect with `tls=skip-verify` or `tls=preferred`.


##### `writeTimeout`
Expand Down
9 changes: 9 additions & 0 deletions driver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1520,6 +1520,15 @@ func TestTLS(t *testing.T) {
InsecureSkipVerify: true,
})
runTests(t, dsn+"&tls=custom-skip-verify", tlsTestReq)

// Test tls-verify parameter with system CA
runTests(t, dsn+"&tls=true&tls-verify=identity", tlsTestReq)

// Test tls-verify parameter with custom TLS config
RegisterTLSConfig("custom-ca-verify", &tls.Config{
InsecureSkipVerify: true,
})
runTests(t, dsn+"&tls=custom-ca-verify&tls-verify=ca", tlsTestReq)
}

func TestReuseClosedConnection(t *testing.T) {
Expand Down
31 changes: 31 additions & 0 deletions dsn.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ type Config struct {
MaxAllowedPacket int // Max packet size allowed
ServerPubKey string // Server public key name
TLSConfig string // TLS configuration name
TLSVerify string // TLS verification level: "identity" (default) or "ca"
TLS *tls.Config // TLS configuration, its priority is higher than TLSConfig
Timeout time.Duration // Dial timeout
ReadTimeout time.Duration // I/O read timeout
Comment on lines +52 to 55
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Tighten tls-verify parsing and reconcile TLSVerify defaults with DSN tests

The core wiring of TLSVerify looks good, but a few small inconsistencies are worth fixing:

  1. Error message vs. test expectation

parseDSNParams currently reports invalid values as:

return fmt.Errorf("invalid tls-verify value: %s (must be 'identity' or 'ca')", value)

while TestTLSVerifyInvalidValue looks for "invalid value for tls-verify". To keep tests and messaging consistent (and aligned with other DSN errors like the TLS config name), consider:

-			mode := strings.ToLower(value)
-			if mode != "identity" && mode != "ca" {
-				return fmt.Errorf("invalid tls-verify value: %s (must be 'identity' or 'ca')", value)
-			}
+			mode := strings.ToLower(value)
+			if mode != "identity" && mode != "ca" {
+				return fmt.Errorf("invalid value for tls-verify: %s (must be 'identity' or 'ca')", value)
+			}
  1. TLSVerify defaulting vs. existing golden Config values

normalize() now sets TLSVerify = "identity" whenever cfg.TLS == nil and TLSVerify == "", regardless of whether TLS is actually enabled:

if cfg.TLS == nil {
    if cfg.TLSVerify == "" {
        cfg.TLSVerify = "identity"
    }
    switch cfg.TLSConfig {
    case "false", "":
        // ...

This means every DSN, even ones without tls at all, ends up with TLSVerify="identity" after parsing. The new TLS tests (e.g., TestTLSVerifyBackwardsCompatibility) assume this, but the testDSNs golden Config values in dsn_test.go mostly don’t set TLSVerify, so TestDSNParser will see differences.

You likely want to either:

  • Update all testDSNs expected Config literals to include TLSVerify: "identity" where appropriate, or
  • Explicitly ignore TLSVerify in TestDSNParser (similar to zeroing cfg.TLS) if you consider it an internal default rather than part of the golden surface.
  1. Contradictory DSNs

Right now tls-verify is accepted even when tls is unset or is false/skip-verify/preferred, but it only has an effect when TLS is actually used. That’s documented, so it’s not strictly wrong, but you might consider rejecting obviously contradictory combinations (like tls-verify=ca with tls=false or missing tls) instead of silently ignoring them to avoid confusion for users debugging misconfigurations. This would be a behavior-change, so optional, but worth considering while the feature is new.

Also applies to: 198-233, 392-394, 684-691

Expand Down Expand Up @@ -195,21 +196,39 @@ func (cfg *Config) normalize() error {
}

if cfg.TLS == nil {
// Default TLSVerify to identity if not specified
if cfg.TLSVerify == "" {
cfg.TLSVerify = "identity"
}

switch cfg.TLSConfig {
case "false", "":
// don't set anything
case "true":
// Reject tls=true with tls-verify=ca since it provides minimal security
if cfg.TLSVerify == "ca" {
return errors.New("tls-verify=ca requires a custom TLS config with specific CA certificates (use tls=<config-name>); tls=true is not supported with tls-verify=ca")
}
// System CA pool with full verification (identity check)
cfg.TLS = &tls.Config{}
case "skip-verify":
cfg.TLS = &tls.Config{InsecureSkipVerify: true}
case "preferred":
cfg.TLS = &tls.Config{InsecureSkipVerify: true}
cfg.AllowFallbackToPlaintext = true
default:
// Custom registered TLS config
cfg.TLS = getTLSConfigClone(cfg.TLSConfig)
if cfg.TLS == nil {
return errors.New("invalid value / unknown config name: " + cfg.TLSConfig)
}

// Apply tls-verify to custom config
if cfg.TLSVerify == "ca" {
// Preserve all settings from custom config, only modify verification behavior
rootCAs := cfg.TLS.RootCAs
cfg.TLS = createVerifyCAConfig(cfg.TLS, rootCAs)
}
}
}

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

if cfg.TLSVerify != "" && cfg.TLSVerify != "identity" {
writeDSNParam(&buf, &hasParam, "tls-verify", cfg.TLSVerify)
}

if cfg.WriteTimeout > 0 {
writeDSNParam(&buf, &hasParam, "writeTimeout", cfg.WriteTimeout.String())
}
Expand Down Expand Up @@ -658,6 +681,14 @@ func parseDSNParams(cfg *Config, params string) (err error) {
cfg.TLSConfig = name
}

// TLS verification level
case "tls-verify":
mode := strings.ToLower(value)
if mode != "identity" && mode != "ca" {
return fmt.Errorf("invalid tls-verify value: %s (must be 'identity' or 'ca')", value)
}
cfg.TLSVerify = mode

// I/O write Timeout
case "writeTimeout":
cfg.WriteTimeout, err = time.ParseDuration(value)
Expand Down
225 changes: 225 additions & 0 deletions dsn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ package mysql

import (
"crypto/tls"
"crypto/x509"
"fmt"
"net/url"
"reflect"
"strings"
"testing"
"time"
)
Expand Down Expand Up @@ -80,6 +82,9 @@ var testDSNs = []struct {
}, {
"foo:bar@tcp(192.168.1.50:3307)/baz?timeout=10s&connectionAttributes=program_name:MySQLGoDriver%2FTest,program_version:1.2.3",
&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"},
}, {
"user:password@tcp(localhost:5555)/dbname?tls=true&tls-verify=identity",
&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"},
},
}

Expand Down Expand Up @@ -429,6 +434,226 @@ func TestNormalizeTLSConfig(t *testing.T) {
}
}

func TestTLSVerifySystemCA(t *testing.T) {
tests := []struct {
name string
dsn string
}{
{"identity with system CA (explicit)", "tcp(example.com:1234)/?tls=true&tls-verify=identity"},
{"identity with system CA (default)", "tcp(example.com:1234)/?tls=true"},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cfg, err := ParseDSN(tc.dsn)
if err != nil {
t.Error(err.Error())
}
if cfg.TLS == nil {
t.Error("cfg.TLS should not be nil")
}

// identity (default) should set ServerName
if cfg.TLS.ServerName != "example.com" {
t.Errorf("identity mode should set ServerName to 'example.com', got %q", cfg.TLS.ServerName)
}
if cfg.TLS.VerifyPeerCertificate != nil {
t.Error("identity mode should not have VerifyPeerCertificate callback set")
}
})
}
}

func TestTLSVerifyCustomConfig(t *testing.T) {
// Register a custom TLS config
customConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
ServerName: "customServer",
RootCAs: nil, // Use system CA pool for this test
}
RegisterTLSConfig("custom", customConfig)
defer DeregisterTLSConfig("custom")

tests := []struct {
name string
dsn string
}{
{"ca with custom config", "tcp(example.com:1234)/?tls=custom&tls-verify=ca"},
{"identity with custom config (explicit)", "tcp(example.com:1234)/?tls=custom&tls-verify=identity"},
{"identity with custom config (default)", "tcp(example.com:1234)/?tls=custom"},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cfg, err := ParseDSN(tc.dsn)
if err != nil {
t.Error(err.Error())
}
if cfg.TLS == nil {
t.Error("cfg.TLS should not be nil")
}

if cfg.TLSVerify == "ca" {
if !cfg.TLS.InsecureSkipVerify {
t.Error("ca mode should have InsecureSkipVerify=true")
}
if cfg.TLS.VerifyPeerCertificate == nil {
t.Error("ca mode should have VerifyPeerCertificate callback set")
}
// ca mode should preserve custom config's ServerName for SNI
if cfg.TLS.ServerName != "customServer" {
t.Errorf("ca mode should preserve custom ServerName 'customServer', got %q", cfg.TLS.ServerName)
}
} else {
// identity (default) should preserve custom config's ServerName
if cfg.TLS.ServerName != "customServer" {
t.Errorf("identity mode should preserve custom ServerName 'customServer', got %q", cfg.TLS.ServerName)
}
if cfg.TLS.VerifyPeerCertificate != nil {
t.Error("identity mode should not have VerifyPeerCertificate callback set")
}
}
})
}
}

func TestTLSVerifyBackwardsCompatibility(t *testing.T) {
tests := []struct {
name string
dsn string
expectTLSVerify string
expectServerName string
}{
{"tls=true defaults to identity", "tcp(example.com:1234)/?tls=true", "identity", "example.com"},
{"tls=false no TLS", "tcp(example.com:1234)/?tls=false", "identity", ""},
{"tls=skip-verify unchanged", "tcp(example.com:1234)/?tls=skip-verify", "identity", ""},
{"tls=preferred unchanged", "tcp(example.com:1234)/?tls=preferred", "identity", ""},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cfg, err := ParseDSN(tc.dsn)
if err != nil {
t.Error(err.Error())
}

if cfg.TLSVerify != tc.expectTLSVerify {
t.Errorf("expected TLSVerify=%q, got %q", tc.expectTLSVerify, cfg.TLSVerify)
}

if tc.expectServerName == "" {
if cfg.TLS == nil {
return // Expected no TLS
}
if cfg.TLS.ServerName != "" {
t.Errorf("expected no ServerName, got %q", cfg.TLS.ServerName)
}
} else {
if cfg.TLS == nil {
t.Error("expected TLS config but got nil")
return
}
if cfg.TLS.ServerName != tc.expectServerName {
t.Errorf("expected ServerName=%q, got %q", tc.expectServerName, cfg.TLS.ServerName)
}
}
})
}
}

func TestTLSVerifyInvalidValue(t *testing.T) {
dsn := "tcp(example.com:1234)/?tls=true&tls-verify=invalid"
_, err := ParseDSN(dsn)
if err == nil {
t.Error("expected error for invalid tls-verify value")
}
expectedMsg := "invalid value for tls-verify"
if err != nil && !strings.Contains(err.Error(), expectedMsg) {
t.Errorf("error message should contain %q, got: %v", expectedMsg, err)
}
}

func TestTLSTrueWithVerifyCAIsRejected(t *testing.T) {
dsn := "tcp(example.com:1234)/?tls=true&tls-verify=ca"
_, err := ParseDSN(dsn)
if err == nil {
t.Error("expected error for tls=true with tls-verify=ca")
}
expectedMsg := "tls-verify=ca requires a custom TLS config"
if err != nil && !strings.Contains(err.Error(), expectedMsg) {
t.Errorf("error message should contain %q, got: %v", expectedMsg, err)
}
}

func TestTLSVerifyPreservesCustomConfig(t *testing.T) {
// Register a custom TLS config with various settings
customConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
MaxVersion: tls.VersionTLS13,
ServerName: "customServer",
CipherSuites: []uint16{tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256},
NextProtos: []string{"h2", "http/1.1"},
RootCAs: x509.NewCertPool(),
}
RegisterTLSConfig("custom-full", customConfig)
defer DeregisterTLSConfig("custom-full")

dsn := "tcp(example.com:1234)/?tls=custom-full&tls-verify=ca"
cfg, err := ParseDSN(dsn)
if err != nil {
t.Fatal(err)
}

if cfg.TLS == nil {
t.Fatal("cfg.TLS should not be nil")
}

// Verify VERIFY_CA mode is enabled
if !cfg.TLS.InsecureSkipVerify {
t.Error("ca mode should have InsecureSkipVerify=true")
}
if cfg.TLS.VerifyPeerCertificate == nil {
t.Error("ca mode should have VerifyPeerCertificate callback set")
}

// Verify all custom settings are preserved
if cfg.TLS.MinVersion != tls.VersionTLS12 {
t.Errorf("MinVersion not preserved: got %v, want %v", cfg.TLS.MinVersion, tls.VersionTLS12)
}
if cfg.TLS.MaxVersion != tls.VersionTLS13 {
t.Errorf("MaxVersion not preserved: got %v, want %v", cfg.TLS.MaxVersion, tls.VersionTLS13)
}
if cfg.TLS.ServerName != "customServer" {
t.Errorf("ServerName not preserved: got %q, want 'customServer'", cfg.TLS.ServerName)
}
if len(cfg.TLS.CipherSuites) != 1 || cfg.TLS.CipherSuites[0] != tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 {
t.Error("CipherSuites not preserved")
}
if len(cfg.TLS.NextProtos) != 2 || cfg.TLS.NextProtos[0] != "h2" || cfg.TLS.NextProtos[1] != "http/1.1" {
t.Error("NextProtos not preserved")
}
if cfg.TLS.RootCAs == nil {
t.Error("RootCAs not preserved")
}
}

func TestRegisterTLSConfigReservedKey(t *testing.T) {
reservedKeys := []string{
"true", "True", "TRUE",
"false", "False", "FALSE",
"skip-verify", "Skip-Verify", "SKIP-VERIFY",
"preferred", "Preferred", "PREFERRED",
}

for _, key := range reservedKeys {
err := RegisterTLSConfig(key, &tls.Config{})
if err == nil {
t.Errorf("RegisterTLSConfig should reject reserved key %q", key)
}
DeregisterTLSConfig(key) // Clean up in case it was registered
}
}

func BenchmarkParseDSN(b *testing.B) {
b.ReportAllocs()

Expand Down
Loading