diff --git a/AUTHORS b/AUTHORS index 37c96208a..0506078c3 100644 --- a/AUTHORS +++ b/AUTHORS @@ -156,5 +156,6 @@ PingCAP Inc. Pivotal Inc. Shattered Silicon Ltd. Stripe Inc. +Team Humaki LLC ThousandEyes Zendesk Inc. diff --git a/README.md b/README.md index abef7e808..f9675b98e 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,8 @@ Alternatively, [Config.FormatDSN](https://godoc.org/github.com/go-sql-driver/mys #### Password Passwords can consist of any character. Escaping is **not** necessary. +Usernames that contain `:` (the user/password separator) must percent-encode it as `%3A`, for example `user%3Aname:password@protocol(address)/dbname`. Prefer [NewConfig](https://pkg.go.dev/github.com/go-sql-driver/mysql#NewConfig) / [NewConnector](https://pkg.go.dev/github.com/go-sql-driver/mysql#NewConnector) when credentials can hold reserved DSN characters. + #### Protocol See [net.Dial](https://golang.org/pkg/net/#Dial) for more information which networks are available. In general you should use a Unix domain socket if available and TCP otherwise for best performance. diff --git a/dsn.go b/dsn.go index 0bbb2ea9a..0293e50c3 100644 --- a/dsn.go +++ b/dsn.go @@ -294,10 +294,10 @@ func (cfg *Config) FormatDSN() string { // [username[:password]@] if len(cfg.User) > 0 { - buf.WriteString(cfg.User) + buf.WriteString(escapeUserinfo(cfg.User)) if len(cfg.Passwd) > 0 { buf.WriteByte(':') - buf.WriteString(cfg.Passwd) + buf.WriteString(escapeUserinfo(cfg.Passwd)) } buf.WriteByte('@') } @@ -483,11 +483,11 @@ func ParseDSN(dsn string) (cfg *Config, err error) { // Find the first ':' in dsn[:j] for k = 0; k < j; k++ { // We cannot use k = range j here, because we use dsn[:k] below if dsn[k] == ':' { - cfg.Passwd = dsn[k+1 : j] + cfg.Passwd = unescapeUserinfo(dsn[k+1 : j]) break } } - cfg.User = dsn[:k] + cfg.User = unescapeUserinfo(dsn[:k]) break } @@ -541,6 +541,41 @@ func ParseDSN(dsn string) (cfg *Config, err error) { return } +// unescapeUserinfo percent-decodes a DSN username or password. Invalid +// escapes are left as-is so a literal '%' in a credential still parses. +func unescapeUserinfo(s string) string { + u, err := url.PathUnescape(s) + if err != nil { + return s + } + return u +} + +// escapeUserinfo percent-encodes DSN delimiters so FormatDSN round-trips +// credentials that contain ':', '@', or '/'. +func escapeUserinfo(s string) string { + if !strings.ContainsAny(s, "%:@/") { + return s + } + var b strings.Builder + b.Grow(len(s) + 4) + for i := 0; i < len(s); i++ { + switch s[i] { + case '%': + b.WriteString("%25") + case ':': + b.WriteString("%3A") + case '@': + b.WriteString("%40") + case '/': + b.WriteString("%2F") + default: + b.WriteByte(s[i]) + } + } + return b.String() +} + // parseDSNParams parses the DSN "query string" // Values must be url.QueryEscape'ed func parseDSNParams(cfg *Config, params string) (err error) { diff --git a/dsn_test.go b/dsn_test.go index 120550cf4..4fb3a38a2 100644 --- a/dsn_test.go +++ b/dsn_test.go @@ -53,6 +53,17 @@ var testDSNs = []struct { cfg.paramOrder = []string{"param"} }), }, + { + // percent-encoded ':' in the username (#1747) + in: "user%3Aname:p%40ss@protocol(address)/dbname", + out: newTestConfig(func(cfg *Config) { + cfg.User = "user:name" + cfg.Passwd = "p@ss" + cfg.Net = "protocol" + cfg.Addr = "address" + cfg.DBName = "dbname" + }), + }, { in: "username:password@protocol(address)/dbname?param=value&columnsWithAlias=true", out: newTestConfig(func(cfg *Config) { @@ -310,6 +321,37 @@ func TestDSNReformat(t *testing.T) { } } +func TestParseDSNUsernameColon(t *testing.T) { + cfg, err := ParseDSN("user%3Aname:p%40ss@tcp(localhost:3306)/dbname") + if err != nil { + t.Fatal(err) + } + if cfg.User != "user:name" { + t.Errorf("User = %q, want %q", cfg.User, "user:name") + } + if cfg.Passwd != "p@ss" { + t.Errorf("Passwd = %q, want %q", cfg.Passwd, "p@ss") + } + + got := cfg.FormatDSN() + cfg2, err := ParseDSN(got) + if err != nil { + t.Fatalf("FormatDSN %q: %v", got, err) + } + if cfg2.User != cfg.User || cfg2.Passwd != cfg.Passwd { + t.Errorf("round-trip User/Passwd = %q/%q, want %q/%q", cfg2.User, cfg2.Passwd, cfg.User, cfg.Passwd) + } + + // Unencoded colon in the username still splits as user:password (compat). + cfg3, err := ParseDSN("user:name@tcp(localhost:3306)/dbname") + if err != nil { + t.Fatal(err) + } + if cfg3.User != "user" || cfg3.Passwd != "name" { + t.Errorf("compat User/Passwd = %q/%q, want user/name", cfg3.User, cfg3.Passwd) + } +} + func TestDSNServerPubKey(t *testing.T) { baseDSN := "User:password@tcp(localhost:5555)/dbname?serverPubKey="