diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 44719d432..0607b0714 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -784,7 +784,10 @@ That same shared `internal/api/` dependency also now assumes hosted runtime websocket upgrades trust the cloud proxy only through explicit tenant `PULSE_TRUSTED_PROXY_CIDRS` wiring, so first-session handoff and agent-facing live activity surfaces do not degrade into reconnect loops when a hosted -workspace is opened through the control plane. +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 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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 2180ef188..10933cb35 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -679,7 +679,10 @@ 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. +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 +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 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/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 0e040866c..396798a87 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -131,6 +131,10 @@ The security transport surfaces remain intentionally shared with `api-contracts`: token, auth, and telemetry settings payloads are still API contracts, but they now also count as first-class security/privacy runtime behavior that `L14` must govern directly. +That same shared auth and forwarded-header trust surface must reject wildcard +proxy trust ranges in `PULSE_TRUSTED_PROXY_CIDRS` at startup, and runtime +client-IP derivation must fail closed instead of trusting forwarded headers if +an invalid wildcard proxy trust range is configured. That shared settings/auth boundary now also inherits the runtime-versus- commercial licensing split. Security/privacy settings may consume runtime capability truth where feature availability matters, but billing identity, diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index dc159bb3e..cfd5bd82b 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -843,7 +843,10 @@ That same shared `internal/api/` dependency also now assumes hosted runtime websocket upgrades trust the cloud proxy only through explicit tenant `PULSE_TRUSTED_PROXY_CIDRS` wiring, so storage- and recovery-adjacent live status surfaces do not fall into reconnect loops after a hosted workspace -handoff. +handoff. That shared proxy-trust boundary must also reject wildcard trust +ranges such as `0.0.0.0/0` or `::/0` at startup, and storage/recovery-adjacent +forwarded-header reads must fail closed if invalid wildcard proxy trust +configuration is present. That same shared `internal/api/` dependency also assumes telemetry transparency stays on its governed system-settings trust surface. When shared router or config-system files move under storage- or recovery-adjacent work, diff --git a/internal/api/export_test.go b/internal/api/export_test.go index 4f054790a..03a7c1953 100644 --- a/internal/api/export_test.go +++ b/internal/api/export_test.go @@ -6,5 +6,6 @@ import "sync" // This must be called after setting PULSE_TRUSTED_PROXY_CIDRS env var. func ResetTrustedProxyConfigForTests() { trustedProxyCIDRs = nil + trustedProxyConfigErr = nil trustedProxyOnce = sync.Once{} } diff --git a/internal/api/security.go b/internal/api/security.go index 502284d8b..51adad226 100644 --- a/internal/api/security.go +++ b/internal/api/security.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "encoding/base64" + "fmt" "net" "net/http" "strings" @@ -219,51 +220,114 @@ var ( maxFailedAttempts = 5 lockoutDuration = 15 * time.Minute - trustedProxyOnce sync.Once - trustedProxyCIDRs []*net.IPNet + trustedProxyOnce sync.Once + trustedProxyCIDRs []*net.IPNet + trustedProxyConfigErr error ) func loadTrustedProxyCIDRs() { raw := utils.GetenvTrim("PULSE_TRUSTED_PROXY_CIDRS") - if raw == "" { + cidrs, err := parseTrustedProxyCIDRs(raw) + if err != nil { + trustedProxyConfigErr = err + log.Error().Err(err).Msg("Invalid trusted proxy configuration; refusing to trust forwarded headers") + trustedProxyCIDRs = nil return } + trustedProxyCIDRs = cidrs +} +// ValidateTrustedProxyCIDRsFromEnv rejects trusted-proxy wildcard trust ranges at startup. +func ValidateTrustedProxyCIDRsFromEnv() error { + _, err := parseTrustedProxyCIDRs(utils.GetenvTrim("PULSE_TRUSTED_PROXY_CIDRS")) + return err +} + +func parseTrustedProxyCIDRs(raw string) ([]*net.IPNet, error) { + if raw == "" { + return nil, nil + } + + cidrs := make([]*net.IPNet, 0) for _, entry := range strings.Split(raw, ",") { entry = strings.TrimSpace(entry) if entry == "" { continue } + var network *net.IPNet if strings.Contains(entry, "/") { - _, network, parseErr := net.ParseCIDR(entry) - if parseErr == nil { - network.IP = network.IP.Mask(network.Mask) - trustedProxyCIDRs = append(trustedProxyCIDRs, network) + _, parsedNetwork, parseErr := net.ParseCIDR(entry) + if parseErr != nil { + log.Warn(). + Str("cidr", entry). + Err(parseErr). + Msg("Ignoring invalid CIDR in PULSE_TRUSTED_PROXY_CIDRS") continue } + parsedNetwork.IP = parsedNetwork.IP.Mask(parsedNetwork.Mask) + network = parsedNetwork + } else { + ip := net.ParseIP(entry) + if ip == nil { + log.Warn(). + Str("value", entry). + Msg("Ignoring invalid IP in PULSE_TRUSTED_PROXY_CIDRS") + continue + } + + bits := 32 + if ip.To4() == nil { + bits = 128 + } + mask := net.CIDRMask(bits, bits) + network = &net.IPNet{IP: ip.Mask(mask), Mask: mask} + } + + if err := validateTrustedProxyCIDR(entry, network); err != nil { + return nil, err + } + warnBroadTrustedProxyCIDR(entry, network) + cidrs = append(cidrs, network) + } + + return cidrs, nil +} + +func validateTrustedProxyCIDR(entry string, network *net.IPNet) error { + if network == nil { + return nil + } + + ones, bits := network.Mask.Size() + if bits <= 0 { + return nil + } + if ones == 0 { + return fmt.Errorf("PULSE_TRUSTED_PROXY_CIDRS must not include wildcard trust range %q", entry) + } + return nil +} + +func warnBroadTrustedProxyCIDR(entry string, network *net.IPNet) { + if network == nil { + return + } + + ones, bits := network.Mask.Size() + switch bits { + case 32: + if ones > 0 && ones <= 16 { log.Warn(). Str("cidr", entry). - Err(parseErr). - Msg("Ignoring invalid CIDR in PULSE_TRUSTED_PROXY_CIDRS") - continue + Msg("Trusted proxy CIDR is broad; prefer a narrower reverse-proxy range") } - - ip := net.ParseIP(entry) - if ip == nil { + case 128: + if ones > 0 && ones <= 64 { log.Warn(). - Str("value", entry). - Msg("Ignoring invalid IP in PULSE_TRUSTED_PROXY_CIDRS") - continue + Str("cidr", entry). + Msg("Trusted proxy CIDR is broad; prefer a narrower reverse-proxy range") } - - bits := 32 - if ip.To4() == nil { - bits = 128 - } - mask := net.CIDRMask(bits, bits) - network := &net.IPNet{IP: ip.Mask(mask), Mask: mask} - trustedProxyCIDRs = append(trustedProxyCIDRs, network) } } @@ -311,6 +375,9 @@ func isTrustedProxyIP(ipStr string) bool { } trustedProxyOnce.Do(loadTrustedProxyCIDRs) + if trustedProxyConfigErr != nil { + return false + } if len(trustedProxyCIDRs) == 0 { return false } diff --git a/internal/api/security_regression_test.go b/internal/api/security_regression_test.go index 78e2acbdd..c5125f4fc 100644 --- a/internal/api/security_regression_test.go +++ b/internal/api/security_regression_test.go @@ -63,6 +63,23 @@ func newTokenRecord(t *testing.T, raw string, scopes []string, metadata map[stri return *record } +func TestSecurityRejectsWildcardTrustedProxyCIDR(t *testing.T) { + t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "0.0.0.0/0") + resetTrustedProxyConfig() + + if err := ValidateTrustedProxyCIDRsFromEnv(); err == nil || !strings.Contains(err.Error(), "wildcard trust range") { + t.Fatalf("expected wildcard trusted proxy configuration to be rejected, got %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "http://pulse.local/api/state", nil) + req.RemoteAddr = "198.51.100.42:8443" + req.Header.Set("X-Forwarded-For", "203.0.113.10") + + if got := GetClientIP(req); got != "198.51.100.42" { + t.Fatalf("expected forwarded headers to fail closed, got %q", got) + } +} + func readRegisteredPayload(t *testing.T, conn *websocket.Conn) agentexec.RegisteredPayload { t.Helper() _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) diff --git a/internal/api/security_test.go b/internal/api/security_test.go index 7d42de69b..f5544aef3 100644 --- a/internal/api/security_test.go +++ b/internal/api/security_test.go @@ -21,6 +21,7 @@ func fixedTimeForTest() time.Time { func resetTrustedProxyConfig() { trustedProxyCIDRs = nil + trustedProxyConfigErr = nil trustedProxyOnce = sync.Once{} } @@ -76,6 +77,25 @@ func TestGetClientIPUsesXRealIPTrustedProxy(t *testing.T) { } } +func TestValidateTrustedProxyCIDRsFromEnvRejectsWildcard(t *testing.T) { + t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "0.0.0.0/0") + resetTrustedProxyConfig() + + err := ValidateTrustedProxyCIDRsFromEnv() + if err == nil || !strings.Contains(err.Error(), "wildcard trust range") { + t.Fatalf("expected wildcard trust range error, got %v", err) + } +} + +func TestIsTrustedProxyIPRejectsWildcardCIDR(t *testing.T) { + t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "0.0.0.0/0") + resetTrustedProxyConfig() + + if isTrustedProxyIP("203.0.113.10") { + t.Fatal("expected wildcard trusted proxy CIDR to fail closed") + } +} + func TestIsTrustedProxyIP(t *testing.T) { tests := []struct { name string diff --git a/pkg/server/server.go b/pkg/server/server.go index ec3f56703..e4f265265 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -126,6 +126,10 @@ func Run(ctx context.Context, version string) error { }) defer logging.Shutdown() + if err := api.ValidateTrustedProxyCIDRsFromEnv(); err != nil { + return err + } + // Check for auto-import on first startup if ShouldAutoImport() { if err := PerformAutoImport(); err != nil { diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index 4592b2699..fa078b50a 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -152,3 +152,20 @@ func TestServerRun_Shutdown(t *testing.T) { t.Logf("Run returned: %v", err) } } + +func TestServerRun_RejectsWildcardTrustedProxyCIDR(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("PULSE_DATA_DIR", tmpDir) + t.Setenv("PULSE_CONFIG_PATH", tmpDir) + t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "0.0.0.0/0") + + configFile := filepath.Join(tmpDir, "config.yaml") + if err := os.WriteFile(configFile, []byte("bindAddress: 127.0.0.1\nfrontendPort: 0"), 0644); err != nil { + t.Fatal(err) + } + + err := Run(context.Background(), "test-version") + if err == nil || !strings.Contains(err.Error(), "wildcard trust range") { + t.Fatalf("expected wildcard trusted proxy configuration to be rejected, got %v", err) + } +}