Files
pulse/pkg/securityutil/httpurl_test.go
T
rcourtman 93fff4f1d8 Add an explicit operator override for plaintext HTTP to non-local Pulse hosts
Fleets on networks numbered from nominally public IP space (issue
#1522: an AD estate on 192.20.0.0/16) cannot pass the agent's
local-network plaintext heuristic, and the only workaround was pointing
a .internal DNS alias at the server, which bypasses the same control
less visibly than a flag would. --allow-plaintext-http
(PULSE_AGENT_ALLOW_PLAINTEXT_HTTP) records process-wide consent once at
agent startup before any module validates a URL, covers every agent
transport including the websocket command channel, warns at startup
that the API token travels in cleartext, defaults closed, and is never
emitted by generated install commands or settable by the server.
2026-07-14 16:42:02 +01:00

164 lines
6.1 KiB
Go

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 TestNormalizeLocalRedirectPath(t *testing.T) {
tests := []struct {
name string
raw string
want string
wantErr bool
}{
{name: "local path", raw: "/settings/infrastructure?add=linux-host", want: "/settings/infrastructure?add=linux-host"},
{name: "scheme relative", raw: "//evil.example/path", wantErr: true},
{name: "backslash authority", raw: `/\\evil.example/path`, wantErr: true},
{name: "encoded slash authority", raw: "/%2f%2fevil.example/path", wantErr: true},
{name: "encoded backslash authority", raw: "/%5cevil.example/path", wantErr: true},
{name: "absolute URL", raw: "https://evil.example/path", wantErr: true},
{name: "control character", raw: "/settings\nnext", wantErr: true},
{name: "relative path", raw: "settings", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := NormalizeLocalRedirectPath(tt.raw)
if tt.wantErr {
if err == nil {
t.Fatalf("NormalizeLocalRedirectPath(%q) = %q, want error", tt.raw, got)
}
return
}
if err != nil {
t.Fatalf("NormalizeLocalRedirectPath(%q) error = %v", tt.raw, err)
}
if got != tt.want {
t.Fatalf("NormalizeLocalRedirectPath(%q) = %q, want %q", tt.raw, got, tt.want)
}
})
}
}
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())
}
}
func TestOperatorPlaintextHTTPOverrides(t *testing.T) {
const publicHTTP = "http://pulse.internal.example.com:7655"
resolvePublic := func(context.Context, string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("192.20.10.5")}}, nil
}
if _, err := NormalizePulseHTTPBaseURLWithOptions(publicHTTP, PulseURLValidationOptions{
AllowLocalNetworkHTTP: true,
ResolveIPAddrs: resolvePublic,
}); err == nil {
t.Fatal("plain HTTP to a public-resolving host was accepted without operator consent")
}
if _, err := NormalizePulseHTTPBaseURLWithOptions(publicHTTP, PulseURLValidationOptions{
AllowOperatorPlaintextHTTP: true,
ResolveIPAddrs: resolvePublic,
}); err != nil {
t.Fatalf("per-call operator consent was refused: %v", err)
}
SetOperatorPlaintextHTTPConsent(true)
t.Cleanup(func() { SetOperatorPlaintextHTTPConsent(false) })
if _, err := NormalizePulseHTTPBaseURLWithOptions(publicHTTP, PulseURLValidationOptions{
AllowLocalNetworkHTTP: true,
ResolveIPAddrs: resolvePublic,
}); err != nil {
t.Fatalf("process-wide operator consent was refused: %v", err)
}
if _, err := NormalizePulseWebSocketBaseURLWithOptions("ws://pulse.internal.example.com:7655", PulseURLValidationOptions{
ResolveIPAddrs: resolvePublic,
}); err != nil {
t.Fatalf("operator consent did not extend to the websocket channel: %v", err)
}
}