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
2 changes: 1 addition & 1 deletion cmd/crabbox-ssh-gateway/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -669,7 +669,7 @@ func runCommand(ctx context.Context, out io.ReadWriter, perms *ssh.Permissions,
fmt.Fprintf(out, "%s/app/\n", client.baseURL)
return 0
default:
fmt.Fprintf(out, "unknown command: %s\n\n", args[0])
fmt.Fprintf(out, "unknown command: %s\n\n", fleettext.Safe(fleettext.SafeMultiline(args[0])))
printHelp(out, auth.user)
return 2
}
Expand Down
22 changes: 22 additions & 0 deletions cmd/crabbox-ssh-gateway/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,28 @@ func TestRunCommandSanitizesControlPlaneErrors(t *testing.T) {
}
}

func TestRunCommandSanitizesUnknownCommand(t *testing.T) {
permissions := &ssh.Permissions{Extensions: map[string]string{
"authorized": "true",
"fingerprint": "SHA256:test",
"login": "operator",
"role": "owner",
}}
client := &apiClient{baseURL: "https://example.test", token: "gateway-token", client: http.DefaultClient}
var output bytes.Buffer
exit := runCommand(context.Background(), &output, permissions, client, "bad\x1b]52;c;secret\x07cmd", sessionPTY{})
if exit != 2 {
t.Fatalf("exit=%d output=%q", exit, output.String())
}
got := output.String()
if strings.ContainsAny(got, "\x1b\x07") || strings.Contains(got, "secret") || strings.Contains(got, "]52") {
t.Fatalf("unknown command retained terminal controls: %q", got)
}
if !strings.HasPrefix(got, "unknown command: badcmd\n") {
t.Fatalf("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" {
Expand Down
67 changes: 63 additions & 4 deletions cmd/crabfleet/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"bytes"
"context"
"crypto/x509"
"encoding/json"
Expand All @@ -24,6 +25,7 @@ import (
const defaultAPIURL = "https://crabfleet.openclaw.ai"
const defaultSSHHost = "crabd.sh"
const maxMessageBytes = 64 * 1024
const maxSSHOutputBytes = 64 * 1024

var version = "dev"

Expand Down Expand Up @@ -635,10 +637,67 @@ func runSSHOutput(app *cli, args ...string) (string, error) {
if err != nil {
return "", err
}
cmd := exec.Command("ssh", sshArgs...)
cmd.Stderr = os.Stderr
output, err := cmd.Output()
return string(output), err
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cmd := exec.CommandContext(ctx, "ssh", sshArgs...)
stdout := newLimitedSSHOutput("ssh output", cancel)
stderr := newLimitedSSHOutput("ssh stderr", cancel)
cmd.Stdout = stdout
cmd.Stderr = stderr
err = cmd.Run()
if stderr.Len() > 0 {
fmt.Fprint(os.Stderr, fleettext.SafeMultiline(stderr.String()))
}
if stdout.Overflowed() {
return "", stdout.OverflowError()
}
if stderr.Overflowed() {
return "", stderr.OverflowError()
}
return stdout.String(), err
}

type limitedSSHOutput struct {
label string
cancel context.CancelFunc
buffer bytes.Buffer
overflow bool
}

func newLimitedSSHOutput(label string, cancel context.CancelFunc) *limitedSSHOutput {
return &limitedSSHOutput{label: label, cancel: cancel}
}

func (o *limitedSSHOutput) Write(data []byte) (int, error) {
if o.overflow {
return 0, o.OverflowError()
}
remaining := maxSSHOutputBytes - o.buffer.Len()
if len(data) > remaining {
if remaining > 0 {
_, _ = o.buffer.Write(data[:remaining])
}
o.overflow = true
o.cancel()
return remaining, o.OverflowError()
}
return o.buffer.Write(data)
}

func (o *limitedSSHOutput) Len() int {
return o.buffer.Len()
}

func (o *limitedSSHOutput) String() string {
return o.buffer.String()
}

func (o *limitedSSHOutput) Overflowed() bool {
return o.overflow
}

func (o *limitedSSHOutput) OverflowError() error {
return fmt.Errorf("%s exceeds %d bytes", o.label, maxSSHOutputBytes)
}

func sshInvocationArgs(app *cli, args ...string) ([]string, error) {
Expand Down
25 changes: 25 additions & 0 deletions cmd/crabfleet/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
Expand Down Expand Up @@ -237,6 +238,18 @@ func installOutputSSH(t *testing.T, output string) {
t.Setenv("SSH_OUTPUT", output)
}

func installLargeOutputSSH(t *testing.T, bytes int) {
t.Helper()
dir := t.TempDir()
sshPath := filepath.Join(dir, "ssh")
script := "#!/bin/sh\nyes x | tr -d '\\n' | head -c \"$SSH_OUTPUT_BYTES\"\n"
if err := os.WriteFile(sshPath, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv("SSH_OUTPUT_BYTES", fmt.Sprintf("%d", bytes))
}

func readFakeSSHArgs(t *testing.T, argsPath string) string {
t.Helper()
data, err := os.ReadFile(argsPath)
Expand Down Expand Up @@ -464,6 +477,18 @@ func TestNewVNCFallbackValidatesCapturedURL(t *testing.T) {
}
}

func TestVNCFallbackRejectsOversizedCapturedSSHOutput(t *testing.T) {
installLargeOutputSSH(t, maxSSHOutputBytes+1)
app := &cli{SSHHost: "crabd.test"}
output, err := runSSHOutput(app, "vnc", "IS-7")
if err == nil || !strings.Contains(err.Error(), "ssh output exceeds") {
t.Fatalf("output length=%d error=%v", len(output), err)
}
if output != "" {
t.Fatalf("output length=%d, want empty", len(output))
}
}

func TestFirstLineSkipsBlankLines(t *testing.T) {
if got, want := firstLine("\n\n https://example.com/vnc\nignored\n"), "https://example.com/vnc"; got != want {
t.Fatalf("firstLine = %q, want %q", got, want)
Expand Down