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 package cliui
import ( import (
"bufio"
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -8,19 +9,21 @@ import (
"os" "os"
"os/signal" "os/signal"
"strings" "strings"
"unicode"
"github.com/bgentry/speakeasy"
"github.com/mattn/go-isatty" "github.com/mattn/go-isatty"
"golang.org/x/xerrors" "golang.org/x/xerrors"
"github.com/coder/coder/v2/pty"
"github.com/coder/pretty" "github.com/coder/pretty"
"github.com/coder/serpent" "github.com/coder/serpent"
) )
// PromptOptions supply a set of options to the prompt. // PromptOptions supply a set of options to the prompt.
type PromptOptions struct { type PromptOptions struct {
Text string Text string
Default string Default string
// When true, the input will be masked with asterisks.
Secret bool Secret bool
IsConfirm bool IsConfirm bool
Validate func(string) error Validate func(string) error
@@ -88,14 +91,13 @@ func Prompt(inv *serpent.Invocation, opts PromptOptions) (string, error) {
var line string var line string
var err error var err error
signal.Notify(interrupt, os.Interrupt)
defer signal.Stop(interrupt)
inFile, isInputFile := inv.Stdin.(*os.File) inFile, isInputFile := inv.Stdin.(*os.File)
if opts.Secret && isInputFile && isatty.IsTerminal(inFile.Fd()) { if opts.Secret && isInputFile && isatty.IsTerminal(inFile.Fd()) {
// we don't install a signal handler here because speakeasy has its own line, err = readSecretInput(inFile, inv.Stdout)
line, err = speakeasy.Ask("")
} else { } else {
signal.Notify(interrupt, os.Interrupt)
defer signal.Stop(interrupt)
line, err = readUntil(inv.Stdin, '\n') line, err = readUntil(inv.Stdin, '\n')
// Check if the first line beings with JSON object or array chars. // 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" "io"
"os" "os"
"os/exec" "os/exec"
"runtime"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -13,7 +14,6 @@ import (
"golang.org/x/xerrors" "golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui" "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/pty/ptytest"
"github.com/coder/coder/v2/testutil" "github.com/coder/coder/v2/testutil"
"github.com/coder/serpent" "github.com/coder/serpent"
@@ -181,6 +181,48 @@ func TestPrompt(t *testing.T) {
resp := testutil.TryReceive(ctx, t, doneChan) resp := testutil.TryReceive(ctx, t, doneChan)
require.Equal(t, "valid", resp) 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) { 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() passwordHelper()
return return
} }
if runtime.GOOS == "windows" {
t.Skip("Skipping on windows. PTY doesn't read ptty.Write correctly.")
}
t.Parallel() t.Parallel()
ptty := ptytest.New(t) 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 := exec.Command(os.Args[0], "-test.run=TestPasswordTerminalState") //nolint:gosec
cmd.Env = append(os.Environ(), "TEST_SUBPROCESS=1") cmd.Env = append(os.Environ(), "TEST_SUBPROCESS=1")
@@ -229,21 +270,16 @@ func TestPasswordTerminalState(t *testing.T) {
defer process.Kill() defer process.Kill()
ptty.ExpectMatch("Password: ") ptty.ExpectMatch("Password: ")
ptty.Write('t')
require.Eventually(t, func() bool { ptty.Write('e')
echo, err := ptyWithFlags.EchoEnabled() ptty.Write('s')
return err == nil && !echo ptty.Write('t')
}, testutil.WaitShort, testutil.IntervalMedium, "echo is on while reading password") ptty.ExpectMatch("****")
err = process.Signal(os.Interrupt) err = process.Signal(os.Interrupt)
require.NoError(t, err) require.NoError(t, err)
_, err = process.Wait() _, err = process.Wait()
require.NoError(t, err) 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 // nolint:unused
-1
View File
@@ -83,7 +83,6 @@ require (
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2
github.com/awalterschulze/gographviz v2.0.3+incompatible github.com/awalterschulze/gographviz v2.0.3+incompatible
github.com/aws/smithy-go v1.22.3 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/bramvdbogaerde/go-scp v1.5.0
github.com/briandowns/spinner v1.23.0 github.com/briandowns/spinner v1.23.0
github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5 github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5
-2
View File
@@ -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 h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas=
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= 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.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 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E=