Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cmd/crabbox-ssh-gateway/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -706,7 +706,7 @@ func safeError(err error) string {
if err == nil {
return ""
}
return fleettext.Safe(err.Error())
return fleettext.Safe(fleettext.SafeMultiline(err.Error()))
}

func printList(out io.Writer, state fleetapi.State) {
Expand Down Expand Up @@ -930,7 +930,7 @@ func attach(
) uint32 {
err := api.Attach(ctx, id, terminal, pty.cols, pty.rows, pty.resizes)
if err != nil && !errors.Is(err, net.ErrClosed) && !strings.Contains(err.Error(), "closed") {
fmt.Fprintf(terminal, "\nattach closed: %v\n", err)
fmt.Fprintf(terminal, "\nattach closed: %s\n", safeError(err))
return 1
}
return 0
Expand Down
100 changes: 99 additions & 1 deletion cmd/crabbox-ssh-gateway/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,19 @@ import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/binary"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"sync"
"testing"
"time"

"github.com/coder/websocket"
"github.com/openclaw/crabfleet/internal/fleetapi"
"golang.org/x/crypto/ssh"
)
Expand Down Expand Up @@ -708,11 +712,59 @@ func TestRunCommandSanitizesControlPlaneErrors(t *testing.T) {
if strings.ContainsAny(got, "\x1b\x07") {
t.Fatalf("error output retained terminal controls: %q", got)
}
if !strings.Contains(got, "bad]52;c;secretstate") {
if strings.Contains(got, "secret") || strings.Contains(got, "]52") {
t.Fatalf("error output retained terminal payload: %q", got)
}
if !strings.Contains(got, "badstate") {
t.Fatalf("error output = %q", got)
}
}

func TestAttachSanitizesTerminalErrors(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/terminal/ws" {
t.Errorf("path = %q", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
conn, err := websocket.Accept(w, r, nil)
if err != nil {
t.Error(err)
return
}
defer conn.Close(websocket.StatusNormalClosure, "")

for range 2 {
if _, _, err := conn.Read(r.Context()); err != nil {
t.Error(err)
return
}
}
subscribed, _ := json.Marshal(map[string]any{"type": "subscribed", "canInput": true})
if err := conn.Write(r.Context(), websocket.MessageBinary, testTerminalFrame(22, "IS-7", subscribed)); err != nil {
t.Error(err)
return
}
failure, _ := json.Marshal(map[string]string{"error": "bad\x1b]52;c;secret\x07state\x1b[31m"})
_ = conn.Write(r.Context(), websocket.MessageBinary, testTerminalFrame(23, "IS-7", failure))
}))
defer server.Close()

client := fleetapi.NewClient(server.URL, server.Client(), fleetapi.SSHAuth("gateway-token", "SHA256:test"))
terminal := newBlockingTestTerminal()
exit := attach(context.Background(), terminal, client, "IS-7", sessionPTY{cols: 80, rows: 24})
if exit != 1 {
t.Fatalf("exit=%d output=%q", exit, terminal.String())
}
output := terminal.String()
if strings.ContainsAny(output, "\x1b\x07") || strings.Contains(output, "secret") || strings.Contains(output, "]52") {
t.Fatalf("attach output retained terminal controls: %q", output)
}
if !strings.Contains(output, "attach closed: badstate") {
t.Fatalf("attach output = %q", output)
}
}

func TestRunCommandSanitizesLinkURL(t *testing.T) {
permissions := &ssh.Permissions{Extensions: map[string]string{
"authorized": "false",
Expand All @@ -731,6 +783,52 @@ func TestRunCommandSanitizesLinkURL(t *testing.T) {
}
}

type blockingTestTerminal struct {
mu sync.Mutex
output bytes.Buffer
done chan struct{}
}

func newBlockingTestTerminal() *blockingTestTerminal {
return &blockingTestTerminal{done: make(chan struct{})}
}

func (t *blockingTestTerminal) Read(_ []byte) (int, error) {
<-t.done
return 0, io.EOF
}

func (t *blockingTestTerminal) Write(data []byte) (int, error) {
t.mu.Lock()
defer t.mu.Unlock()
return t.output.Write(data)
}

func (t *blockingTestTerminal) CancelRead() error {
close(t.done)
return nil
}

func (t *blockingTestTerminal) String() string {
t.mu.Lock()
defer t.mu.Unlock()
return t.output.String()
}

func testTerminalFrame(messageType byte, sessionID string, data []byte) []byte {
session := []byte(sessionID)
payload := make([]byte, 12+len(session)+len(data))
binary.LittleEndian.PutUint16(payload[0:2], 0x5943)
payload[2] = 2
payload[3] = messageType
binary.LittleEndian.PutUint32(payload[4:8], uint32(len(session)))
copy(payload[8:], session)
offset := 8 + len(session)
binary.LittleEndian.PutUint32(payload[offset:offset+4], uint32(len(data)))
copy(payload[offset+4:], data)
return payload
}

func TestTranscriptCommandSanitizesTerminalControls(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/ssh/interactive-sessions/IS-7/transcript" {
Expand Down
2 changes: 1 addition & 1 deletion cmd/crabfleet/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ func TestDoctorSanitizesControlPlaneErrors(t *testing.T) {
if strings.ContainsAny(output, "\x1b\x07") {
t.Fatalf("doctor output contains terminal controls: %q", output)
}
if !strings.Contains(output, "auth: failed: crabfleet API 500 Internal Server Error: bad]52;c;secretstate") {
if !strings.Contains(output, "auth: failed: crabfleet API 500 Internal Server Error: badstate") {
t.Fatalf("doctor output = %q", output)
}
}
Expand Down
66 changes: 64 additions & 2 deletions internal/fleetapi/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ func (c *Client) terminal(ctx context.Context, id string, cols uint32, rows uint
return nil, &StatusError{
StatusCode: statusErr.StatusCode,
Status: statusErr.Status,
Body: statusErr.Body,
Body: sanitizeErrorBody(statusErr.Body),
}
}
return nil, err
Expand Down Expand Up @@ -302,12 +302,74 @@ func responseError(resp *http.Response) error {
return &StatusError{
StatusCode: resp.StatusCode,
Status: resp.Status,
Body: strings.TrimSpace(string(data)),
Body: sanitizeErrorBody(string(data)),
}
}
return nil
}

func sanitizeErrorBody(value string) string {
var out strings.Builder
const (
stateText = iota
stateEscape
stateCSI
stateStringControl
stateStringControlEscape
)
state := stateText
for _, r := range value {
switch state {
case stateText:
switch {
case r == '\x1b':
state = stateEscape
case r == '\x9b':
state = stateCSI
case r == '\x90' || r == '\x9d' || r == '\x9e' || r == '\x9f':
state = stateStringControl
case r == '\n' || r == '\r' || r == '\t':
out.WriteRune(' ')
case isErrorControl(r):
continue
default:
out.WriteRune(r)
}
case stateEscape:
switch r {
case '[':
state = stateCSI
case ']', 'P', '^', '_':
state = stateStringControl
default:
state = stateText
}
case stateCSI:
if r >= 0x40 && r <= 0x7e {
state = stateText
}
case stateStringControl:
switch r {
case '\x07', '\x9c':
state = stateText
case '\x1b':
state = stateStringControlEscape
}
case stateStringControlEscape:
if r == '\\' {
state = stateText
} else if r != '\x1b' {
state = stateStringControl
}
}
}
return strings.TrimSpace(out.String())
}

func isErrorControl(r rune) bool {
return r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f)
}

func readBoundedResponse(body io.Reader) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(body, maxResponseBytes+1))
if len(data) > maxResponseBytes {
Expand Down
42 changes: 42 additions & 0 deletions internal/fleetapi/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,48 @@ func TestClientRejectsIncompleteAuthentication(t *testing.T) {
}
}

func TestClientSanitizesStatusErrorBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("bad\x1b]52;c;secret\x07state\x1b[31m!\nnext"))
}))
defer server.Close()

client := NewClient(server.URL, server.Client(), SSHAuth("gateway-token", "SHA256:test"))
_, err := client.State(context.Background())
if err == nil {
t.Fatal("expected status error")
}
message := err.Error()
if strings.ContainsAny(message, "\x1b\x07") || strings.Contains(message, "secret") || strings.Contains(message, "]52") {
t.Fatalf("error retained terminal controls: %q", message)
}
if !strings.Contains(message, "crabfleet API 500 Internal Server Error: badstate! next") {
t.Fatalf("error = %q", message)
}
}

func TestClientSanitizesTerminalHandshakeStatusErrorBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("terminal\x1b]52;c;secret\x07failed\x1b[31m"))
}))
defer server.Close()

client := NewClient(server.URL, server.Client(), SSHAuth("gateway-token", "SHA256:test"))
err := client.Message(context.Background(), "IS-7", "hello", true, 80, 24)
if err == nil {
t.Fatal("expected terminal status error")
}
message := err.Error()
if strings.ContainsAny(message, "\x1b\x07") || strings.Contains(message, "secret") || strings.Contains(message, "]52") {
t.Fatalf("error retained terminal controls: %q", message)
}
if !strings.Contains(message, "crabfleet API 500 Internal Server Error: terminalfailed") {
t.Fatalf("error = %q", message)
}
}

func TestClientRejectsOversizedJSONResponses(t *testing.T) {
largeLogin := strings.Repeat("a", maxResponseBytes+1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
Expand Down