mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: enable masking password inputs instead of blocking echo (#17469)
Closes #17059
This commit is contained in:
+65
-8
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user