Fix same-host websocket proxy origin checks

This commit is contained in:
rcourtman 2026-04-13 12:04:29 +01:00
parent 18f4580e03
commit 2d8385c1d8
6 changed files with 93 additions and 9 deletions

View file

@ -556,12 +556,15 @@ path, signup path, and stable workspace summary fields such as `created_at`.
That workspace summary contract must expose explicit health semantics: `healthy`
for passing health checks, `checking` only when no completed health check
exists yet, and `unhealthy` for a failed latest health check.
That same shared `internal/api/` boundary also owns trusted-proxy websocket
origin continuity for hosted runtimes. When a hosted tenant is opened through
the cloud proxy, the runtime must derive the effective same-origin
`https://<tenant-host>` boundary from trusted forwarded host/proto headers
only after the hosted container contract has injected explicit proxy CIDRs, so
live updates stay connected without weakening cross-site websocket checks.
That same shared `internal/api/` plus `internal/websocket/hub.go` boundary also
owns browser websocket origin continuity for reverse-proxied runtimes. Same-host
browser origins must continue to connect when a reverse proxy preserves the
external host but terminates TLS upstream, so live updates do not fail merely
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.
That same shared boundary now also owns outbound SSO metadata and discovery
URL handling. SAML test/preview metadata fetches and OIDC issuer discovery
must normalize absolute HTTP(S) inputs through shared helpers, reject

View file

@ -1382,7 +1382,8 @@
"frontend-modern/src/types/api.ts",
"frontend-modern/src/utils/agentInstallCommand.ts",
"frontend-modern/src/utils/apiTokenPresentation.ts",
"frontend-modern/src/utils/infrastructureSettingsPresentation.ts"
"frontend-modern/src/utils/infrastructureSettingsPresentation.ts",
"internal/websocket/hub.go"
],
"verification": {
"allow_same_subsystem_tests": false,
@ -1455,7 +1456,8 @@
"frontend-modern/src/api/security.ts",
"internal/api/security.go",
"internal/api/security_tokens.go",
"internal/api/system_settings.go"
"internal/api/system_settings.go",
"internal/websocket/hub.go"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
@ -1467,7 +1469,9 @@
"internal/api/security_tokens_lifecycle_test.go",
"internal/api/security_tokens_owner_binding_test.go",
"internal/api/security_tokens_test.go",
"internal/api/system_settings_telemetry_test.go"
"internal/api/system_settings_telemetry_test.go",
"internal/api/websocket_origin_security_test.go",
"internal/websocket/hub_test.go"
]
},
{

View file

@ -1,6 +1,8 @@
package api
import (
"context"
"net"
"net/http"
"net/http/httptest"
"strings"
@ -141,6 +143,35 @@ func TestWebSocketOriginAllowsPrivateWhenNoAllowedOrigins(t *testing.T) {
conn.Close()
}
func TestWebSocketOriginAllowsSameHostTLSTerminationWithoutTrustedProxy(t *testing.T) {
rawToken := "ws-origin-same-host-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
server, cleanup := newWebSocketRouter(t, []string{}, record)
defer cleanup()
dialer := websocket.Dialer{
NetDialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
var dialer net.Dialer
return dialer.DialContext(ctx, network, server.Listener.Addr().String())
},
}
headers := http.Header{}
headers.Set("X-API-Token", rawToken)
headers.Set("Origin", "https://tenant.example.com")
conn, resp, err := dialer.Dial("ws://tenant.example.com/ws?org_id=default", headers)
if err != nil {
t.Fatalf("expected websocket connection when proxy preserves host but terminates tls upstream, got %v", err)
}
if resp == nil || resp.StatusCode != http.StatusSwitchingProtocols {
conn.Close()
t.Fatalf("expected 101 switching protocols, got %v", resp)
}
conn.Close()
}
func TestWebSocketOriginAllowsTrustedForwardedHostedOrigin(t *testing.T) {
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "127.0.0.1/32")
resetTrustedProxyCIDRsForTests()

View file

@ -8,6 +8,7 @@ import (
"math"
"net"
"net/http"
"net/url"
"strings"
"sync"
"sync/atomic"
@ -171,6 +172,9 @@ func (h *Hub) checkOrigin(r *http.Request) bool {
if origin == requestOrigin {
return true
}
if sameHostOrigin(origin, host) {
return true
}
// Check if wildcard is allowed
for _, allowed := range allowedOrigins {
@ -242,6 +246,34 @@ 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

@ -723,6 +723,18 @@ func TestHub_CheckOrigin(t *testing.T) {
host: "localhost:8080",
expected: true,
},
{
name: "same host allowed when proxy preserves host but terminates tls upstream",
origin: "https://tenant.example.com",
host: "tenant.example.com",
expected: true,
},
{
name: "same host with non-default port allowed when proxy preserves host",
origin: "https://tenant.example.com:8443",
host: "tenant.example.com:8443",
expected: true,
},
{
name: "same origin with forwarded proto https",
origin: "https://example.com",

View file

@ -2748,6 +2748,8 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
"internal/api/security_tokens_owner_binding_test.go",
"internal/api/security_tokens_test.go",
"internal/api/system_settings_telemetry_test.go",
"internal/api/websocket_origin_security_test.go",
"internal/websocket/hub_test.go",
],
}
],