Files
kandev/apps/backend/internal/gateway/websocket/terminal_handler_test.go
T
João Salavisa 3746a30126 fix: enforce origin validation on websocket upgrades (#1698)
* fix: enforce origin validation on websocket upgrades

The gateway /ws upgrader hardcoded CheckOrigin to true, so any web page
could open a socket to the local backend and drive session/shell actions
(cross-site WebSocket hijacking → RCE on the host).

Both upgraders now share checkWebSocketOrigin: no Origin header is
allowed (non-browser clients), same-hostname and loopback↔loopback
origins are allowed, everything else is rejected. Replaces the terminal
handler's prefix-based check, which accepted origins like
http://localhost.attacker.tld.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor: share origin trust policy between cors and websocket checks

Review feedback on #1698: the CORS middleware and the WebSocket upgrader
carried two copies of the same origin policy, which could silently
diverge. Both now delegate to httpmw.AllowedOrigin, which also rejects
URL components that never appear in a browser Origin header (userinfo,
path, query, fragment, opaque data).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-15 01:13:46 +01:00

259 lines
6.8 KiB
Go

package websocket
import (
"bytes"
"context"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"time"
"github.com/gin-gonic/gin"
agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events/bus"
)
func TestStripTerminalResponses(t *testing.T) {
tests := []struct {
name string
input []byte
want []byte
}{
{
name: "no sequences",
input: []byte("hello world\r\n$ "),
want: []byte("hello world\r\n$ "),
},
{
name: "empty input",
input: []byte{},
want: []byte{},
},
{
name: "OSC 11 response with ESC backslash",
input: []byte("\x1b]11;rgb:1f1f/1f1f/1f1f\x1b\\"),
want: []byte{},
},
{
name: "OSC 11 response with BEL",
input: []byte("\x1b]11;rgb:1f1f/1f1f/1f1f\x07"),
want: []byte{},
},
{
name: "DA1 response",
input: []byte("\x1b[?1;2c"),
want: []byte{},
},
{
name: "DA1 response with multiple params",
input: []byte("\x1b[?64;1;2;6;22c"),
want: []byte{},
},
{
name: "CPR response row;col",
input: []byte("\x1b[5;1R"),
want: []byte{},
},
{
name: "CPR response row only",
input: []byte("\x1b[1R"),
want: []byte{},
},
{
name: "only responses produces empty",
input: []byte("\x1b]11;rgb:1f1f/1f1f/1f1f\x1b\\\x1b[?1;2c\x1b[5;1R"),
want: []byte{},
},
{
name: "mixed content preserves normal output",
input: []byte("$ ls\r\nfile.txt\r\n\x1b]11;rgb:0000/0000/0000\x1b\\\x1b[?1;2c$ "),
want: []byte("$ ls\r\nfile.txt\r\n$ "),
},
{
name: "sequences between normal text",
input: []byte("before\x1b[24;80Rafter"),
want: []byte("beforeafter"),
},
{
name: "multiple OSC 11 responses",
input: []byte("\x1b]11;rgb:1f1f/1f1f/1f1f\x1b\\\x1b]11;rgb:ffff/ffff/ffff\x07"),
want: []byte{},
},
{
name: "preserves other escape sequences",
input: []byte("\x1b[32mgreen\x1b[0m \x1b[?1;2c normal"),
want: []byte("\x1b[32mgreen\x1b[0m normal"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := stripTerminalResponses(tt.input)
if !bytes.Equal(got, tt.want) {
t.Errorf("stripTerminalResponses(%q)\n got: %q\nwant: %q", tt.input, got, tt.want)
}
})
}
}
func TestParseTerminalRoute(t *testing.T) {
tests := []struct {
name string
target string
wantKind string
wantID string
}{
{name: "environment route", target: "/environment/env-1", wantKind: terminalRouteEnvironment, wantID: "env-1"},
{name: "session route", target: "/session/session-1", wantKind: terminalRouteSession, wantID: "session-1"},
{name: "unknown route", target: "/unknown-target", wantKind: "", wantID: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseTerminalRoute(&gin.Context{Params: gin.Params{{Key: "target", Value: tt.target}}})
if got.kind != tt.wantKind || got.id != tt.wantID {
t.Fatalf("parseTerminalRoute() = {%q %q}, want {%q %q}",
got.kind, got.id, tt.wantKind, tt.wantID)
}
})
}
}
func TestSessionTerminalRouteRequiresAgentMode(t *testing.T) {
tests := []struct {
name string
query string
}{
{name: "missing mode", query: ""},
{name: "shell mode", query: "?mode=shell"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/terminal/session/session-1"+tt.query, nil)
handler := &TerminalHandler{}
handler.handleSessionTerminalRoute(c, "session-1")
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusBadRequest)
}
var body map[string]string
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body["error"] != "session terminal route requires mode=agent; shell terminals must use /terminal/environment/:environmentId" {
t.Fatalf("error = %q", body["error"])
}
})
}
}
func TestWaitForRemoteExecutionReadyRechecksReplacedExecution(t *testing.T) {
log := testTerminalLogger(t)
manager := lifecycle.NewManager(
nil,
bus.NewMemoryEventBus(log),
nil,
nil,
nil,
nil,
lifecycle.ExecutorFallbackDeny,
t.TempDir(),
log,
)
handler := NewTerminalHandler(manager, nil, nil, log)
staleServer := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
}))
defer staleServer.Close()
readyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer readyServer.Close()
oldExecution := testRemoteExecution(t, staleServer.URL, "exec-old", "session-1", "env-1", log)
newExecution := testRemoteExecution(t, readyServer.URL, "exec-new", "session-1", "env-1", log)
if err := manager.ExecutionStoreForTesting().Add(oldExecution); err != nil {
t.Fatalf("add old execution: %v", err)
}
go func() {
time.Sleep(100 * time.Millisecond)
manager.RemoveExecution(oldExecution.ID)
if err := manager.ExecutionStoreForTesting().Add(newExecution); err != nil {
t.Errorf("add new execution: %v", err)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
got, ok := handler.waitForRemoteExecutionReadyWithTimeout(ctx, "session-1", 1500*time.Millisecond)
if !ok {
t.Fatal("waitForRemoteExecutionReadyWithTimeout returned not ready")
}
if got.ID != newExecution.ID {
t.Fatalf("execution ID = %q, want %q", got.ID, newExecution.ID)
}
}
func testRemoteExecution(
t *testing.T,
serverURL string,
executionID string,
sessionID string,
taskEnvironmentID string,
log *logger.Logger,
) *lifecycle.AgentExecution {
t.Helper()
parsed, err := url.Parse(serverURL)
if err != nil {
t.Fatalf("parse server URL: %v", err)
}
host, portString, err := net.SplitHostPort(parsed.Host)
if err != nil {
t.Fatalf("split server host/port: %v", err)
}
port, err := strconv.Atoi(portString)
if err != nil {
t.Fatalf("parse server port: %v", err)
}
instance := &lifecycle.ExecutorInstance{
InstanceID: executionID,
Client: agentctlclient.NewClient(host, port, log),
RuntimeName: "docker",
WorkspacePath: "/workspace",
}
return instance.ToAgentExecution(&lifecycle.ExecutorCreateRequest{
TaskID: "task-1",
SessionID: sessionID,
TaskEnvironmentID: taskEnvironmentID,
WorkspacePath: "/workspace",
})
}
func testTerminalLogger(t *testing.T) *logger.Logger {
t.Helper()
log, err := logger.NewLogger(logger.LoggingConfig{
Level: "error",
Format: "json",
OutputPath: t.TempDir() + "/test.log",
})
if err != nil {
t.Fatalf("create logger: %v", err)
}
return log
}