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
32 changes: 24 additions & 8 deletions pkg/client/columns.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,19 @@ func (c *Client) ListColumns(ctx context.Context, parentResourceID *v2.ResourceI

// If the privilege is "grant", it grants SELECT, INSERT, UPDATE, and REFERENCES privileges.
func (c *Client) GrantColumnPrivilege(ctx context.Context, table string, column string, user string, privilege string) error {
userSplit := strings.Split(user, "@")
if len(userSplit) != 2 {
return fmt.Errorf("invalid user format: %s", user)
userName, host, err := SplitUserHost(user)
if err != nil {
return fmt.Errorf("invalid user format: %s: %w", user, err)
}
userEsc, err := escapeMySQLUserHost(userName)
if err != nil {
return err
}
hostEsc, err := escapeMySQLUserHost(host)
if err != nil {
return err
}
userGrant := fmt.Sprintf("%s'@'%s", userSplit[0], userSplit[1])
userGrant := fmt.Sprintf("%s'@'%s", userEsc, hostEsc)

var privileges []string
if strings.ToLower(privilege) == "grant" {
Expand Down Expand Up @@ -120,11 +128,19 @@ func (c *Client) GrantColumnPrivilege(ctx context.Context, table string, column
}

func (c *Client) RevokeColumnPrivilege(ctx context.Context, table string, column string, user string, privilege string) error {
userSplit := strings.Split(user, "@")
if len(userSplit) != 2 {
return fmt.Errorf("invalid user format: %s", user)
userName, host, err := SplitUserHost(user)
if err != nil {
return fmt.Errorf("invalid user format: %s: %w", user, err)
}
userEsc, err := escapeMySQLUserHost(userName)
if err != nil {
return err
}
hostEsc, err := escapeMySQLUserHost(host)
if err != nil {
return err
}
userRevoke := fmt.Sprintf("%s'@'%s", userSplit[0], userSplit[1])
userRevoke := fmt.Sprintf("%s'@'%s", userEsc, hostEsc)

var privileges []string
if strings.ToLower(privilege) == "grant" {
Expand Down
20 changes: 10 additions & 10 deletions pkg/client/databases.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,15 @@ func (c *Client) ListDatabases(ctx context.Context, pager *Pager) ([]*DbModel, s
}

func (c *Client) GrantDatabasePrivilege(ctx context.Context, database string, user string, privilege string) error {
userSplit := strings.Split(user, "@")
if len(userSplit) != 2 {
return fmt.Errorf("invalid user format, expected user@host")
userName, host, err := SplitUserHost(user)
if err != nil {
return fmt.Errorf("invalid user format, expected user@host: %w", err)
}
userEsc, err := escapeMySQLUserHost(userSplit[0])
userEsc, err := escapeMySQLUserHost(userName)
if err != nil {
return err
}
hostEsc, err := escapeMySQLUserHost(userSplit[1])
hostEsc, err := escapeMySQLUserHost(host)
if err != nil {
return err
}
Expand All @@ -99,15 +99,15 @@ func (c *Client) GrantDatabasePrivilege(ctx context.Context, database string, us
}

func (c *Client) RevokeDatabasePrivilege(ctx context.Context, database string, user string, privilege string) error {
userSplit := strings.Split(user, "@")
if len(userSplit) != 2 {
return fmt.Errorf("invalid user format, expected user@host")
userName, host, err := SplitUserHost(user)
if err != nil {
return fmt.Errorf("invalid user format, expected user@host: %w", err)
}
userEsc, err := escapeMySQLUserHost(userSplit[0])
userEsc, err := escapeMySQLUserHost(userName)
if err != nil {
return err
}
hostEsc, err := escapeMySQLUserHost(userSplit[1])
hostEsc, err := escapeMySQLUserHost(host)
if err != nil {
return err
}
Expand Down
23 changes: 21 additions & 2 deletions pkg/client/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,31 @@ func escapeMySQLIdent(ident string) (string, error) {
return strings.Join(parts, "."), nil
}

// Helper for user/host.
var validUserHost = regexp.MustCompile(`^[a-zA-Z0-9_%\\.\\-]+$`)
// Helper for user/host. Empty is allowed: every caller derives ident via
// SplitUserHost, which guarantees the host half is always non-empty, so an
// empty string here can only be the username of MySQL's anonymous account
// (''@'host'). ":" and "/" are allowed because MySQL host specs include IPv6
// literals (the stock root@::1) and netmask forms (198.51.100.0/255.255.255.0);
// both are inert inside the single-quoted '%s'@'%s' the callers build. "'" and
// "\" stay excluded, as those are what could break out of that quoting.
var validUserHost = regexp.MustCompile(`^[a-zA-Z0-9_%.@:/\-]*$`)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion: Widening the regex unblocks IPv6 at the escape layer, but the connector-side principal-ID parsing still splits on :, so IPv6 hosts remain unreachable for grant/revoke. For user:root@::1, pkg/connector/server.go:77, database.go:85, and column.go:80 take Split(id, ":")[1]"root@" (then SplitUserHost errors), while table.go:91, routine.go:106, role.go:96, and column.go:105 hit their len(parts) != 2 guard and return invalid principal ID. Netmask hosts are fine since / isn't a separator. Consider strings.TrimPrefix(id, resourceType+":") at those sites, as already done in grants.go:25 and user.go:179.


func escapeMySQLUserHost(ident string) (string, error) {
if !validUserHost.MatchString(ident) {
return "", fmt.Errorf("invalid user/host: %s", ident)
}
return ident, nil
}

// SplitUserHost splits a "name@host" identifier into its name and host parts.
// Names (MySQL usernames or role names) may themselves legally contain "@",
// but MySQL host specifications (hostnames, IPs, netmasks, or "%" wildcards)
// never do, so splitting on the last "@" unambiguously recovers both parts.
func SplitUserHost(s string) (string, string, error) {
idx := strings.LastIndex(s, "@")
// An empty name is valid: MySQL's anonymous account is ''@'host'.
if idx < 0 || idx == len(s)-1 {
Comment thread
OneWhoNests marked this conversation as resolved.
return "", "", fmt.Errorf("invalid user@host format: %s", s)
}
return s[:idx], s[idx+1:], nil
Comment thread
OneWhoNests marked this conversation as resolved.
}
118 changes: 118 additions & 0 deletions pkg/client/helper_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package client

import (
"testing"
)

func Test_SplitUserHost(t *testing.T) {
type want struct {
user string
host string
}
tests := []struct {
name string
in string
want want
wantErr bool
}{
{
name: "simple user and host",
in: "someone@%",
want: want{user: "someone", host: "%"},
},
{
name: "username containing @",
in: "someone@orion.com@%",
want: want{user: "someone@orion.com", host: "%"},
},
{
name: "username containing multiple @",
in: "a@b@c@10.0.0.1",
want: want{user: "a@b@c", host: "10.0.0.1"},
},
{
name: "collapsed comma-separated hosts",
in: "someone@orion.com@localhost,%",
want: want{user: "someone@orion.com", host: "localhost,%"},
},
{
name: "ipv6 loopback host",
in: "root@::1",
want: want{user: "root", host: "::1"},
},
{
name: "username with @ and ipv6 host",
in: "someone@orion.com@::1",
want: want{user: "someone@orion.com", host: "::1"},
},
{
name: "netmask host",
in: "someone@198.51.100.0/255.255.255.0",
want: want{user: "someone", host: "198.51.100.0/255.255.255.0"},
},
{
name: "no @",
in: "someone",
wantErr: true,
},
{
name: "empty user (MySQL anonymous account)",
in: "@%",
want: want{user: "", host: "%"},
},
{
name: "empty host",
in: "someone@",
wantErr: true,
},
{
name: "empty string",
in: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
user, host, err := SplitUserHost(tt.in)
if (err != nil) != tt.wantErr {
t.Fatalf("SplitUserHost(%q) error = %v, wantErr %v", tt.in, err, tt.wantErr)
}
if tt.wantErr {
return
}
if user != tt.want.user || host != tt.want.host {
t.Errorf("SplitUserHost(%q) = (%q, %q), want (%q, %q)", tt.in, user, host, tt.want.user, tt.want.host)
}
})
}
}

func Test_escapeMySQLUserHost(t *testing.T) {
tests := []struct {
name string
in string
wantErr bool
}{
{name: "empty (MySQL anonymous account username)", in: ""},
{name: "plain username", in: "someone"},
{name: "username with @", in: "someone@orion.com"},
{name: "wildcard host", in: "%"},
{name: "hostname", in: "%.example.com"},
{name: "ipv4 host", in: "127.0.0.1"},
{name: "ipv6 loopback host", in: "::1"},
{name: "ipv6 full host", in: "2001:db8::8a2e:370:7334"},
{name: "netmask host", in: "198.51.100.0/255.255.255.0"},
{name: "wildcard octet host", in: "198.51.100.%"},
{name: "quote injection attempt", in: "someone' OR '1'='1", wantErr: true},
{name: "space", in: "some one", wantErr: true},
{name: "trailing backslash", in: `someone\`, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := escapeMySQLUserHost(tt.in)
if (err != nil) != tt.wantErr {
t.Errorf("escapeMySQLUserHost(%q) error = %v, wantErr %v", tt.in, err, tt.wantErr)
}
})
}
}
41 changes: 20 additions & 21 deletions pkg/client/roles.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,32 @@ package client
import (
"context"
"fmt"
"strings"
)

func (c *Client) GrantRolePrivilege(ctx context.Context, role, user, privilege string) error {
roleParts := strings.Split(role, "@")
if len(roleParts) != 2 {
return fmt.Errorf("invalid role format: %s", role)
roleName, roleHostRaw, err := SplitUserHost(role)
if err != nil {
return fmt.Errorf("invalid role format: %s: %w", role, err)
}

userParts := strings.Split(user, "@")
if len(userParts) != 2 {
return fmt.Errorf("invalid user format: %s", user)
userName, userHostRaw, err := SplitUserHost(user)
if err != nil {
return fmt.Errorf("invalid user format: %s: %w", user, err)
}
Comment thread
OneWhoNests marked this conversation as resolved.

roleUser, err := escapeMySQLUserHost(roleParts[0])
roleUser, err := escapeMySQLUserHost(roleName)
if err != nil {
return err
}
roleHost, err := escapeMySQLUserHost(roleParts[1])
roleHost, err := escapeMySQLUserHost(roleHostRaw)
if err != nil {
return err
}
targetUser, err := escapeMySQLUserHost(userParts[0])
targetUser, err := escapeMySQLUserHost(userName)
if err != nil {
return err
}
targetHost, err := escapeMySQLUserHost(userParts[1])
targetHost, err := escapeMySQLUserHost(userHostRaw)
if err != nil {
return err
}
Expand All @@ -53,29 +52,29 @@ func (c *Client) GrantRolePrivilege(ctx context.Context, role, user, privilege s
}

func (c *Client) RevokeRolePrivilege(ctx context.Context, role, user, privilege string) error {
roleParts := strings.Split(role, "@")
if len(roleParts) != 2 {
return fmt.Errorf("invalid role format: %s", role)
roleName, roleHostRaw, err := SplitUserHost(role)
if err != nil {
return fmt.Errorf("invalid role format: %s: %w", role, err)
}

userParts := strings.Split(user, "@")
if len(userParts) != 2 {
return fmt.Errorf("invalid user format: %s", user)
userName, userHostRaw, err := SplitUserHost(user)
if err != nil {
return fmt.Errorf("invalid user format: %s: %w", user, err)
}

roleUser, err := escapeMySQLUserHost(roleParts[0])
roleUser, err := escapeMySQLUserHost(roleName)
if err != nil {
return err
}
roleHost, err := escapeMySQLUserHost(roleParts[1])
roleHost, err := escapeMySQLUserHost(roleHostRaw)
if err != nil {
return err
}
targetUser, err := escapeMySQLUserHost(userParts[0])
targetUser, err := escapeMySQLUserHost(userName)
if err != nil {
return err
}
targetHost, err := escapeMySQLUserHost(userParts[1])
targetHost, err := escapeMySQLUserHost(userHostRaw)
if err != nil {
return err
}
Expand Down
20 changes: 10 additions & 10 deletions pkg/client/routines.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,15 @@ func (c *Client) GrantRoutinePrivilege(ctx context.Context, privilege string, sc
return err
}

userSplit := strings.Split(user, "@")
if len(userSplit) != 2 {
return fmt.Errorf("invalid user format, expected user@host")
userName, host, err := SplitUserHost(user)
if err != nil {
return fmt.Errorf("invalid user format, expected user@host: %w", err)
}
userEsc, err := escapeMySQLUserHost(userSplit[0])
userEsc, err := escapeMySQLUserHost(userName)
if err != nil {
return err
}
hostEsc, err := escapeMySQLUserHost(userSplit[1])
hostEsc, err := escapeMySQLUserHost(host)
if err != nil {
return err
}
Expand Down Expand Up @@ -134,15 +134,15 @@ func (c *Client) RevokeRoutinePrivilege(ctx context.Context, privilege string, s
return err
}

userSplit := strings.Split(user, "@")
if len(userSplit) != 2 {
return fmt.Errorf("invalid user format, expected user@host")
userName, host, err := SplitUserHost(user)
if err != nil {
return fmt.Errorf("invalid user format, expected user@host: %w", err)
}
userEsc, err := escapeMySQLUserHost(userSplit[0])
userEsc, err := escapeMySQLUserHost(userName)
if err != nil {
return err
}
hostEsc, err := escapeMySQLUserHost(userSplit[1])
hostEsc, err := escapeMySQLUserHost(host)
if err != nil {
return err
}
Expand Down
Loading
Loading