Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
44 changes: 43 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,49 @@ 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:
- `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

**IMPORTANT:** The `tls-verify=ca` parameter **only works with custom TLS configs**, not with `tls=true` (system CAs). The combination `tls=true&tls-verify=ca` is explicitly rejected because it provides minimal security benefit - attackers can obtain valid certificates from any public CA, making CA-only verification ineffective. This matches MySQL CLI behavior, which requires `--ssl-ca` or `--ssl-capath` when using VERIFY_CA mode.

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

##### `tls-verify`

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

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

**IMPORTANT:** The `tls-verify=ca` option **only works with custom TLS configs** (e.g., `tls=<custom-config>`), not with `tls=true`.

Use `tls-verify=ca` when:
- You have a private CA with specific trusted certificates
- Connecting to servers via IP addresses or dynamic hostnames
- Working in environments where certificates don't include matching hostname/IP SANs

This parameter 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