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

Closes #17059
This commit is contained in:
Michael Suchacz
2025-04-24 09:54:00 +02:00
committed by GitHub
parent 614a7d0d58
commit 9922240fd4
4 changed files with 116 additions and 26 deletions
+65 -8
View File
@@ -1,6 +1,7 @@
package cliui
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
@@ -8,19 +9,21 @@ import (
"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
@@ -88,14 +91,13 @@ func Prompt(inv *serpent.Invocation, opts PromptOptions) (string, error) {
var line string
var err error
signal.Notify(interrupt, os.Interrupt)
defer signal.Stop(interrupt)
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("")
line, err = readSecretInput(inFile, inv.Stdout)
} else {
signal.Notify(interrupt, os.Interrupt)
defer signal.Stop(interrupt)
line, err = readUntil(inv.Stdin, '\n')
// Check if the first line beings with JSON object or array chars.
@@ -204,3 +206,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
}
}
}
}
}
+51 -15
View File
@@ -6,6 +6,7 @@ import (
"io"
"os"
"os/exec"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
@@ -13,7 +14,6 @@ import (
"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"
@@ -181,6 +181,48 @@ 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)
})
t.Run("UTF8Password", 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("和製漢字")
resp := testutil.TryReceive(ctx, t, doneChan)
require.Equal(t, "和製漢字", resp)
})
}
func newPrompt(ctx context.Context, ptty *ptytest.PTY, opts cliui.PromptOptions, invOpt func(inv *serpent.Invocation)) (string, error) {
@@ -209,13 +251,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")
@@ -229,21 +270,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