Files
kandev/apps/backend/internal/gateway/websocket/handler_origin_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

118 lines
3.1 KiB
Go

package websocket
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
gorillaws "github.com/gorilla/websocket"
)
// startWSGateway serves the real /ws route backed by a running hub and returns
// the gateway plus the ws:// URL to dial.
func startWSGateway(t *testing.T) (*Gateway, string) {
t.Helper()
g := NewGateway(newTestProxyLogger())
ctx, cancel := context.WithCancel(context.Background())
hubDone := make(chan struct{})
go func() {
defer close(hubDone)
g.Hub.Run(ctx)
}()
gin.SetMode(gin.TestMode)
router := gin.New()
g.SetupRoutes(router)
srv := httptest.NewServer(router)
t.Cleanup(func() {
srv.Close()
cancel()
<-hubDone
})
return g, "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws"
}
func dialWS(t *testing.T, wsURL, origin string) (*gorillaws.Conn, *http.Response, error) {
t.Helper()
header := http.Header{}
if origin != "" {
header.Set("Origin", origin)
}
conn, resp, err := gorillaws.DefaultDialer.Dial(wsURL, header)
if resp != nil && resp.Body != nil {
t.Cleanup(func() { _ = resp.Body.Close() })
}
return conn, resp, err
}
// waitForNoClients blocks until every connection has unregistered from the hub
// so goleak sees clean read/write pumps at package teardown.
func waitForNoClients(t *testing.T, g *Gateway) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for g.Hub.GetClientCount() != 0 {
if time.Now().After(deadline) {
t.Fatalf("hub still has %d client(s)", g.Hub.GetClientCount())
}
time.Sleep(5 * time.Millisecond)
}
}
// TestHandleConnection_RejectsForeignOrigin is the CSWSH regression test: a
// browser page on an attacker-controlled origin must not be able to complete
// a WebSocket upgrade against the gateway.
func TestHandleConnection_RejectsForeignOrigin(t *testing.T) {
_, wsURL := startWSGateway(t)
conn, resp, err := dialWS(t, wsURL, "https://attacker.example")
if err == nil {
_ = conn.Close()
t.Fatal("expected upgrade with foreign Origin to fail")
}
if resp == nil {
t.Fatalf("expected HTTP response on rejected upgrade, got err=%v", err)
}
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("rejected upgrade status = %d, want %d", resp.StatusCode, http.StatusForbidden)
}
}
// TestHandleConnection_AllowsTrustedOrigins verifies legitimate clients still
// connect: same-origin browsers, loopback dev servers on another port, and
// non-browser clients that send no Origin header.
func TestHandleConnection_AllowsTrustedOrigins(t *testing.T) {
g, wsURL := startWSGateway(t)
sameOrigin := "http" + strings.TrimSuffix(strings.TrimPrefix(wsURL, "ws"), "/ws")
for name, origin := range map[string]string{
"no origin": "",
"same origin": sameOrigin,
"loopback cross port": "http://localhost:5173",
"loopback 127 with port": "http://127.0.0.1:12345",
} {
t.Run(name, func(t *testing.T) {
conn, resp, err := dialWS(t, wsURL, origin)
if err != nil {
status := 0
if resp != nil {
status = resp.StatusCode
}
t.Fatalf("upgrade with origin %q failed (status %d): %v", origin, status, err)
}
_ = conn.Close()
waitForNoClients(t, g)
})
}
}