Skip to content

feat: enable masking password inputs instead of blocking echo #17469

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Apr 24, 2025
Merged
69 changes: 64 additions & 5 deletions cli/cliui/prompt.go
Original file line number Diff line number Diff line change
@@ -1,26 +1,29 @@
package cliui

import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"os/signal"
"strings"
"unicode"

"github.com/bgentry/speakeasy"
"github.com/mattn/go-isatty"
"golang.org/x/xerrors"

"github.com/coder/coder/v2/pty"
"github.com/coder/pretty"
"github.com/coder/serpent"
)

// PromptOptions supply a set of options to the prompt.
type PromptOptions struct {
Text string
Default string
Text string
Default string
// When true, the input will be masked with asterisks.
Secret bool
IsConfirm bool
Validate func(string) error
Expand Down Expand Up @@ -90,8 +93,9 @@ func Prompt(inv *serpent.Invocation, opts PromptOptions) (string, error) {

inFile, isInputFile := inv.Stdin.(*os.File)
if opts.Secret && isInputFile && isatty.IsTerminal(inFile.Fd()) {
// we don't install a signal handler here because speakeasy has its own
line, err = speakeasy.Ask("")
signal.Notify(interrupt, os.Interrupt)
defer signal.Stop(interrupt)
line, err = readSecretInput(inFile, inv.Stdout)
} else {
signal.Notify(interrupt, os.Interrupt)
defer signal.Stop(interrupt)
Expand Down Expand Up @@ -204,3 +208,58 @@ func readUntil(r io.Reader, delim byte) (string, error) {
}
}
}

// readSecretInput reads secret input from the terminal rune-by-rune,
// masking each character with an asterisk.
func readSecretInput(f *os.File, w io.Writer) (string, error) {
// Put terminal into raw mode (no echo, no line buffering).
oldState, err := pty.MakeInputRaw(f.Fd())
if err != nil {
return "", err
}
defer func() {
_ = pty.RestoreTerminal(f.Fd(), oldState)
}()

reader := bufio.NewReader(f)
var runes []rune

for {
r, _, err := reader.ReadRune()
if err != nil {
return "", err
}

switch {
case r == '\r' || r == '\n':
// Finish on Enter
if _, err := fmt.Fprint(w, "\r\n"); err != nil {
return "", err
}
return string(runes), nil

case r == 3:
// Ctrl+C
return "", ErrCanceled

case r == 127 || r == '\b':
// Backspace/Delete: remove last rune
if len(runes) > 0 {
// Erase the last '*' on the screen
if _, err := fmt.Fprint(w, "\b \b"); err != nil {
return "", err
}
runes = runes[:len(runes)-1]
}

default:
// Only mask printable, non-control runes
if !unicode.IsControl(r) {
runes = append(runes, r)
if _, err := fmt.Fprint(w, "*"); err != nil {
return "", err
}
}
}
}
}
45 changes: 30 additions & 15 deletions cli/cliui/prompt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ import (
"io"
"os"
"os/exec"
"runtime"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"

"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/pty"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/serpent"
Expand Down Expand Up @@ -181,6 +181,27 @@ func TestPrompt(t *testing.T) {
resp := testutil.TryReceive(ctx, t, doneChan)
require.Equal(t, "valid", resp)
})

t.Run("MaskedSecret", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
ptty := ptytest.New(t)
doneChan := make(chan string)
go func() {
resp, err := newPrompt(ctx, ptty, cliui.PromptOptions{
Text: "Password:",
Secret: true,
}, nil)
assert.NoError(t, err)
doneChan <- resp
}()
ptty.ExpectMatch("Password: ")

ptty.WriteLine("test")

resp := testutil.TryReceive(ctx, t, doneChan)
require.Equal(t, "test", resp)
})
}

func newPrompt(ctx context.Context, ptty *ptytest.PTY, opts cliui.PromptOptions, invOpt func(inv *serpent.Invocation)) (string, error) {
Expand Down Expand Up @@ -209,13 +230,12 @@ func TestPasswordTerminalState(t *testing.T) {
passwordHelper()
return
}
if runtime.GOOS == "windows" {
t.Skip("Skipping on windows. PTY doesn't read ptty.Write correctly.")
}
t.Parallel()

ptty := ptytest.New(t)
ptyWithFlags, ok := ptty.PTY.(pty.WithFlags)
if !ok {
t.Skip("unable to check PTY local echo on this platform")
}

cmd := exec.Command(os.Args[0], "-test.run=TestPasswordTerminalState") //nolint:gosec
cmd.Env = append(os.Environ(), "TEST_SUBPROCESS=1")
Expand All @@ -229,21 +249,16 @@ func TestPasswordTerminalState(t *testing.T) {
defer process.Kill()

ptty.ExpectMatch("Password: ")

require.Eventually(t, func() bool {
echo, err := ptyWithFlags.EchoEnabled()
return err == nil && !echo
}, testutil.WaitShort, testutil.IntervalMedium, "echo is on while reading password")
ptty.Write('t')
ptty.Write('e')
ptty.Write('s')
ptty.Write('t')
ptty.ExpectMatch("****")

err = process.Signal(os.Interrupt)
require.NoError(t, err)
_, err = process.Wait()
require.NoError(t, err)

require.Eventually(t, func() bool {
echo, err := ptyWithFlags.EchoEnabled()
return err == nil && echo
}, testutil.WaitShort, testutil.IntervalMedium, "echo is off after reading password")
}

// nolint:unused
Expand Down
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@ require (
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2
github.com/awalterschulze/gographviz v2.0.3+incompatible
github.com/aws/smithy-go v1.22.3
github.com/bgentry/speakeasy v0.2.0
github.com/bramvdbogaerde/go-scp v1.5.0
github.com/briandowns/spinner v1.23.0
github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5
Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -815,8 +815,6 @@ github.com/bep/tmc v0.5.1/go.mod h1:tGYHN8fS85aJPhDLgXETVKp+PR382OvFi2+q2GkGsq0=
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas=
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/bgentry/speakeasy v0.2.0 h1:tgObeVOf8WAvtuAX6DhJ4xks4CFNwPDZiqzGqIHE51E=
github.com/bgentry/speakeasy v0.2.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E=
Expand Down
Loading