diff --git a/cli/server.go b/cli/server.go index b4a4f0a654..f1125da3f0 100644 --- a/cli/server.go +++ b/cli/server.go @@ -648,7 +648,12 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. options.Database = dbmem.New() options.Pubsub = pubsub.NewInMemory() } else { - sqlDB, err := ConnectToPostgres(ctx, logger, sqlDriver, vals.PostgresURL.String()) + dbURL, err := escapePostgresURLUserInfo(vals.PostgresURL.String()) + if err != nil { + return xerrors.Errorf("escaping postgres URL: %w", err) + } + + sqlDB, err := ConnectToPostgres(ctx, logger, sqlDriver, dbURL) if err != nil { return xerrors.Errorf("connect to postgres: %w", err) } @@ -657,7 +662,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. }() options.Database = database.New(sqlDB) - options.Pubsub, err = pubsub.New(ctx, sqlDB, vals.PostgresURL.String()) + options.Pubsub, err = pubsub.New(ctx, sqlDB, dbURL) if err != nil { return xerrors.Errorf("create pubsub: %w", err) } @@ -2433,3 +2438,41 @@ func parseExternalAuthProvidersFromEnv(prefix string, environ []string) ([]coder } return providers, nil } + +// If the user provides a postgres URL with a password that contains special +// characters, the URL will be invalid. We need to escape the password so that +// the URL parse doesn't fail at the DB connector level. +func escapePostgresURLUserInfo(v string) (string, error) { + _, err := url.Parse(v) + // I wish I could use errors.Is here, but this error is not declared as a + // variable in net/url. :( + if err != nil { + if strings.Contains(err.Error(), "net/url: invalid userinfo") { + // If the URL is invalid, we assume it is because the password contains + // special characters that need to be escaped. + + // get everything before first @ + parts := strings.SplitN(v, "@", 2) + if len(parts) != 2 { + return "", xerrors.Errorf("invalid postgres url with userinfo: %s", v) + } + start := parts[0] + // get password, which is the last item in start when split by : + startParts := strings.Split(start, ":") + password := startParts[len(startParts)-1] + // escape password, and replace the last item in the startParts slice + // with the escaped password. + // + // url.PathEscape is used here because url.QueryEscape + // will not escape spaces correctly. + newPassword := url.PathEscape(password) + startParts[len(startParts)-1] = newPassword + start = strings.Join(startParts, ":") + return start + "@" + parts[1], nil + } + + return "", xerrors.Errorf("parse postgres url: %w", err) + } + + return v, nil +} diff --git a/cli/server_internal_test.go b/cli/server_internal_test.go index 4adb85cc64..52bc6fd82c 100644 --- a/cli/server_internal_test.go +++ b/cli/server_internal_test.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/pflag" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/xerrors" "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" @@ -296,3 +297,53 @@ func TestIsDERPPath(t *testing.T) { }) } } + +func TestEscapePostgresURLUserInfo(t *testing.T) { + t.Parallel() + + testcases := []struct { + input string + output string + err error + }{ + { + input: "postgres://coder:coder@localhost:5432/coder", + output: "postgres://coder:coder@localhost:5432/coder", + err: nil, + }, + { + input: "postgres://coder:co{der@localhost:5432/coder", + output: "postgres://coder:co%7Bder@localhost:5432/coder", + err: nil, + }, + { + input: "postgres://coder:co:der@localhost:5432/coder", + output: "postgres://coder:co:der@localhost:5432/coder", + err: nil, + }, + { + input: "postgres://coder:co der@localhost:5432/coder", + output: "postgres://coder:co%20der@localhost:5432/coder", + err: nil, + }, + { + input: "postgres://local host:5432/coder", + output: "", + err: xerrors.New("parse postgres url: parse \"postgres://local host:5432/coder\": invalid character \" \" in host name"), + }, + } + for _, tc := range testcases { + tc := tc + t.Run(tc.input, func(t *testing.T) { + t.Parallel() + o, err := escapePostgresURLUserInfo(tc.input) + require.Equal(t, tc.output, o) + if tc.err != nil { + require.Error(t, err) + require.EqualValues(t, tc.err.Error(), err.Error()) + } else { + require.NoError(t, err) + } + }) + } +}