Require explicit websocket origin continuity

This commit is contained in:
rcourtman 2026-04-22 04:46:13 +01:00
parent 14fc2bd4f0
commit ccb2edc3b8
19 changed files with 410 additions and 107 deletions

View file

@ -788,6 +788,13 @@ workspace is opened through the control plane. That proxy-trust boundary must
also reject wildcard trust ranges such as `0.0.0.0/0` or `::/0` at startup,
and agent-adjacent forwarded-header reads must fail closed if invalid wildcard
proxy trust configuration is present.
That same lifecycle-owned command websocket now derives an explicit
same-origin HTTP `Origin` header for `/api/agent/ws` from the canonical Pulse
base URL through `internal/securityutil/websocket_origin.go`, and the agent
receiver must reject missing or cross-host origins before registration.
Runtime command sockets therefore stay on the same fail-closed host/proxy
continuity contract as the browser websocket path instead of accepting
originless upgrades.
That same shared helper layer also now assumes the Pulse Mobile relay runtime
credential reaches only the explicit backend-owned route inventory, so
lifecycle-adjacent setup and install flows cannot accidentally widen the

View file

@ -679,8 +679,11 @@ because the backend hop is plain HTTP. Forwarded host/proto headers may extend
that same-origin boundary only after explicit trusted proxy CIDRs are injected,
so hosted tenants and proxies that rewrite hostnames still fail closed onto the
trusted forwarded-origin contract instead of weakening cross-site websocket
checks. `PULSE_TRUSTED_PROXY_CIDRS` must also reject wildcard trust ranges such
as `0.0.0.0/0` or `::/0` at startup, while runtime forwarded-header parsing
checks. Browser-facing websocket upgrades must also require an explicit
`Origin` header even when `allowedOrigins` is wildcarded, so missing-origin
requests cannot silently bypass the cross-site websocket boundary.
`PULSE_TRUSTED_PROXY_CIDRS` must also reject wildcard trust ranges such as
`0.0.0.0/0` or `::/0` at startup, while runtime forwarded-header parsing
fails closed if an invalid wildcard proxy trust range somehow reaches the
process.
That same shared boundary now also owns outbound SSO metadata and discovery

View file

@ -5,9 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
@ -15,6 +13,7 @@ import (
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rs/zerolog/log"
)
@ -232,35 +231,10 @@ func validateReadFilePayload(req *ReadFilePayload) error {
func isAllowedWebSocketOrigin(r *http.Request) bool {
origin := strings.TrimSpace(r.Header.Get("Origin"))
if origin == "" {
// Non-browser clients (expected for agents) usually omit Origin.
return true
}
parsed, err := url.Parse(origin)
if err != nil || parsed.Host == "" {
return false
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return false
}
return normalizeOriginHost(parsed.Host) == normalizeOriginHost(r.Host)
}
func normalizeOriginHost(host string) string {
normalized := strings.TrimSpace(strings.ToLower(host))
if normalized == "" {
return normalized
}
parsedHost, parsedPort, err := net.SplitHostPort(normalized)
if err != nil {
return normalized
}
if parsedPort == "80" || parsedPort == "443" {
return parsedHost
}
return net.JoinHostPort(parsedHost, parsedPort)
return securityutil.SameHostWebSocketOrigin(origin, r.Host)
}
// HandleWebSocket handles incoming WebSocket connections from agents

View file

@ -40,7 +40,7 @@ func newConnPair(t *testing.T) (*websocket.Conn, *websocket.Conn, func()) {
serverConnCh <- conn
}))
clientConn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(ts.URL), nil)
clientConn, _, err := dialAgentExecWebSocket(t, ts.URL)
if err != nil {
ts.Close()
t.Fatalf("Dial: %v", err)
@ -75,7 +75,7 @@ func TestHandleWebSocket_RegistrationReadError(t *testing.T) {
ts := newWSServer(t, s)
defer ts.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(ts.URL), nil)
conn, _, err := dialAgentExecWebSocket(t, ts.URL)
if err != nil {
t.Fatalf("Dial: %v", err)
}
@ -87,7 +87,7 @@ func TestHandleWebSocket_RegistrationMessageJSONError(t *testing.T) {
ts := newWSServer(t, s)
defer ts.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(ts.URL), nil)
conn, _, err := dialAgentExecWebSocket(t, ts.URL)
if err != nil {
t.Fatalf("Dial: %v", err)
}
@ -108,7 +108,7 @@ func TestHandleWebSocket_RegistrationPayloadMissing(t *testing.T) {
ts := newWSServer(t, s)
defer ts.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(ts.URL), nil)
conn, _, err := dialAgentExecWebSocket(t, ts.URL)
if err != nil {
t.Fatalf("Dial: %v", err)
}
@ -127,7 +127,7 @@ func TestHandleWebSocket_RegistrationPayloadUnmarshalError(t *testing.T) {
ts := newWSServer(t, s)
defer ts.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(ts.URL), nil)
conn, _, err := dialAgentExecWebSocket(t, ts.URL)
if err != nil {
t.Fatalf("Dial: %v", err)
}
@ -154,7 +154,7 @@ func TestHandleWebSocket_InvalidTokenRejectionSendFailure(t *testing.T) {
ts := newWSServer(t, s)
defer ts.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(ts.URL), nil)
conn, _, err := dialAgentExecWebSocket(t, ts.URL)
if err != nil {
t.Fatalf("Dial: %v", err)
}
@ -184,7 +184,7 @@ func TestHandleWebSocket_RegistrationAckSendFailure(t *testing.T) {
ts := newWSServer(t, s)
defer ts.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(ts.URL), nil)
conn, _, err := dialAgentExecWebSocket(t, ts.URL)
if err != nil {
t.Fatalf("Dial: %v", err)
}
@ -212,7 +212,7 @@ func TestHandleWebSocket_PongHandler(t *testing.T) {
ts := newWSServer(t, s)
defer ts.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(ts.URL), nil)
conn, _, err := dialAgentExecWebSocket(t, ts.URL)
if err != nil {
t.Fatalf("Dial: %v", err)
}
@ -550,7 +550,7 @@ func TestReadFileRoundTrip(t *testing.T) {
ts := newWSServer(t, s)
defer ts.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(ts.URL), nil)
conn, _, err := dialAgentExecWebSocket(t, ts.URL)
if err != nil {
t.Fatalf("Dial: %v", err)
}
@ -651,7 +651,7 @@ func TestShutdownRejectsNewWebSocketConnections(t *testing.T) {
ts := newWSServer(t, s)
defer ts.Close()
conn, resp, err := websocket.DefaultDialer.Dial(wsURLForHTTP(ts.URL), nil)
conn, resp, err := dialAgentExecWebSocket(t, ts.URL)
if conn != nil {
conn.Close()
}
@ -672,7 +672,7 @@ func TestShutdownClosesActiveConnectionsAndIsIdempotent(t *testing.T) {
ts := newWSServer(t, s)
defer ts.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(ts.URL), nil)
conn, _, err := dialAgentExecWebSocket(t, ts.URL)
if err != nil {
t.Fatalf("Dial: %v", err)
}

View file

@ -11,6 +11,7 @@ import (
"time"
"github.com/gorilla/websocket"
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
)
type wsRawMessage struct {
@ -31,6 +32,24 @@ func wsURLForHTTP(serverURL string) string {
return "ws" + strings.TrimPrefix(serverURL, "http")
}
func wsHeadersForHTTP(t *testing.T, serverURL string) http.Header {
t.Helper()
origin, err := securityutil.HTTPOriginForWebSocketBaseURL(serverURL)
if err != nil {
t.Fatalf("failed to derive websocket origin: %v", err)
}
headers := http.Header{}
headers.Set("Origin", origin)
return headers
}
func dialAgentExecWebSocket(t *testing.T, serverURL string) (*websocket.Conn, *http.Response, error) {
t.Helper()
return websocket.DefaultDialer.Dial(wsURLForHTTP(serverURL), wsHeadersForHTTP(t, serverURL))
}
func wsWriteMessage(t *testing.T, conn *websocket.Conn, msg Message) {
t.Helper()
_ = conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
@ -103,7 +122,7 @@ func TestHandleWebSocket_RegistrationSuccessAndDisconnectRemovesAgent(t *testing
ts := newWSServer(t, s)
defer ts.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(ts.URL), nil)
conn, _, err := dialAgentExecWebSocket(t, ts.URL)
if err != nil {
t.Fatalf("Dial: %v", err)
}
@ -131,12 +150,30 @@ func TestHandleWebSocket_RegistrationSuccessAndDisconnectRemovesAgent(t *testing
waitFor(t, 2*time.Second, func() bool { return !s.IsAgentConnected("a1") })
}
func TestHandleWebSocket_RejectsMissingOrigin(t *testing.T) {
s := NewServer(allowAllTestTokens)
ts := newWSServer(t, s)
defer ts.Close()
conn, resp, err := websocket.DefaultDialer.Dial(wsURLForHTTP(ts.URL), nil)
if err == nil {
conn.Close()
t.Fatalf("expected websocket upgrade to reject missing Origin")
}
if resp == nil {
t.Fatalf("expected HTTP response for rejected websocket upgrade")
}
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected %d, got %d", http.StatusForbidden, resp.StatusCode)
}
}
func TestHandleWebSocket_InvalidTokenRejected(t *testing.T) {
s := NewServer(func(string, string) bool { return false })
ts := newWSServer(t, s)
defer ts.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(ts.URL), nil)
conn, _, err := dialAgentExecWebSocket(t, ts.URL)
if err != nil {
t.Fatalf("Dial: %v", err)
}
@ -169,7 +206,7 @@ func TestHandleWebSocket_MissingAgentIDRejected(t *testing.T) {
ts := newWSServer(t, s)
defer ts.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(ts.URL), nil)
conn, _, err := dialAgentExecWebSocket(t, ts.URL)
if err != nil {
t.Fatalf("Dial: %v", err)
}
@ -200,7 +237,7 @@ func TestHandleWebSocket_FirstMessageMustBeRegister(t *testing.T) {
ts := newWSServer(t, s)
defer ts.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(ts.URL), nil)
conn, _, err := dialAgentExecWebSocket(t, ts.URL)
if err != nil {
t.Fatalf("Dial: %v", err)
}
@ -220,7 +257,7 @@ func TestHandleWebSocket_RejectsOversizedRegistrationMessage(t *testing.T) {
ts := newWSServer(t, s)
defer ts.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(ts.URL), nil)
conn, _, err := dialAgentExecWebSocket(t, ts.URL)
if err != nil {
t.Fatalf("Dial: %v", err)
}
@ -243,7 +280,7 @@ func TestHandleWebSocket_AgentPingRespondsWithPong(t *testing.T) {
ts := newWSServer(t, s)
defer ts.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(ts.URL), nil)
conn, _, err := dialAgentExecWebSocket(t, ts.URL)
if err != nil {
t.Fatalf("Dial: %v", err)
}
@ -271,7 +308,7 @@ func TestExecuteCommand_RoundTripViaWebSocket(t *testing.T) {
ts := newWSServer(t, s)
defer ts.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(ts.URL), nil)
conn, _, err := dialAgentExecWebSocket(t, ts.URL)
if err != nil {
t.Fatalf("Dial: %v", err)
}
@ -358,7 +395,7 @@ func TestHandleWebSocket_ReconnectSameAgentIDClosesOldConnection(t *testing.T) {
dial := func() *websocket.Conn {
t.Helper()
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(ts.URL), nil)
conn, _, err := dialAgentExecWebSocket(t, ts.URL)
if err != nil {
t.Fatalf("Dial: %v", err)
}

View file

@ -17,6 +17,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/chat"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
)
@ -597,10 +598,23 @@ func wsURLForHTTP(url string) string {
return "ws://" + strings.TrimPrefix(url, "http://")
}
func wsHeadersForHTTP(t *testing.T, url string) http.Header {
t.Helper()
origin, err := securityutil.HTTPOriginForWebSocketBaseURL(url)
if err != nil {
t.Fatalf("failed to derive websocket origin: %v", err)
}
headers := http.Header{}
headers.Set("Origin", origin)
return headers
}
func registerAgent(t *testing.T, url, agentID, hostname string) *websocket.Conn {
t.Helper()
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(url), nil)
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(url), wsHeadersForHTTP(t, url))
if err != nil {
t.Fatalf("failed to dial websocket: %v", err)
}

View file

@ -10315,7 +10315,7 @@ func TestContract_AgentExecWebSocketRejectsUnboundToken(t *testing.T) {
ts := newIPv4HTTPServer(t, router.Handler())
defer ts.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(ts.URL)+"/api/agent/ws", nil)
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(ts.URL)+"/api/agent/ws", wsHeadersForHTTP(t, ts.URL))
if err != nil {
t.Fatalf("Dial: %v", err)
}
@ -10350,7 +10350,7 @@ func TestContract_AgentExecWebSocketAcceptsLegacyHostnameBinding(t *testing.T) {
ts := newIPv4HTTPServer(t, router.Handler())
defer ts.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(ts.URL)+"/api/agent/ws", nil)
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(ts.URL)+"/api/agent/ws", wsHeadersForHTTP(t, ts.URL))
if err != nil {
t.Fatalf("Dial: %v", err)
}

View file

@ -24,6 +24,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rcourtman/pulse-go-rewrite/internal/recovery"
recoverymanager "github.com/rcourtman/pulse-go-rewrite/internal/recovery/manager"
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
internalws "github.com/rcourtman/pulse-go-rewrite/internal/websocket"
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
@ -37,6 +38,19 @@ type integrationServer struct {
config *config.Config
}
func wsHeadersForHTTP(t *testing.T, serverURL string) http.Header {
t.Helper()
origin, err := securityutil.HTTPOriginForWebSocketBaseURL(serverURL)
if err != nil {
t.Fatalf("failed to derive websocket origin: %v", err)
}
headers := http.Header{}
headers.Set("Origin", origin)
return headers
}
func newIntegrationServer(t *testing.T) *integrationServer {
return newIntegrationServerWithConfig(t, nil)
}
@ -1471,7 +1485,7 @@ func TestWebSocketSendsInitialState(t *testing.T) {
wsURL := "ws" + strings.TrimPrefix(srv.server.URL, "http") + "/ws?org_id=default"
conn, _, err := gorillaws.DefaultDialer.Dial(wsURL, nil)
conn, _, err := gorillaws.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, srv.server.URL))
if err != nil {
t.Fatalf("websocket dial failed: %v", err)
}
@ -1548,7 +1562,7 @@ func TestWebsocketPayloadContractShape(t *testing.T) {
wsURL := "ws" + strings.TrimPrefix(srv.server.URL, "http") + "/ws?org_id=default"
conn, _, err := gorillaws.DefaultDialer.Dial(wsURL, nil)
conn, _, err := gorillaws.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, srv.server.URL))
if err != nil {
t.Fatalf("websocket dial failed: %v", err)
}
@ -1657,7 +1671,7 @@ func TestWebsocketPayloadUsesCanonicalStateContract(t *testing.T) {
wsURL := "ws" + strings.TrimPrefix(srv.server.URL, "http") + "/ws?org_id=default"
conn, _, err := gorillaws.DefaultDialer.Dial(wsURL, nil)
conn, _, err := gorillaws.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, srv.server.URL))
if err != nil {
t.Fatalf("websocket dial failed: %v", err)
}

View file

@ -499,7 +499,7 @@ func TestAgentExecTokenBindingEnforced(t *testing.T) {
wsURL := wsURLForHTTP(ts.URL) + "/api/agent/ws"
// Mismatched agent ID should be rejected
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
conn, _, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
if err != nil {
t.Fatalf("Dial: %v", err)
}
@ -526,7 +526,7 @@ func TestAgentExecTokenBindingEnforced(t *testing.T) {
conn.Close()
// Matching agent ID should succeed
conn, _, err = websocket.DefaultDialer.Dial(wsURL, nil)
conn, _, err = websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
if err != nil {
t.Fatalf("Dial: %v", err)
}
@ -563,7 +563,7 @@ func TestSecurityTokens_AgentExecRejectsUnboundToken(t *testing.T) {
defer ts.Close()
wsURL := wsURLForHTTP(ts.URL) + "/api/agent/ws"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
conn, _, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
if err != nil {
t.Fatalf("Dial: %v", err)
}
@ -600,7 +600,7 @@ func TestSecurityTokens_AgentExecRequiresAgentExecScope(t *testing.T) {
defer ts.Close()
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/agent/ws"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
conn, _, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
if err != nil {
t.Fatalf("Dial: %v", err)
}
@ -642,7 +642,7 @@ func TestSecurityTokens_AgentExecRejectsLegacySingleAPIToken(t *testing.T) {
defer ts.Close()
wsURL := wsURLForHTTP(ts.URL) + "/api/agent/ws"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
conn, _, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
if err != nil {
t.Fatalf("Dial: %v", err)
}
@ -683,7 +683,7 @@ func TestWebSocketAllowsMonitoringReadScope(t *testing.T) {
defer ts.Close()
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?org_id=default"
headers := http.Header{}
headers := wsHeadersForHTTP(t, ts.URL)
headers.Set("X-API-Token", rawToken)
conn, _, err := websocket.DefaultDialer.Dial(wsURL, headers)
if err != nil {
@ -706,7 +706,7 @@ func TestWebSocketAllowsBearerToken(t *testing.T) {
defer ts.Close()
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?org_id=default"
headers := http.Header{}
headers := wsHeadersForHTTP(t, ts.URL)
headers.Set("Authorization", "Bearer "+rawToken)
conn, _, err := websocket.DefaultDialer.Dial(wsURL, headers)
if err != nil {
@ -729,7 +729,7 @@ func TestWebSocketAllowsTokenQueryParam(t *testing.T) {
defer ts.Close()
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?org_id=default&token=" + rawToken
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
if err != nil {
t.Fatalf("Dial: %v", err)
}
@ -754,7 +754,7 @@ func TestWebSocketRejectsOrgQueryMismatchWithTenantContext(t *testing.T) {
defer ts.Close()
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?org_id=tenant-b&token=" + rawToken
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
if err == nil {
conn.Close()
t.Fatalf("expected websocket org mismatch rejection")
@ -781,7 +781,7 @@ func TestWebSocketRejectsInvalidOrgQueryID(t *testing.T) {
defer ts.Close()
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?org_id=../tenant-b&token=" + rawToken
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
if err == nil {
conn.Close()
t.Fatalf("expected websocket invalid org rejection")

View file

@ -74,6 +74,30 @@ func TestWebSocketOriginRejectedWhenNotAllowed(t *testing.T) {
}
}
func TestWebSocketOriginRejectedWhenMissing(t *testing.T) {
rawToken := "ws-origin-missing-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
server, cleanup := newWebSocketRouter(t, []string{"*"}, record)
defer cleanup()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws?org_id=default"
headers := http.Header{}
headers.Set("X-API-Token", rawToken)
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, headers)
if err == nil {
conn.Close()
t.Fatalf("expected websocket origin rejection when Origin header is missing")
}
if resp == nil {
t.Fatalf("expected HTTP response for rejected websocket origin")
}
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected status %d, got %d", http.StatusForbidden, resp.StatusCode)
}
}
func TestWebSocketOriginAllowedWhenConfigured(t *testing.T) {
rawToken := "ws-origin-allow-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)

View file

@ -201,6 +201,53 @@ func TestNewCommandClient_SetsSecureCommandDefaults(t *testing.T) {
}
}
func TestNewCommandClient_BuildWebSocketOriginFollowsCanonicalPulseURL(t *testing.T) {
logger := zerolog.Nop()
tests := []struct {
name string
pulseURL string
want string
wantErr bool
}{
{
name: "hosted https origin",
pulseURL: "https://pulse.example/base/",
want: "https://pulse.example",
},
{
name: "loopback http origin",
pulseURL: "http://127.0.0.1:7655/pulse",
want: "http://127.0.0.1:7655",
},
{
name: "rejects non loopback plaintext",
pulseURL: "http://pulse.example",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := NewCommandClient(Config{
PulseURL: tt.pulseURL,
Logger: &logger,
}, "agent-1", "node-1", "linux", "1.0.0")
got, err := client.buildWebSocketOrigin()
if (err != nil) != tt.wantErr {
t.Fatalf("buildWebSocketOrigin() err = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
return
}
if got != tt.want {
t.Fatalf("buildWebSocketOrigin() = %q, want %q", got, tt.want)
}
})
}
}
func TestNew_UsesPinnedServerFingerprintForHTTPTransport(t *testing.T) {
mc := &mockCollector{
hostInfoFn: func(context.Context) (*gohost.InfoStat, error) {

View file

@ -151,3 +151,56 @@ func TestCommandClientBuildWebSocketURL(t *testing.T) {
})
}
}
func TestCommandClientBuildWebSocketOrigin(t *testing.T) {
t.Parallel()
tests := []struct {
name string
pulseURL string
want string
wantErr bool
}{
{
name: "https becomes https origin",
pulseURL: "https://example.invalid/pulse/",
want: "https://example.invalid",
},
{
name: "loopback http stays http origin",
pulseURL: "http://localhost:7655/pulse",
want: "http://localhost:7655",
},
{
name: "wss becomes https origin",
pulseURL: "wss://example.invalid",
want: "https://example.invalid",
},
{
name: "non-loopback http rejected",
pulseURL: "http://example.invalid",
wantErr: true,
},
{
name: "missing host rejected",
pulseURL: "/relative/path",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := &CommandClient{pulseURL: tt.pulseURL}
got, err := client.buildWebSocketOrigin()
if (err != nil) != tt.wantErr {
t.Fatalf("buildWebSocketOrigin() err = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
return
}
if got != tt.want {
t.Fatalf("buildWebSocketOrigin() = %q, want %q", got, tt.want)
}
})
}
}

View file

@ -8,6 +8,7 @@ import (
"fmt"
"math/rand"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
@ -269,6 +270,10 @@ func (c *CommandClient) connectAndHandle(ctx context.Context) error {
if err != nil {
return fmt.Errorf("build websocket url: %w", err)
}
origin, err := c.buildWebSocketOrigin()
if err != nil {
return fmt.Errorf("build websocket origin: %w", err)
}
c.logger.Debug().Str("url", wsURL).Msg("Connecting to Pulse command server")
@ -282,9 +287,11 @@ func (c *CommandClient) connectAndHandle(ctx context.Context) error {
TLSClientConfig: tlsConfig,
HandshakeTimeout: 45 * time.Second,
}
headers := http.Header{}
headers.Set("Origin", origin)
// Connect
conn, _, err := dialer.DialContext(ctx, wsURL, nil)
conn, _, err := dialer.DialContext(ctx, wsURL, headers)
if err != nil {
return fmt.Errorf("dial websocket: %w", err)
}
@ -368,6 +375,10 @@ func (c *CommandClient) buildWebSocketURL() (string, error) {
return parsed.String(), nil
}
func (c *CommandClient) buildWebSocketOrigin() (string, error) {
return securityutil.HTTPOriginForWebSocketBaseURL(c.pulseURL)
}
func (c *CommandClient) sendRegistration(conn *websocket.Conn) error {
payload, err := json.Marshal(registerPayload{
AgentID: c.agentID,

View file

@ -9,6 +9,7 @@ import (
"time"
"github.com/gorilla/websocket"
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
"github.com/rs/zerolog"
)
@ -16,6 +17,19 @@ func wsURLForHTTP(serverURL string) string {
return "ws" + strings.TrimPrefix(serverURL, "http")
}
func wsHeadersForHTTP(t *testing.T, serverURL string) http.Header {
t.Helper()
origin, err := securityutil.HTTPOriginForWebSocketBaseURL(serverURL)
if err != nil {
t.Fatalf("failed to derive websocket origin: %v", err)
}
headers := http.Header{}
headers.Set("Origin", origin)
return headers
}
func TestCommandClient_sendRegistration_WritesExpectedPayload(t *testing.T) {
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
@ -48,7 +62,7 @@ func TestCommandClient_sendRegistration_WritesExpectedPayload(t *testing.T) {
}))
defer server.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(server.URL), nil)
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(server.URL), wsHeadersForHTTP(t, server.URL))
if err != nil {
t.Fatalf("Dial: %v", err)
}
@ -110,7 +124,7 @@ func TestCommandClient_waitForRegistration_AcceptsSuccess(t *testing.T) {
}))
defer server.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(server.URL), nil)
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(server.URL), wsHeadersForHTTP(t, server.URL))
if err != nil {
t.Fatalf("Dial: %v", err)
}
@ -139,7 +153,7 @@ func TestCommandClient_waitForRegistration_RejectsFailure(t *testing.T) {
}))
defer server.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(server.URL), nil)
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(server.URL), wsHeadersForHTTP(t, server.URL))
if err != nil {
t.Fatalf("Dial: %v", err)
}
@ -167,7 +181,7 @@ func TestCommandClient_waitForRegistration_UnexpectedMessageType(t *testing.T) {
}))
defer server.Close()
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(server.URL), nil)
conn, _, err := websocket.DefaultDialer.Dial(wsURLForHTTP(server.URL), wsHeadersForHTTP(t, server.URL))
if err != nil {
t.Fatalf("Dial: %v", err)
}

View file

@ -164,6 +164,64 @@ func TestNormalizePulseWebSocketBaseURL(t *testing.T) {
}
}
func TestSameHostWebSocketOrigin(t *testing.T) {
tests := []struct {
name string
origin string
requestHost string
want bool
}{
{name: "same host", origin: "https://tenant.example.com", requestHost: "tenant.example.com", want: true},
{name: "default port normalized", origin: "https://tenant.example.com:443", requestHost: "tenant.example.com", want: true},
{name: "different host", origin: "https://evil.example.com", requestHost: "tenant.example.com", want: false},
{name: "bad scheme", origin: "ws://tenant.example.com", requestHost: "tenant.example.com", want: false},
{name: "invalid origin", origin: "://bad", requestHost: "tenant.example.com", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := SameHostWebSocketOrigin(tt.origin, tt.requestHost); got != tt.want {
t.Fatalf("SameHostWebSocketOrigin(%q, %q) = %v, want %v", tt.origin, tt.requestHost, got, tt.want)
}
})
}
}
func TestHTTPOriginForWebSocketBaseURL(t *testing.T) {
tests := []struct {
name string
raw string
want string
wantError string
}{
{name: "wss becomes https origin", raw: "wss://example.invalid/pulse", want: "https://example.invalid"},
{name: "ws becomes http origin", raw: "ws://localhost:7655/pulse", want: "http://localhost:7655"},
{name: "https input becomes https origin", raw: "https://example.invalid/base", want: "https://example.invalid"},
{name: "rejects invalid input", raw: "ftp://example.invalid", wantError: "unsupported scheme"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := HTTPOriginForWebSocketBaseURL(tt.raw)
if tt.wantError != "" {
if err == nil {
t.Fatalf("HTTPOriginForWebSocketBaseURL(%q) expected error", tt.raw)
}
if !strings.Contains(err.Error(), tt.wantError) {
t.Fatalf("HTTPOriginForWebSocketBaseURL(%q) error = %q, want substring %q", tt.raw, err.Error(), tt.wantError)
}
return
}
if err != nil {
t.Fatalf("HTTPOriginForWebSocketBaseURL(%q) error = %v", tt.raw, err)
}
if got != tt.want {
t.Fatalf("HTTPOriginForWebSocketBaseURL(%q) = %q, want %q", tt.raw, got, tt.want)
}
})
}
}
func TestResolveRelativeURLRejectsAbsoluteURL(t *testing.T) {
base, err := NormalizeHTTPBaseURL("https://example.com/api", "")
if err != nil {

View file

@ -0,0 +1,62 @@
package securityutil
import (
"fmt"
"net"
"net/url"
"strings"
)
// NormalizeWebSocketOriginHost normalizes Origin/Host values for same-origin comparison.
func NormalizeWebSocketOriginHost(host string) string {
normalized := strings.TrimSpace(strings.ToLower(host))
if normalized == "" {
return normalized
}
parsedHost, parsedPort, err := net.SplitHostPort(normalized)
if err != nil {
return normalized
}
if parsedPort == "80" || parsedPort == "443" {
return parsedHost
}
return net.JoinHostPort(parsedHost, parsedPort)
}
// SameHostWebSocketOrigin validates that an Origin header is http(s) and matches the request host.
func SameHostWebSocketOrigin(origin string, requestHost string) bool {
parsed, err := url.Parse(strings.TrimSpace(origin))
if err != nil || parsed.Host == "" {
return false
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return false
}
return NormalizeWebSocketOriginHost(parsed.Host) == NormalizeWebSocketOriginHost(requestHost)
}
// HTTPOriginForWebSocketBaseURL returns the http(s) Origin header for a Pulse websocket base URL.
func HTTPOriginForWebSocketBaseURL(raw string) (string, error) {
parsed, err := NormalizePulseWebSocketBaseURL(raw)
if err != nil {
return "", err
}
switch parsed.Scheme {
case "ws":
parsed.Scheme = "http"
case "wss":
parsed.Scheme = "https"
default:
return "", fmt.Errorf("unsupported websocket origin scheme %q", parsed.Scheme)
}
parsed.Path = ""
parsed.RawPath = ""
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String(), nil
}

View file

@ -8,7 +8,6 @@ import (
"math"
"net"
"net/http"
"net/url"
"strings"
"sync"
"sync/atomic"
@ -16,6 +15,7 @@ import (
"github.com/gorilla/websocket"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rcourtman/pulse-go-rewrite/pkg/audit"
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
@ -126,10 +126,9 @@ func (h *Hub) SetAllowedOrigins(origins []string) {
// checkOrigin validates the origin against allowed origins
func (h *Hub) checkOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
origin := strings.TrimSpace(r.Header.Get("Origin"))
if origin == "" {
// No origin header, allow for non-browser clients
return true
return false
}
h.mu.RLock()
@ -172,7 +171,7 @@ func (h *Hub) checkOrigin(r *http.Request) bool {
if origin == requestOrigin {
return true
}
if sameHostOrigin(origin, host) {
if securityutil.SameHostWebSocketOrigin(origin, host) {
return true
}
@ -246,34 +245,6 @@ func (h *Hub) checkOrigin(r *http.Request) bool {
return false
}
func sameHostOrigin(origin, requestHost string) bool {
parsed, err := url.Parse(origin)
if err != nil || parsed.Host == "" {
return false
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return false
}
return normalizeOriginHost(parsed.Host) == normalizeOriginHost(requestHost)
}
func normalizeOriginHost(host string) string {
normalized := strings.TrimSpace(strings.ToLower(host))
if normalized == "" {
return normalized
}
parsedHost, parsedPort, err := net.SplitHostPort(normalized)
if err != nil {
return normalized
}
if parsedPort == "80" || parsedPort == "443" {
return parsedHost
}
return net.JoinHostPort(parsedHost, parsedPort)
}
// Client represents a WebSocket client
type Client struct {
hub *Hub

View file

@ -10,8 +10,22 @@ import (
"time"
"github.com/gorilla/websocket"
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
)
func wsHeadersForHTTP(t *testing.T, serverURL string) http.Header {
t.Helper()
origin, err := securityutil.HTTPOriginForWebSocketBaseURL(serverURL)
if err != nil {
t.Fatalf("failed to derive websocket origin: %v", err)
}
headers := http.Header{}
headers.Set("Origin", origin)
return headers
}
func TestBroadcastStateEnqueuesRawData(t *testing.T) {
hub := NewHub(nil)
state := struct {
@ -168,7 +182,7 @@ func TestHandleWebSocketPingPong(t *testing.T) {
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "?org_id=default"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
conn, _, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, server.URL))
if err != nil {
t.Fatalf("dial websocket: %v", err)
}
@ -214,7 +228,7 @@ func TestHandleWebSocket_ReadLimitExceededClosesConnection(t *testing.T) {
EnableCompression: false,
}
conn, _, err := dialer.Dial(wsURL, nil)
conn, _, err := dialer.Dial(wsURL, wsHeadersForHTTP(t, server.URL))
if err != nil {
t.Fatalf("dial websocket: %v", err)
}

View file

@ -708,12 +708,12 @@ func TestHub_CheckOrigin(t *testing.T) {
remoteAddr string // Simulated peer IP for CSWSH checks
expected bool
}{
// No origin header - always allowed for non-browser clients
// Browser-facing hub requires an explicit Origin header.
{
name: "no origin header",
origin: "",
host: "localhost:8080",
expected: true,
expected: false,
},
// Same-origin requests