Ctrl K
ComunidadGuías…

server/ssh/web/session_test.go

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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")
}