mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 18:45:53 +00:00
93fff4f1d8
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.
164 lines
6.1 KiB
Go
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)
|
|
}
|
|
}
|