Allow local DNS agent HTTP URLs

Refs #1522
This commit is contained in:
rcourtman 2026-07-06 13:53:19 +01:00
parent caa9b41834
commit e7334e66e4
4 changed files with 171 additions and 2 deletions

View file

@ -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.

View file

@ -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

View file

@ -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 {

View file

@ -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())
}
}