mirror of
https://github.com/gravitational/teleport.git
synced 2026-11-03 07:40:28 +08:00
Fixes an issue with ContextReader on `bash` where abandoned password reads cause the terminal to remain "locked" even after `tsh` exits. This happens on interrupts but also on regular exit if the user picks a security key in the dual security key/OTP login prompt. Doesn't seem to affect shells like `zsh` or `fish`. Repro steps: ```shell $ bash # repro #1 $ ./tsh login --proxy=example.com > Enter password for Teleport user llama: <CTRL-C> # shell is now locked # repro #2 # (Use an account with security key and OTP MFA.) $ ./tsh login --proxy=example.com > Enter password for Teleport user llama: <enters password> > Tap any security key or enter a code from a OTP device <taps security key> # shell is now locked ``` * Restore terminal state on interrupt or exit * Notify prompt on tsh exit * Review: context.WithTimeout and Notify on os.Exit
274 lines
7.2 KiB
Go
274 lines
7.2 KiB
Go
/*
|
|
Copyright 2021 Gravitational, Inc.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package prompt
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/term"
|
|
)
|
|
|
|
func TestContextReader(t *testing.T) {
|
|
pr, pw := io.Pipe()
|
|
t.Cleanup(func() { pr.Close() })
|
|
t.Cleanup(func() { pw.Close() })
|
|
|
|
write := func(t *testing.T, s string) {
|
|
t.Helper()
|
|
_, err := pw.Write([]byte(s))
|
|
assert.NoError(t, err, "Write failed")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
cr := NewContextReader(pr)
|
|
|
|
t.Run("simple read", func(t *testing.T) {
|
|
go write(t, "hello")
|
|
buf, err := cr.ReadContext(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, string(buf), "hello")
|
|
})
|
|
|
|
t.Run("reclaim abandoned read", func(t *testing.T) {
|
|
done := make(chan struct{})
|
|
cancelCtx, cancel := context.WithCancel(ctx)
|
|
go func() {
|
|
time.Sleep(1 * time.Millisecond) // give ReadContext time to block
|
|
cancel()
|
|
write(t, "after cancel")
|
|
close(done)
|
|
}()
|
|
buf, err := cr.ReadContext(cancelCtx)
|
|
require.ErrorIs(t, err, context.Canceled)
|
|
require.Empty(t, buf)
|
|
|
|
<-done // wait for write
|
|
buf, err = cr.ReadContext(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, string(buf), "after cancel")
|
|
})
|
|
|
|
t.Run("close ContextReader", func(t *testing.T) {
|
|
go func() {
|
|
time.Sleep(1 * time.Millisecond) // give ReadContext time to block
|
|
assert.NoError(t, cr.Close(), "Close errored")
|
|
}()
|
|
_, err := cr.ReadContext(ctx)
|
|
require.ErrorIs(t, err, ErrReaderClosed)
|
|
|
|
// Subsequent reads fail.
|
|
_, err = cr.ReadContext(ctx)
|
|
require.ErrorIs(t, err, ErrReaderClosed)
|
|
|
|
// Ongoing read after Close is dropped.
|
|
write(t, "unblock goroutine")
|
|
buf, err := cr.ReadContext(ctx)
|
|
assert.ErrorIs(t, err, ErrReaderClosed)
|
|
assert.Empty(t, buf, "buf not empty")
|
|
|
|
// Multiple closes are fine.
|
|
assert.NoError(t, cr.Close(), "2nd Close failed")
|
|
})
|
|
|
|
// Re-creating is safe because the tests above leave no "pending" reads.
|
|
cr = NewContextReader(pr)
|
|
|
|
t.Run("close underlying reader", func(t *testing.T) {
|
|
go func() {
|
|
write(t, "before close")
|
|
pw.CloseWithError(io.EOF)
|
|
}()
|
|
|
|
// Read the last chunk of data successfully.
|
|
buf, err := cr.ReadContext(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, string(buf), "before close")
|
|
|
|
// Next read fails because underlying reader is closed.
|
|
buf, err = cr.ReadContext(ctx)
|
|
require.ErrorIs(t, err, io.EOF)
|
|
require.Empty(t, buf)
|
|
})
|
|
}
|
|
|
|
func TestContextReader_ReadPassword(t *testing.T) {
|
|
pr, pw := io.Pipe()
|
|
write := func(t *testing.T, s string) {
|
|
t.Helper()
|
|
_, err := pw.Write([]byte(s))
|
|
assert.NoError(t, err, "Write failed")
|
|
}
|
|
|
|
devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0666)
|
|
require.NoError(t, err, "Failed to open %v", os.DevNull)
|
|
defer devNull.Close()
|
|
|
|
term := &fakeTerm{reader: pr}
|
|
cr := NewContextReader(pr)
|
|
cr.term = term
|
|
cr.fd = int(devNull.Fd()) // arbitrary, doesn't matter because term functions are mocked.
|
|
|
|
ctx := context.Background()
|
|
t.Run("read password", func(t *testing.T) {
|
|
const want = "llama45"
|
|
go write(t, want)
|
|
|
|
got, err := cr.ReadPassword(ctx)
|
|
require.NoError(t, err, "ReadPassword failed")
|
|
assert.Equal(t, want, string(got), "ReadPassword mismatch")
|
|
})
|
|
|
|
t.Run("intertwine reads", func(t *testing.T) {
|
|
const want1 = "hello, world"
|
|
go write(t, want1)
|
|
got, err := cr.ReadPassword(ctx)
|
|
require.NoError(t, err, "ReadPassword failed")
|
|
assert.Equal(t, want1, string(got), "ReadPassword mismatch")
|
|
|
|
const want2 = "goodbye, world"
|
|
go write(t, want2)
|
|
got, err = cr.ReadContext(ctx)
|
|
require.NoError(t, err, "ReadContext failed")
|
|
assert.Equal(t, want2, string(got), "ReadContext mismatch")
|
|
})
|
|
|
|
t.Run("password read turned clean", func(t *testing.T) {
|
|
require.False(t, term.restoreCalled, "restoreCalled sanity check failed")
|
|
|
|
// Give ReadPassword time to block.
|
|
cancelCtx, cancel := context.WithTimeout(ctx, 1*time.Millisecond)
|
|
defer cancel()
|
|
got, err := cr.ReadPassword(cancelCtx)
|
|
require.ErrorIs(t, err, context.DeadlineExceeded, "ReadPassword returned unexpected error")
|
|
require.Empty(t, got, "ReadPassword mismatch")
|
|
|
|
// Reclaim as clean read.
|
|
const want = "abandoned pwd read"
|
|
go func() {
|
|
// Once again, give ReadContext time to block.
|
|
// This way we force a restore.
|
|
time.Sleep(1 * time.Millisecond)
|
|
write(t, want)
|
|
}()
|
|
got, err = cr.ReadContext(ctx)
|
|
require.NoError(t, err, "ReadContext failed")
|
|
assert.Equal(t, want, string(got), "ReadContext mismatch")
|
|
assert.True(t, term.restoreCalled, "term.Restore not called")
|
|
})
|
|
|
|
t.Run("Close", func(t *testing.T) {
|
|
require.NoError(t, cr.Close(), "Close errored")
|
|
|
|
_, err := cr.ReadPassword(ctx)
|
|
require.ErrorIs(t, err, ErrReaderClosed, "ReadPassword returned unexpected error")
|
|
})
|
|
}
|
|
|
|
func TestNotifyExit_restoresTerminal(t *testing.T) {
|
|
oldStdin := Stdin()
|
|
t.Cleanup(func() { SetStdin(oldStdin) })
|
|
|
|
pr, _ := io.Pipe()
|
|
|
|
devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0666)
|
|
require.NoError(t, err, "Failed to open %v", os.DevNull)
|
|
defer devNull.Close()
|
|
|
|
term := &fakeTerm{reader: pr}
|
|
ctx := context.Background()
|
|
|
|
tests := []struct {
|
|
name string
|
|
doRead func(ctx context.Context, cr *ContextReader) error
|
|
wantRestore bool
|
|
}{
|
|
{
|
|
name: "no pending read",
|
|
doRead: func(ctx context.Context, cr *ContextReader) error {
|
|
<-ctx.Done()
|
|
return ctx.Err()
|
|
},
|
|
},
|
|
{
|
|
name: "pending clean read",
|
|
doRead: func(ctx context.Context, cr *ContextReader) error {
|
|
_, err := cr.ReadContext(ctx)
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "pending password read",
|
|
doRead: func(ctx context.Context, cr *ContextReader) error {
|
|
_, err := cr.ReadPassword(ctx)
|
|
return err
|
|
},
|
|
wantRestore: true,
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
term.restoreCalled = false // reset state between tests
|
|
|
|
cr := NewContextReader(pr)
|
|
cr.term = term
|
|
cr.fd = int(devNull.Fd()) // arbitrary
|
|
SetStdin(cr)
|
|
|
|
// Give the read time to block.
|
|
ctx, cancel := context.WithTimeout(ctx, 1*time.Millisecond)
|
|
defer cancel()
|
|
err := test.doRead(ctx, cr)
|
|
require.ErrorIs(t, err, context.DeadlineExceeded, "unexpected read error")
|
|
|
|
NotifyExit() // closes Stdin
|
|
assert.Equal(t, test.wantRestore, term.restoreCalled, "term.Restore mismatch")
|
|
})
|
|
}
|
|
}
|
|
|
|
type fakeTerm struct {
|
|
reader io.Reader
|
|
restoreCalled bool
|
|
}
|
|
|
|
func (t *fakeTerm) GetState(fd int) (*term.State, error) {
|
|
return &term.State{}, nil
|
|
}
|
|
|
|
func (t *fakeTerm) IsTerminal(fd int) bool {
|
|
return true
|
|
}
|
|
|
|
func (t *fakeTerm) ReadPassword(fd int) ([]byte, error) {
|
|
const bufLen = 1024 // arbitrary, big enough for test data
|
|
data := make([]byte, bufLen)
|
|
n, err := t.reader.Read(data)
|
|
data = data[:n]
|
|
return data, err
|
|
}
|
|
|
|
func (t *fakeTerm) Restore(fd int, oldState *term.State) error {
|
|
t.restoreCalled = true
|
|
return nil
|
|
}
|