From e9fec47d220c679f29148ec106c50285b600d804 Mon Sep 17 00:00:00 2001 From: sbs44 <83440025+sbs44@users.noreply.github.com> Date: Sun, 15 Feb 2026 22:07:22 -0500 Subject: [PATCH 1/3] feat(secrets): add interactive masked prompt for secrets set - Prompt for secret values when name provided without value - Display asterisks per character for input length verification - Handle Ctrl+C, backspace, and non-printable character filtering - Maintain backwards compatibility with NAME=VALUE inline syntax - Skip SUPABASE_ prefixed names before prompting --- cmd/secrets.go | 4 +- internal/functions/serve/serve.go | 2 +- internal/secrets/set/set.go | 35 ++++++++++- internal/secrets/set/set_test.go | 76 +++++++++++++++++++++-- internal/utils/credentials/input.go | 44 +++++++++++++ internal/utils/credentials/input_test.go | 78 +++++++++++++++++++++++- 6 files changed, 228 insertions(+), 11 deletions(-) diff --git a/cmd/secrets.go b/cmd/secrets.go index df4084e466..612e5c3351 100644 --- a/cmd/secrets.go +++ b/cmd/secrets.go @@ -26,9 +26,9 @@ var ( } secretsSetCmd = &cobra.Command{ - Use: "set ...", + Use: "set [NAME=VALUE | NAME] ...", Short: "Set a secret(s) on Supabase", - Long: "Set a secret(s) to the linked Supabase project.", + Long: "Set a secret(s) to the linked Supabase project. When a secret name is provided without a value, you will be prompted to enter it interactively.", RunE: func(cmd *cobra.Command, args []string) error { return set.Run(cmd.Context(), flags.ProjectRef, envFilePath, args, afero.NewOsFs()) }, diff --git a/internal/functions/serve/serve.go b/internal/functions/serve/serve.go index 730e626dac..631bb3bd01 100644 --- a/internal/functions/serve/serve.go +++ b/internal/functions/serve/serve.go @@ -234,7 +234,7 @@ func parseEnvFile(envFilePath string, fsys afero.Fs) ([]string, error) { envFilePath = filepath.Join(utils.CurrentDirAbs, envFilePath) } env := []string{} - secrets, err := set.ListSecrets(envFilePath, fsys) + secrets, err := set.ListSecrets(envFilePath, fsys, nil) for _, v := range secrets { env = append(env, fmt.Sprintf("%s=%s", v.Name, v.Value)) } diff --git a/internal/secrets/set/set.go b/internal/secrets/set/set.go index 159a2b4e06..b11bf74da0 100644 --- a/internal/secrets/set/set.go +++ b/internal/secrets/set/set.go @@ -13,8 +13,10 @@ import ( "github.com/joho/godotenv" "github.com/spf13/afero" "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/internal/utils/credentials" "github.com/supabase/cli/internal/utils/flags" "github.com/supabase/cli/pkg/api" + "golang.org/x/term" ) func Run(ctx context.Context, projectRef, envFilePath string, args []string, fsys afero.Fs) error { @@ -25,7 +27,22 @@ func Run(ctx context.Context, projectRef, envFilePath string, args []string, fsy if len(envFilePath) > 0 && !filepath.IsAbs(envFilePath) { envFilePath = filepath.Join(utils.CurrentDirAbs, envFilePath) } - secrets, err := ListSecrets(envFilePath, fsys, args...) + promptSecret := func(name string) (string, error) { + // Guard: without this check, PromptMasked would silently consume all piped stdin + if !term.IsTerminal(int(os.Stdin.Fd())) { + return "", errors.Errorf("Cannot prompt for secret value in non-interactive mode. Use %s format instead.", name+"=VALUE") + } + fmt.Fprintf(os.Stderr, "Paste your secret for %s: ", utils.Aqua(name)) + value, err := credentials.PromptMaskedWithAsterisks(os.Stdin) + if err != nil { + return "", err + } + if len(value) == 0 { + return "", errors.New("Secret value cannot be empty. Use NAME= to explicitly set an empty value.") + } + return value, nil + } + secrets, err := ListSecrets(envFilePath, fsys, promptSecret, args...) if err != nil { return err } @@ -43,7 +60,7 @@ func Run(ctx context.Context, projectRef, envFilePath string, args []string, fsy return nil } -func ListSecrets(envFilePath string, fsys afero.Fs, envArgs ...string) (api.CreateSecretBody, error) { +func ListSecrets(envFilePath string, fsys afero.Fs, promptSecret func(string) (string, error), envArgs ...string) (api.CreateSecretBody, error) { envMap := map[string]string{} for name, secret := range utils.Config.EdgeRuntime.Secrets { if len(secret.SHA256) > 0 { @@ -60,7 +77,19 @@ func ListSecrets(envFilePath string, fsys afero.Fs, envArgs ...string) (api.Crea for _, pair := range envArgs { name, value, found := strings.Cut(pair, "=") if !found { - return nil, errors.Errorf("Invalid secret pair: %s. Must be NAME=VALUE.", pair) + if promptSecret == nil { + return nil, errors.Errorf("Invalid secret pair: %s. Must be NAME=VALUE.", pair) + } + // Skip early to avoid prompting for a name that would be discarded below + if strings.HasPrefix(name, "SUPABASE_") { + fmt.Fprintln(os.Stderr, "Env name cannot start with SUPABASE_, skipping: "+name) + continue + } + var err error + value, err = promptSecret(name) + if err != nil { + return nil, err + } } envMap[name] = value } diff --git a/internal/secrets/set/set_test.go b/internal/secrets/set/set_test.go index a99cfd8c8d..71eceb99ce 100644 --- a/internal/secrets/set/set_test.go +++ b/internal/secrets/set/set_test.go @@ -79,7 +79,7 @@ func TestSecretSetCommand(t *testing.T) { assert.ErrorContains(t, err, "No arguments found. Use --env-file to read from a .env file.") }) - t.Run("throws error on malformed secret", func(t *testing.T) { + t.Run("throws error on bare name in non-interactive mode", func(t *testing.T) { // Setup in-memory fs fsys := afero.NewMemMapFs() // Setup valid project ref @@ -88,9 +88,9 @@ func TestSecretSetCommand(t *testing.T) { token := apitest.RandomAccessToken(t) t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) // Run test - err := Run(context.Background(), project, "", []string{"malformed"}, fsys) - // Check error - assert.ErrorContains(t, err, "Invalid secret pair: malformed. Must be NAME=VALUE.") + err := Run(context.Background(), project, "", []string{"MY_SECRET"}, fsys) + // Check error - non-TTY test environment triggers the non-interactive guard + assert.ErrorContains(t, err, "Cannot prompt for secret value in non-interactive mode") }) t.Run("throws error on network error", func(t *testing.T) { @@ -138,3 +138,71 @@ func TestSecretSetCommand(t *testing.T) { assert.Empty(t, apitest.ListUnmatchedRequests()) }) } + +func TestListSecrets(t *testing.T) { + fsys := afero.NewMemMapFs() + + t.Run("errors on bare name with nil prompter", func(t *testing.T) { + _, err := ListSecrets("", fsys, nil, "malformed") + assert.ErrorContains(t, err, "Invalid secret pair: malformed. Must be NAME=VALUE.") + }) + + t.Run("prompts for secret value interactively", func(t *testing.T) { + mockPrompt := func(name string) (string, error) { + assert.Equal(t, "MY_SECRET", name) + return "prompted_value", nil + } + secrets, err := ListSecrets("", fsys, mockPrompt, "MY_SECRET") + require.NoError(t, err) + require.Len(t, secrets, 1) + assert.Equal(t, "MY_SECRET", secrets[0].Name) + assert.Equal(t, "prompted_value", secrets[0].Value) + }) + + t.Run("prompts for multiple secrets", func(t *testing.T) { + callCount := 0 + mockPrompt := func(name string) (string, error) { + callCount++ + return "value_" + name, nil + } + secrets, err := ListSecrets("", fsys, mockPrompt, "KEY1", "KEY2") + require.NoError(t, err) + assert.Equal(t, 2, callCount) + assert.Len(t, secrets, 2) + }) + + t.Run("mixes inline and prompted secrets", func(t *testing.T) { + mockPrompt := func(name string) (string, error) { + assert.Equal(t, "KEY2", name) + return "prompted_value", nil + } + secrets, err := ListSecrets("", fsys, mockPrompt, "KEY1=inline_value", "KEY2") + require.NoError(t, err) + assert.Len(t, secrets, 2) + // Verify both secrets are present + values := map[string]string{} + for _, s := range secrets { + values[s.Name] = s.Value + } + assert.Equal(t, "inline_value", values["KEY1"]) + assert.Equal(t, "prompted_value", values["KEY2"]) + }) + + t.Run("propagates prompt error", func(t *testing.T) { + mockPrompt := func(name string) (string, error) { + return "", errors.New("prompt failed") + } + _, err := ListSecrets("", fsys, mockPrompt, "MY_SECRET") + assert.ErrorContains(t, err, "prompt failed") + }) + + t.Run("skips SUPABASE_ prefixed bare name without prompting", func(t *testing.T) { + mockPrompt := func(name string) (string, error) { + t.Fatal("should not prompt for SUPABASE_ prefixed names") + return "", nil + } + secrets, err := ListSecrets("", fsys, mockPrompt, "SUPABASE_FOO") + require.NoError(t, err) + assert.Empty(t, secrets) + }) +} diff --git a/internal/utils/credentials/input.go b/internal/utils/credentials/input.go index 290b807aea..98493e5e13 100644 --- a/internal/utils/credentials/input.go +++ b/internal/utils/credentials/input.go @@ -9,6 +9,50 @@ import ( "golang.org/x/term" ) +// PromptMaskedWithAsterisks reads input character by character, echoing '*' for +// each typed character. Handles backspace and Ctrl+C. Requires a TTY terminal. +func PromptMaskedWithAsterisks(stdin *os.File) (string, error) { + fd := int(stdin.Fd()) + oldState, err := term.MakeRaw(fd) + if err != nil { + return "", fmt.Errorf("failed to set raw terminal: %w", err) + } + defer term.Restore(fd, oldState) + return readMaskedInput(stdin, os.Stderr) +} + +// readMaskedInput reads bytes one at a time from r, echoing '*' to echo for each +// printable character. Handles backspace, Ctrl+C, and Enter. +func readMaskedInput(r io.Reader, echo io.Writer) (string, error) { + var buf []byte + b := make([]byte, 1) + for { + if _, err := r.Read(b); err != nil { + fmt.Fprint(echo, "\r\n") + if err == io.EOF { + return string(buf), nil + } + return "", fmt.Errorf("failed to read input: %w", err) + } + switch { + case b[0] == 3: // Ctrl+C + fmt.Fprint(echo, "\r\n") + return "", fmt.Errorf("interrupted") + case b[0] == 13 || b[0] == 10: // Enter + fmt.Fprint(echo, "\r\n") + return string(buf), nil + case b[0] == 127 || b[0] == 8: // Backspace / Delete + if len(buf) > 0 { + buf = buf[:len(buf)-1] + fmt.Fprint(echo, "\b \b") + } + case b[0] >= 32 && b[0] < 127: // Printable ASCII + buf = append(buf, b[0]) + fmt.Fprint(echo, "*") + } + } +} + func PromptMasked(stdin *os.File) string { // Start a new line after reading input defer fmt.Println() diff --git a/internal/utils/credentials/input_test.go b/internal/utils/credentials/input_test.go index c83d6d3adc..2a4aeeb1a7 100644 --- a/internal/utils/credentials/input_test.go +++ b/internal/utils/credentials/input_test.go @@ -1,13 +1,88 @@ package credentials import ( + "bytes" + "io" "os" + "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestReadMaskedInput(t *testing.T) { + t.Run("reads until Enter", func(t *testing.T) { + input := strings.NewReader("hello\r") + result, err := readMaskedInput(input, io.Discard) + require.NoError(t, err) + assert.Equal(t, "hello", result) + }) + + t.Run("reads until newline", func(t *testing.T) { + input := strings.NewReader("hello\n") + result, err := readMaskedInput(input, io.Discard) + require.NoError(t, err) + assert.Equal(t, "hello", result) + }) + + t.Run("returns error on Ctrl+C", func(t *testing.T) { + input := strings.NewReader("abc\x03") + _, err := readMaskedInput(input, io.Discard) + assert.ErrorContains(t, err, "interrupted") + }) + + t.Run("handles backspace", func(t *testing.T) { + // Type "abc", backspace, then "d", then Enter + input := strings.NewReader("abc\x7fd\r") + result, err := readMaskedInput(input, io.Discard) + require.NoError(t, err) + assert.Equal(t, "abd", result) + }) + + t.Run("backspace on empty buffer is no-op", func(t *testing.T) { + input := strings.NewReader("\x7f\x7fabc\r") + result, err := readMaskedInput(input, io.Discard) + require.NoError(t, err) + assert.Equal(t, "abc", result) + }) + + t.Run("ignores non-printable characters", func(t *testing.T) { + // Tab (0x09), escape (0x1b), and other control chars should be ignored + input := strings.NewReader("a\x09b\x1bc\r") + result, err := readMaskedInput(input, io.Discard) + require.NoError(t, err) + assert.Equal(t, "abc", result) + }) + + t.Run("echoes asterisks for each character", func(t *testing.T) { + input := strings.NewReader("abc\r") + var echo bytes.Buffer + _, err := readMaskedInput(input, &echo) + require.NoError(t, err) + assert.Equal(t, "***\r\n", echo.String()) + }) + + t.Run("returns accumulated input on EOF", func(t *testing.T) { + input := strings.NewReader("partial") + result, err := readMaskedInput(input, io.Discard) + require.NoError(t, err) + assert.Equal(t, "partial", result) + }) +} + +func TestPromptMaskedWithAsterisks(t *testing.T) { + t.Run("returns error on non-TTY", func(t *testing.T) { + r, w, err := os.Pipe() + require.NoError(t, err) + defer r.Close() + defer w.Close() + // MakeRaw fails on pipes (non-TTY) + _, err = PromptMaskedWithAsterisks(r) + assert.ErrorContains(t, err, "failed to set raw terminal") + }) +} + func TestPromptMasked(t *testing.T) { t.Run("reads from piped stdin", func(t *testing.T) { // Setup token @@ -24,8 +99,9 @@ func TestPromptMasked(t *testing.T) { t.Run("empty string on closed pipe", func(t *testing.T) { // Setup empty stdin - r, _, err := os.Pipe() + r, w, err := os.Pipe() require.NoError(t, err) + require.NoError(t, w.Close()) require.NoError(t, r.Close()) // Run test input := PromptMasked(r) From e6c8b8fb7abc3a6fb249c65bd53d3aa02a7f4c56 Mon Sep 17 00:00:00 2001 From: sbs44 <83440025+sbs44@users.noreply.github.com> Date: Sun, 15 Feb 2026 22:18:57 -0500 Subject: [PATCH 2/3] fix(credentials): address gosec and errcheck lint warnings - Check read byte count before accessing buffer to fix G602 gosec warnings - Explicitly discard term.Restore error to fix errcheck warning --- internal/utils/credentials/input.go | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/internal/utils/credentials/input.go b/internal/utils/credentials/input.go index 98493e5e13..ce5bf27a3a 100644 --- a/internal/utils/credentials/input.go +++ b/internal/utils/credentials/input.go @@ -17,7 +17,7 @@ func PromptMaskedWithAsterisks(stdin *os.File) (string, error) { if err != nil { return "", fmt.Errorf("failed to set raw terminal: %w", err) } - defer term.Restore(fd, oldState) + defer func() { _ = term.Restore(fd, oldState) }() return readMaskedInput(stdin, os.Stderr) } @@ -25,29 +25,30 @@ func PromptMaskedWithAsterisks(stdin *os.File) (string, error) { // printable character. Handles backspace, Ctrl+C, and Enter. func readMaskedInput(r io.Reader, echo io.Writer) (string, error) { var buf []byte - b := make([]byte, 1) + var b [1]byte for { - if _, err := r.Read(b); err != nil { + if _, err := io.ReadFull(r, b[:]); err != nil { fmt.Fprint(echo, "\r\n") - if err == io.EOF { + if err == io.EOF || err == io.ErrUnexpectedEOF { return string(buf), nil } return "", fmt.Errorf("failed to read input: %w", err) } + ch := b[0] switch { - case b[0] == 3: // Ctrl+C + case ch == 3: // Ctrl+C fmt.Fprint(echo, "\r\n") return "", fmt.Errorf("interrupted") - case b[0] == 13 || b[0] == 10: // Enter + case ch == 13 || ch == 10: // Enter fmt.Fprint(echo, "\r\n") return string(buf), nil - case b[0] == 127 || b[0] == 8: // Backspace / Delete + case ch == 127 || ch == 8: // Backspace / Delete if len(buf) > 0 { buf = buf[:len(buf)-1] fmt.Fprint(echo, "\b \b") } - case b[0] >= 32 && b[0] < 127: // Printable ASCII - buf = append(buf, b[0]) + case ch >= 32 && ch < 127: // Printable ASCII + buf = append(buf, ch) fmt.Fprint(echo, "*") } } From 904b60e3ba14c214185f9929e2d9575c87958d5e Mon Sep 17 00:00:00 2001 From: sbs44 <83440025+sbs44@users.noreply.github.com> Date: Sun, 15 Feb 2026 22:35:02 -0500 Subject: [PATCH 3/3] fix(credentials): accept non-ASCII bytes in masked input Bytes >= 128 were silently dropped, corrupting UTF-8 secrets. --- internal/utils/credentials/input.go | 2 +- internal/utils/credentials/input_test.go | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/utils/credentials/input.go b/internal/utils/credentials/input.go index ce5bf27a3a..939d1839a6 100644 --- a/internal/utils/credentials/input.go +++ b/internal/utils/credentials/input.go @@ -47,7 +47,7 @@ func readMaskedInput(r io.Reader, echo io.Writer) (string, error) { buf = buf[:len(buf)-1] fmt.Fprint(echo, "\b \b") } - case ch >= 32 && ch < 127: // Printable ASCII + case ch >= 32 && ch != 127: // Printable (incl. non-ASCII bytes) buf = append(buf, ch) fmt.Fprint(echo, "*") } diff --git a/internal/utils/credentials/input_test.go b/internal/utils/credentials/input_test.go index 2a4aeeb1a7..dc79fa264b 100644 --- a/internal/utils/credentials/input_test.go +++ b/internal/utils/credentials/input_test.go @@ -55,6 +55,14 @@ func TestReadMaskedInput(t *testing.T) { assert.Equal(t, "abc", result) }) + t.Run("accepts non-ASCII bytes", func(t *testing.T) { + // UTF-8 encoded "é" is 0xc3 0xa9 + input := bytes.NewReader([]byte{'a', 0xc3, 0xa9, 'b', '\r'}) + result, err := readMaskedInput(input, io.Discard) + require.NoError(t, err) + assert.Equal(t, "a\xc3\xa9b", result) + }) + t.Run("echoes asterisks for each character", func(t *testing.T) { input := strings.NewReader("abc\r") var echo bytes.Buffer