Fail closed on wildcard trusted proxy configuration

This commit is contained in:
rcourtman
2026-04-22 04:23:23 +01:00
parent d64f5b2917
commit 14fc2bd4f0
10 changed files with 166 additions and 27 deletions
@@ -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
@@ -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
@@ -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,
@@ -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,
+1
View File
@@ -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{}
}
+91 -24
View File
@@ -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
}
+17
View File
@@ -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))
+20
View File
@@ -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
+4
View File
@@ -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 {
+17
View File
@@ -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)
}
}