diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 612535ab3..b5edd10b4 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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://` 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 diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 2ac41cf2d..c2b368bf1 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -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" ] }, { diff --git a/internal/api/websocket_origin_security_test.go b/internal/api/websocket_origin_security_test.go index 7bcabf804..cd6bf15b6 100644 --- a/internal/api/websocket_origin_security_test.go +++ b/internal/api/websocket_origin_security_test.go @@ -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() diff --git a/internal/websocket/hub.go b/internal/websocket/hub.go index d0b7b6a05..8a6a9bbc8 100644 --- a/internal/websocket/hub.go +++ b/internal/websocket/hub.go @@ -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 diff --git a/internal/websocket/hub_test.go b/internal/websocket/hub_test.go index efba76127..affa88d64 100644 --- a/internal/websocket/hub_test.go +++ b/internal/websocket/hub_test.go @@ -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", diff --git a/scripts/release_control/canonical_completion_guard_test.py b/scripts/release_control/canonical_completion_guard_test.py index c7c933477..c83f89e6e 100644 --- a/scripts/release_control/canonical_completion_guard_test.py +++ b/scripts/release_control/canonical_completion_guard_test.py @@ -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", ], } ],