mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Fail closed on wildcard trusted proxy configuration
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user