diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 502431fe1..199b18bad 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -1569,6 +1569,12 @@ Agent` secondary handoff against the live setup wizard instead of relying from a deserialised HTTP body, a user-supplied command string, an AI tool call, or a governed approval consumer — those callers must continue to carry an `ApprovalID` and approval grant. +18. Keep plaintext agent transport validation resolver-aware but fail-closed. + When agent reporting, remote config, update, Docker, Kubernetes, or + command-channel clients allow self-hosted `http://` or `ws://`, dotted + local DNS names must resolve only to loopback, private/link-local, or + carrier-grade NAT addresses. Any unresolved, public, or mixed public/local + answer must keep requiring HTTPS/WSS. ## Current State @@ -2652,6 +2658,10 @@ normalization used by `internal/hostagent/agent.go`, `http://` or `ws://` only for loopback, private/link-local IP, carrier-grade NAT `100.64.0.0/10`, single-label, or local DNS Pulse origins; public remote Pulse URLs must still use HTTPS/WSS. +For dotted local DNS Pulse origins, the validator must either recognize the +name as an operator-local namespace or resolve it and require every returned +address to remain loopback, private/link-local, or carrier-grade NAT; mixed +public and local resolution must fail closed to HTTPS/WSS. `InsecureSkipVerify` may relax certificate verification on TLS transport; it must not reopen public plaintext HTTP for updater, websocket, reporting, or remote-config paths. diff --git a/internal/securityutil/httpurl_test.go b/internal/securityutil/httpurl_test.go index c529cb5e1..ea9a74c05 100644 --- a/internal/securityutil/httpurl_test.go +++ b/internal/securityutil/httpurl_test.go @@ -2,6 +2,7 @@ package securityutil import ( "context" + "net" "strings" "testing" ) @@ -177,6 +178,24 @@ func TestNormalizePulseHTTPBaseURLWithOptions(t *testing.T) { } } +func TestNormalizePulseHTTPBaseURLWithOptionsAllowsResolvedLocalDNS(t *testing.T) { + got, err := NormalizePulseHTTPBaseURLWithOptions("http://myhost.fritz.box:7655/", PulseURLValidationOptions{ + AllowLocalNetworkHTTP: true, + ResolveIPAddrs: func(_ context.Context, host string) ([]net.IPAddr, error) { + if host != "myhost.fritz.box" { + t.Fatalf("resolved host = %q, want myhost.fritz.box", host) + } + return []net.IPAddr{{IP: net.ParseIP("192.168.178.20")}}, nil + }, + }) + if err != nil { + t.Fatalf("NormalizePulseHTTPBaseURLWithOptions() error = %v", err) + } + if got.String() != "http://myhost.fritz.box:7655" { + t.Fatalf("NormalizePulseHTTPBaseURLWithOptions() = %q", got.String()) + } +} + func TestNormalizeSecureHTTPBaseURL(t *testing.T) { tests := []struct { name string diff --git a/pkg/securityutil/httpurl.go b/pkg/securityutil/httpurl.go index a6a7cf75c..67469eaa6 100644 --- a/pkg/securityutil/httpurl.go +++ b/pkg/securityutil/httpurl.go @@ -10,10 +10,13 @@ import ( "path" "strconv" "strings" + "time" ) const requestPlaceholderURL = "http://pulse.invalid" +const localNetworkHostResolveTimeout = 2 * time.Second + func cloneURL(u *url.URL) *url.URL { if u == nil { return nil @@ -127,7 +130,7 @@ func IsLocalNetworkHost(host string) bool { } if ip := net.ParseIP(normalized); ip != nil { - return ip.IsPrivate() || ip.IsLinkLocalUnicast() || isCarrierGradeNATIPv4(ip) + return isLocalNetworkIP(ip) } if !strings.Contains(normalized, ".") { @@ -143,6 +146,13 @@ func IsLocalNetworkHost(host string) bool { return false } +func isLocalNetworkIP(ip net.IP) bool { + if ip == nil { + return false + } + return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || isCarrierGradeNATIPv4(ip) +} + func isCarrierGradeNATIPv4(ip net.IP) bool { v4 := ip.To4() if v4 == nil { @@ -162,6 +172,11 @@ type PulseURLValidationOptions struct { // AllowLocalNetworkHTTP permits plain HTTP/WS to private IP, link-local, // single-label, and local DNS control-plane hosts for self-hosted installs. AllowLocalNetworkHTTP bool + + // ResolveIPAddrs resolves dotted local DNS names when deciding whether + // AllowLocalNetworkHTTP may permit plaintext transport. When unset, the + // system resolver is used. + ResolveIPAddrs func(ctx context.Context, host string) ([]net.IPAddr, error) } // NormalizePulseHTTPBaseURL validates a Pulse control-plane base URL. @@ -296,7 +311,40 @@ func pulseURLAllowsPlaintextHost(host string, opts PulseURLValidationOptions) bo if IsLoopbackHost(host) { return true } - return (opts.AllowInsecureHTTP || opts.AllowLocalNetworkHTTP) && IsLocalNetworkHost(host) + if !(opts.AllowInsecureHTTP || opts.AllowLocalNetworkHTTP) { + return false + } + return IsLocalNetworkHost(host) || hostResolvesToLocalNetwork(host, opts) +} + +func hostResolvesToLocalNetwork(host string, opts PulseURLValidationOptions) bool { + normalized := strings.ToLower(strings.TrimSpace(strings.Trim(host, "[]"))) + normalized = strings.TrimSuffix(normalized, ".") + if normalized == "" { + return false + } + if ip := net.ParseIP(normalized); ip != nil { + return isLocalNetworkIP(ip) + } + + ctx, cancel := context.WithTimeout(context.Background(), localNetworkHostResolveTimeout) + defer cancel() + + resolver := opts.ResolveIPAddrs + if resolver == nil { + resolver = net.DefaultResolver.LookupIPAddr + } + addrs, err := resolver(ctx, normalized) + if err != nil || len(addrs) == 0 { + return false + } + + for _, addr := range addrs { + if !isLocalNetworkIP(addr.IP) { + return false + } + } + return true } func pulseURLPlaintextError(raw string, websocket bool, opts PulseURLValidationOptions) error { diff --git a/pkg/securityutil/httpurl_test.go b/pkg/securityutil/httpurl_test.go new file mode 100644 index 000000000..bb630caab --- /dev/null +++ b/pkg/securityutil/httpurl_test.go @@ -0,0 +1,92 @@ +package securityutil + +import ( + "context" + "errors" + "net" + "strings" + "testing" +) + +func localNetworkHTTPOptions(resolver func(context.Context, string) ([]net.IPAddr, error)) PulseURLValidationOptions { + return PulseURLValidationOptions{ + AllowLocalNetworkHTTP: true, + ResolveIPAddrs: resolver, + } +} + +func TestNormalizePulseHTTPBaseURLWithOptionsAllowsResolvedLocalDNS(t *testing.T) { + opts := localNetworkHTTPOptions(func(_ context.Context, host string) ([]net.IPAddr, error) { + if host != "myhost.fritz.box" { + t.Fatalf("resolved host = %q, want myhost.fritz.box", host) + } + return []net.IPAddr{{IP: net.ParseIP("192.168.178.20")}}, nil + }) + + got, err := NormalizePulseHTTPBaseURLWithOptions("http://myhost.fritz.box:7655/", opts) + if err != nil { + t.Fatalf("NormalizePulseHTTPBaseURLWithOptions() error = %v", err) + } + if got.String() != "http://myhost.fritz.box:7655" { + t.Fatalf("NormalizePulseHTTPBaseURLWithOptions() = %q", got.String()) + } +} + +func TestNormalizePulseHTTPBaseURLWithOptionsRejectsPublicDNS(t *testing.T) { + opts := localNetworkHTTPOptions(func(_ context.Context, host string) ([]net.IPAddr, error) { + if host != "pulse.example.test" { + t.Fatalf("resolved host = %q, want pulse.example.test", host) + } + return []net.IPAddr{{IP: net.ParseIP("203.0.113.10")}}, nil + }) + + _, err := NormalizePulseHTTPBaseURLWithOptions("http://pulse.example.test:7655/", opts) + if err == nil || !strings.Contains(err.Error(), "must use https unless host is loopback or local/private") { + t.Fatalf("NormalizePulseHTTPBaseURLWithOptions() error = %v, want public HTTP rejection", err) + } +} + +func TestNormalizePulseHTTPBaseURLWithOptionsRejectsMixedPublicAndLocalDNS(t *testing.T) { + opts := localNetworkHTTPOptions(func(_ context.Context, host string) ([]net.IPAddr, error) { + if host != "mixed.example.test" { + t.Fatalf("resolved host = %q, want mixed.example.test", host) + } + return []net.IPAddr{ + {IP: net.ParseIP("192.168.1.25")}, + {IP: net.ParseIP("198.51.100.25")}, + }, nil + }) + + _, err := NormalizePulseHTTPBaseURLWithOptions("http://mixed.example.test:7655/", opts) + if err == nil || !strings.Contains(err.Error(), "must use https unless host is loopback or local/private") { + t.Fatalf("NormalizePulseHTTPBaseURLWithOptions() error = %v, want mixed DNS rejection", err) + } +} + +func TestNormalizePulseHTTPBaseURLWithOptionsRejectsUnresolvedDNS(t *testing.T) { + opts := localNetworkHTTPOptions(func(context.Context, string) ([]net.IPAddr, error) { + return nil, errors.New("lookup failed") + }) + + _, err := NormalizePulseHTTPBaseURLWithOptions("http://unresolved.example.test:7655/", opts) + if err == nil || !strings.Contains(err.Error(), "must use https unless host is loopback or local/private") { + t.Fatalf("NormalizePulseHTTPBaseURLWithOptions() error = %v, want unresolved DNS rejection", err) + } +} + +func TestNormalizePulseWebSocketBaseURLWithOptionsAllowsResolvedCGNATDNS(t *testing.T) { + opts := localNetworkHTTPOptions(func(_ context.Context, host string) ([]net.IPAddr, error) { + if host != "pulse.tailnet.example" { + t.Fatalf("resolved host = %q, want pulse.tailnet.example", host) + } + return []net.IPAddr{{IP: net.ParseIP("100.100.100.5")}}, nil + }) + + got, err := NormalizePulseWebSocketBaseURLWithOptions("http://pulse.tailnet.example:7655/pulse", opts) + if err != nil { + t.Fatalf("NormalizePulseWebSocketBaseURLWithOptions() error = %v", err) + } + if got.String() != "ws://pulse.tailnet.example:7655/pulse" { + t.Fatalf("NormalizePulseWebSocketBaseURLWithOptions() = %q", got.String()) + } +}