mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Fix same-host websocket proxy origin checks
This commit is contained in:
parent
18f4580e03
commit
2d8385c1d8
6 changed files with 93 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
}
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue