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

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