mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
parent
caa9b41834
commit
e7334e66e4
4 changed files with 171 additions and 2 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
92
pkg/securityutil/httpurl_test.go
Normal file
92
pkg/securityutil/httpurl_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue