server/ssh/web/session_test.go
Rama master · réplica de lectura del repositorio original.
package web
import (
"encoding/json"
"io"
"net"
"testing"
"testing/iotest"
"time"
gliderssh "github.com/gliderlabs/ssh"
"github.com/shellhub-io/shellhub/server/ssh/pkg/banner"
"github.com/shellhub-io/shellhub/server/ssh/pkg/challenge"
"github.com/shellhub-io/shellhub/server/ssh/web/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
gossh "golang.org/x/crypto/ssh"
)
type zeroReadNoEOFReader struct{}
func (r *zeroReadNoEOFReader) Read(p []byte) (int, error) {
return 0, nil
}
type singleRead struct {
data []byte
read bool
}
func (r *singleRead) Read(p []byte) (int, error) {
if r.read {
return 0, io.EOF
}
n := copy(p, r.data)
r.read = true
return n, nil
}
func TestRedirToWs_Regression_EndNegative(t *testing.T) {
mock := mocks.NewMockSocket(t)
mock.On("Write", []byte{}).Return(0, nil).Once()
conn := NewConn(mock)
reader := &singleRead{data: []byte{0x80, 0x81, 0x82}}
assert.NotPanics(t, func() {
_ = redirToWs(reader, conn)
}, "redirToWs must not panic when end becomes -1 (all UTF-8 continuation bytes)")
}
func TestRedirToWs_Regression_ZeroReadThenEOF(t *testing.T) {
conn := &Conn{
Socket: mocks.NewMockSocket(t),
}
reader := iotest.TimeoutReader(&zeroReadNoEOFReader{})
assert.NotPanics(t, func() {
_ = redirToWs(reader, conn)
}, "expected redirToWs to handle zero read without panicking")
}
// TestBannerNewBannerErrorSetsKind verifies that NewBannerError populates the Kind
// by calling banner.Classify on the message at construction time.
func TestBannerNewBannerErrorSetsKind(t *testing.T) {
cases := []struct {
description string
kind banner.Kind
}{
{
description: "KindConnectionFailed",
kind: banner.KindConnectionFailed,
},
{
description: "KindAccessDenied",
kind: banner.KindAccessDenied,
},
{
description: "KindInvalidSSHID",
kind: banner.KindInvalidSSHID,
},
}
for _, tc := range cases {
t.Run(tc.description, func(t *testing.T) {
msg := banner.Message(tc.kind)
e := NewBannerError(msg)
assert.Equal(t, tc.kind, e.Kind())
assert.Equal(t, msg, e.Message, "NewBannerError must preserve the raw message verbatim")
assert.Equal(t, msg, e.Error(), "Error() must return the raw message verbatim")
})
}
}
// TestBannerNewBannerErrorUnrecognizedSetsKindNone verifies that an unrecognized
// banner string yields KindNone, and that mapBannerError then returns ErrConnect —
// the production-critical path that prevents arbitrary banner text from leaking to
// web clients.
func TestBannerNewBannerErrorUnrecognizedSetsKindNone(t *testing.T) {
e := NewBannerError("totally unknown banner text")
require.Equal(t, banner.KindNone, e.Kind())
got := mapBannerError(e)
assert.ErrorIs(t, got, ErrConnect, "expected ErrConnect for unrecognized banner, got %v", got)
}
// TestMapBannerErrorEmptyMessage verifies that mapBannerError returns ErrConnect
// for a BannerError with an empty Message (KindNone with no text). This is the
// default branch of mapBannerError when no log entry is emitted, which is
// distinct from the non-empty-unknown-text path in
// TestBannerNewBannerErrorUnrecognizedSetsKindNone.
func TestMapBannerErrorEmptyMessage(t *testing.T) {
e := NewBannerError("")
require.Equal(t, banner.KindNone, e.Kind())
got := mapBannerError(e)
assert.ErrorIs(t, got, ErrConnect, "expected ErrConnect for empty-message BannerError, got %v", got)
}
func dialWithBanner(t *testing.T, bannerText string) error {
t.Helper()
l, err := new(net.ListenConfig).Listen(t.Context(), "tcp", "127.0.0.1:0")
require.NoError(t, err)
srv := &gliderssh.Server{
BannerHandler: func(_ gliderssh.Context) string {
return bannerText
},
Handler: func(s gliderssh.Session) {},
}
go srv.Serve(l) //nolint:errcheck
t.Cleanup(func() {
srv.Close() //nolint:errcheck
l.Close() //nolint:errcheck
})
_, dialErr := gossh.Dial("tcp", l.Addr().String(), &gossh.ClientConfig{ //nolint:exhaustruct
User: "test",
Auth: []gossh.AuthMethod{gossh.Password("test")},
HostKeyCallback: gossh.InsecureIgnoreHostKey(), //nolint:gosec
BannerCallback: bannerCallback(),
})
return dialErr
}
// TestBannerKindMapsToSentinel verifies that each banner.Kind maps to the expected
// sentinel error via the production path: a real SSH dial with a BannerCallback
// returning NewBannerError, followed by errors.As extraction and mapBannerError.
// This mirrors exactly what newSession does at session.go:173-189.
func TestBannerKindMapsToSentinel(t *testing.T) {
cases := []struct {
description string
kind banner.Kind
want error
}{
{
description: "KindConnectionFailed maps to ErrConnect",
kind: banner.KindConnectionFailed,
want: ErrConnect,
},
{
description: "KindAccessDenied maps to ErrAccessDenied",
kind: banner.KindAccessDenied,
want: ErrAccessDenied,
},
{
description: "KindInvalidSSHID maps to ErrInvalidSSHID",
kind: banner.KindInvalidSSHID,
want: ErrInvalidSSHID,
},
{
description: "KindNone (empty banner) produces no BannerError and the dial succeeds",
kind: banner.KindNone,
},
}
for _, tc := range cases {
t.Run(tc.description, func(t *testing.T) {
dialErr := dialWithBanner(t, banner.Message(tc.kind))
var e *BannerError
if tc.kind == banner.KindNone {
require.NoError(t, dialErr, "expected dial to succeed when banner is empty")
assert.NotErrorAs(t, dialErr, &e, "expected no BannerError for empty banner")
return
}
require.ErrorAs(t, dialErr, &e, "expected errors.As to extract *BannerError from dial error, got: %v", dialErr)
assert.Equal(t, tc.kind, e.Kind())
got := mapBannerError(e)
assert.ErrorIs(t, got, tc.want, "expected %v, got %v", tc.want, got)
})
}
}
// TestApprovalChallengeCarriesTheCodeToTheBrowser pins the contract between the
// gateway and this bridge: the gateway puts the approval code in the challenge's
// question, and the bridge forwards exactly that to the browser and answers with
// whatever the browser sends back.
//
// It calls the bridge's challenge callback directly. The gateway half of this
// contract is pinned separately, by TestWebChallengeCarriesTheCodeInTheQuestion
// in the auth package; neither catches a field mismatch alone.
func TestApprovalChallengeCarriesTheCodeToTheBrowser(t *testing.T) {
const code = "WXYZ2K7Q"
browser, bridge := net.Pipe()
t.Cleanup(func() {
browser.Close() //nolint:errcheck
bridge.Close() //nolint:errcheck
})
conn := &Conn{Socket: bridge}
answered := make(chan []string, 1)
go func() {
answers, err := approvalChallenge(conn, new(refusal))(challenge.Approval, "", []string{code}, []bool{true})
if err != nil {
close(answered)
return
}
answered <- answers
}()
forwarded := struct {
Kind messageKind `json:"kind"`
Data string `json:"data"`
}{}
require.NoError(t, json.NewDecoder(browser).Decode(&forwarded))
assert.Equal(t, messageKindReauth, forwarded.Kind, "the browser is told an approval is waiting")
assert.Equal(t, code, forwarded.Data, "the code has to survive the trip, or the modal opens on nothing")
_, err := (&Conn{Socket: browser}).WriteMessage(&Message{Kind: messageKindReauthDone, Data: "CONF7788"})
require.NoError(t, err)
select {
case answers, ok := <-answered:
require.True(t, ok, "the challenge failed instead of answering")
require.Len(t, answers, 1)
assert.Equal(t, "CONF7788", answers[0], "what the person confirmed is what answers the challenge")
case <-time.After(5 * time.Second):
require.Fail(t, "the bridge never answered the challenge")
}
}
// TestApprovalChallengeRefusesAnEmptyCode covers the shape of the bug this test
// file exists to catch: a gateway that names the challenge correctly but leaves
// the code out would otherwise open a modal on an empty string.
func TestApprovalChallengeRefusesAnEmptyCode(t *testing.T) {
_, bridge := net.Pipe()
t.Cleanup(func() { bridge.Close() }) //nolint:errcheck
_, err := approvalChallenge(&Conn{Socket: bridge}, new(refusal))(challenge.Approval, "", []string{""}, []bool{true})
require.ErrorIs(t, err, ErrApprovalCodeMissing)
}
// TestDeniedChallengeIsNotAnApproval verifies the bridge does not treat a
// refusal as something the person can answer: a modal opened here would wait on
// a decision the gateway has already made.
func TestDeniedChallengeIsNotAnApproval(t *testing.T) {
_, bridge := net.Pipe()
t.Cleanup(func() { bridge.Close() }) //nolint:errcheck
answers, err := approvalChallenge(&Conn{Socket: bridge}, new(refusal))(challenge.Denied, "a reason", []string{""}, []bool{false})
require.NoError(t, err)
assert.Equal(t, []string{""}, answers, "a refusal is answered with nothing, not forwarded to the browser")
}
// TestDeniedChallengeCarriesTheReasonToTheBrowser is why the refusal is recorded
// rather than only logged. A refusal has no answer the gateway reads, so the
// dial that follows fails as a plain authentication error, and the browser was
// being told its key is not authorized for the device on a login the person had
// just rejected themselves.
func TestDeniedChallengeCarriesTheReasonToTheBrowser(t *testing.T) {
_, bridge := net.Pipe()
t.Cleanup(func() { bridge.Close() }) //nolint:errcheck
refused := new(refusal)
require.NoError(t, refused.Err(), "a login nobody refused carries no reason")
_, err := approvalChallenge(&Conn{Socket: bridge}, refused)(
challenge.Denied, "This login was rejected in the console.", []string{""}, []bool{false})
require.NoError(t, err)
got := refused.Err()
require.ErrorIs(t, got, ErrApprovalRefused)
assert.Equal(t, "the login approval was refused: This login was rejected in the console.", got.Error(),
"the console matches this exact prefix to render the reason, so the whole string is the contract")
}