mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 21:47:20 +00:00
768065b0db
Bind the credential-free capture handshake to the polling context under the existing five-second ceiling. Recheck cancellation before publishing a runtime pin; retain established trust, endpoint selection and ordinary declared-member first use. Add local stalled-handshake, peer-closure, no-dial and later-recovery controls. Change-source: pulse-maintainer
516 lines
17 KiB
Go
516 lines
17 KiB
Go
package tlsutil
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestFetchFingerprintCancellationContract(t *testing.T) {
|
|
testFetchFingerprintCancellation(t)
|
|
}
|
|
|
|
func TestCreateHTTPClientRedirectAuthorityValidation(t *testing.T) {
|
|
for _, mode := range []struct {
|
|
name string
|
|
verify bool
|
|
fingerprint string
|
|
}{
|
|
{"ca", true, ""},
|
|
{"pin", true, strings.Repeat("a", 64)},
|
|
{"explicit-opt-out", false, ""},
|
|
} {
|
|
t.Run(mode.name, func(t *testing.T) {
|
|
client := CreateHTTPClient(mode.verify, mode.fingerprint)
|
|
if client.CheckRedirect == nil {
|
|
t.Fatal("infrastructure client uses the unrestricted default redirect policy")
|
|
}
|
|
for _, pair := range []struct {
|
|
name, original, target string
|
|
allowed bool
|
|
}{
|
|
{"default-port", "https://pve.test", "https://PVE.test:0443/path", true},
|
|
{"ipv6", "https://[::1]:443", "https://[0:0:0:0:0:0:0:1]/path", true},
|
|
{"zone", "https://[fe80::1%25eth0]", "https://[fe80::1%25eth0]/path", true},
|
|
{"zone-case", "https://[fe80::1%25eth0]", "https://[fe80::1%25ETH0]/path", false},
|
|
{"empty-port", "https://pve.test", "https://pve.test:/path", false},
|
|
{"zero-port", "https://pve.test", "https://pve.test:0/path", false},
|
|
{"port-overflow", "https://pve.test", "https://pve.test:65536/path", false},
|
|
} {
|
|
t.Run(pair.name, func(t *testing.T) {
|
|
original, err := url.Parse(pair.original)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
target, err := url.Parse(pair.target)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = client.CheckRedirect(&http.Request{URL: target}, []*http.Request{{URL: original}})
|
|
if (err == nil) != pair.allowed {
|
|
t.Fatalf("origin comparison allowed=%t: %v", pair.allowed, err)
|
|
}
|
|
})
|
|
}
|
|
if client.CheckRedirect(nil, nil) == nil || client.CheckRedirect(&http.Request{}, []*http.Request{nil}) == nil {
|
|
t.Fatal("missing origin evidence admitted a redirect")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFingerprintVerifier_NormalizesFingerprint(t *testing.T) {
|
|
// Fingerprint with colons
|
|
fp1 := "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"
|
|
// Same fingerprint without colons, lowercase
|
|
fp2 := "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"
|
|
|
|
config1 := FingerprintVerifier(fp1)
|
|
config2 := FingerprintVerifier(fp2)
|
|
|
|
// Both should have InsecureSkipVerify set (we do our own verification)
|
|
if !config1.InsecureSkipVerify {
|
|
t.Error("FingerprintVerifier should set InsecureSkipVerify to true")
|
|
}
|
|
if !config2.InsecureSkipVerify {
|
|
t.Error("FingerprintVerifier should set InsecureSkipVerify to true")
|
|
}
|
|
|
|
// Both should have VerifyPeerCertificate function set
|
|
if config1.VerifyPeerCertificate == nil {
|
|
t.Error("FingerprintVerifier should set VerifyPeerCertificate")
|
|
}
|
|
if config2.VerifyPeerCertificate == nil {
|
|
t.Error("FingerprintVerifier should set VerifyPeerCertificate")
|
|
}
|
|
if config1.MinVersion != minimumTLSVersion {
|
|
t.Errorf("FingerprintVerifier MinVersion = %v, want %v", config1.MinVersion, minimumTLSVersion)
|
|
}
|
|
if config2.MinVersion != minimumTLSVersion {
|
|
t.Errorf("FingerprintVerifier MinVersion = %v, want %v", config2.MinVersion, minimumTLSVersion)
|
|
}
|
|
}
|
|
|
|
func TestUnverifiedPeerCertificateCaptureTLSConfigRequiresPeerCertificate(t *testing.T) {
|
|
config := UnverifiedPeerCertificateCaptureTLSConfig()
|
|
if !config.InsecureSkipVerify {
|
|
t.Fatal("UnverifiedPeerCertificateCaptureTLSConfig should enable custom verification mode")
|
|
}
|
|
if config.VerifyPeerCertificate == nil {
|
|
t.Fatal("UnverifiedPeerCertificateCaptureTLSConfig should install a peer-certificate verifier")
|
|
}
|
|
if config.MinVersion != minimumTLSVersion {
|
|
t.Fatalf("UnverifiedPeerCertificateCaptureTLSConfig MinVersion = %v, want %v", config.MinVersion, minimumTLSVersion)
|
|
}
|
|
|
|
err := config.VerifyPeerCertificate(nil, nil)
|
|
if err == nil || !strings.Contains(err.Error(), "no certificates") {
|
|
t.Fatalf("expected missing-certificate error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFingerprintVerifier_NoCertificates(t *testing.T) {
|
|
config := FingerprintVerifier("aabbccdd")
|
|
|
|
// Should fail when no certificates presented
|
|
err := config.VerifyPeerCertificate([][]byte{}, nil)
|
|
if err == nil {
|
|
t.Error("Should fail when no certificates presented")
|
|
}
|
|
if !strings.Contains(err.Error(), "no certificates") {
|
|
t.Errorf("Error message should mention no certificates, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFingerprintVerifier_MatchingFingerprint(t *testing.T) {
|
|
// Create a mock certificate (just random bytes for testing)
|
|
mockCert := []byte("mock certificate data for testing purposes")
|
|
|
|
// Calculate its fingerprint
|
|
fingerprint := sha256.Sum256(mockCert)
|
|
expectedFP := hex.EncodeToString(fingerprint[:])
|
|
|
|
config := FingerprintVerifier(expectedFP)
|
|
|
|
// Should succeed with matching fingerprint
|
|
err := config.VerifyPeerCertificate([][]byte{mockCert}, nil)
|
|
if err != nil {
|
|
t.Errorf("Should succeed with matching fingerprint, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFingerprintVerifier_MismatchedFingerprint(t *testing.T) {
|
|
mockCert := []byte("mock certificate data")
|
|
|
|
// Use a different fingerprint
|
|
wrongFP := "0000000000000000000000000000000000000000000000000000000000000000"
|
|
|
|
config := FingerprintVerifier(wrongFP)
|
|
|
|
// Should fail with mismatched fingerprint
|
|
err := config.VerifyPeerCertificate([][]byte{mockCert}, nil)
|
|
if err == nil {
|
|
t.Error("Should fail with mismatched fingerprint")
|
|
}
|
|
if !strings.Contains(err.Error(), "mismatch") {
|
|
t.Errorf("Error message should mention mismatch, got: %v", err)
|
|
}
|
|
var mismatch *FingerprintMismatchError
|
|
if !errors.As(fmt.Errorf("request failed: %w", err), &mismatch) || mismatch.Expected != wrongFP {
|
|
t.Fatal("local pin failure lost its typed trust evidence through wrapping")
|
|
}
|
|
actual := sha256.Sum256(mockCert)
|
|
if mismatch.Actual != hex.EncodeToString(actual[:]) {
|
|
t.Fatal("typed mismatch changed the observed certificate identity")
|
|
}
|
|
if errors.As(errors.New(err.Error()), &mismatch) {
|
|
t.Fatal("quoted error text created verifier evidence")
|
|
}
|
|
}
|
|
|
|
func TestCreateHTTPClient_InsecureMode(t *testing.T) {
|
|
client := CreateHTTPClient(false, "")
|
|
|
|
if client == nil {
|
|
t.Fatal("CreateHTTPClient returned nil")
|
|
}
|
|
|
|
transport, ok := client.Transport.(*http.Transport)
|
|
if !ok {
|
|
t.Fatal("Transport is not *http.Transport")
|
|
}
|
|
|
|
if transport.TLSClientConfig == nil {
|
|
t.Fatal("TLSClientConfig should be set")
|
|
}
|
|
|
|
if !transport.TLSClientConfig.InsecureSkipVerify {
|
|
t.Error("InsecureSkipVerify should be true in insecure mode")
|
|
}
|
|
if transport.TLSClientConfig.VerifyPeerCertificate == nil {
|
|
t.Fatal("insecure mode should still validate peer certificate structure")
|
|
}
|
|
if transport.TLSClientConfig.MinVersion != minimumTLSVersion {
|
|
t.Fatalf("TLSClientConfig.MinVersion = %v, want %v", transport.TLSClientConfig.MinVersion, minimumTLSVersion)
|
|
}
|
|
}
|
|
|
|
func TestCreateHTTPClient_FingerprintMode(t *testing.T) {
|
|
fingerprint := "aabbccdd"
|
|
client := CreateHTTPClient(false, fingerprint)
|
|
|
|
if client == nil {
|
|
t.Fatal("CreateHTTPClient returned nil")
|
|
}
|
|
|
|
transport, ok := client.Transport.(*http.Transport)
|
|
if !ok {
|
|
t.Fatal("Transport is not *http.Transport")
|
|
}
|
|
|
|
if transport.TLSClientConfig == nil {
|
|
t.Fatal("TLSClientConfig should be set")
|
|
}
|
|
|
|
// Should have custom verification function
|
|
if transport.TLSClientConfig.VerifyPeerCertificate == nil {
|
|
t.Error("VerifyPeerCertificate should be set in fingerprint mode")
|
|
}
|
|
if transport.TLSClientConfig.MinVersion != minimumTLSVersion {
|
|
t.Fatalf("TLSClientConfig.MinVersion = %v, want %v", transport.TLSClientConfig.MinVersion, minimumTLSVersion)
|
|
}
|
|
}
|
|
|
|
func TestCreateHTTPClient_SecureMode(t *testing.T) {
|
|
client := CreateHTTPClient(true, "")
|
|
|
|
if client == nil {
|
|
t.Fatal("CreateHTTPClient returned nil")
|
|
}
|
|
|
|
transport, ok := client.Transport.(*http.Transport)
|
|
if !ok {
|
|
t.Fatal("Transport is not *http.Transport")
|
|
}
|
|
|
|
if transport.TLSClientConfig == nil {
|
|
t.Fatal("secure mode should install an explicit TLS client config")
|
|
}
|
|
if transport.TLSClientConfig.InsecureSkipVerify {
|
|
t.Error("InsecureSkipVerify should not be true in secure mode")
|
|
}
|
|
if transport.TLSClientConfig.MinVersion != minimumTLSVersion {
|
|
t.Fatalf("TLSClientConfig.MinVersion = %v, want %v", transport.TLSClientConfig.MinVersion, minimumTLSVersion)
|
|
}
|
|
}
|
|
|
|
func TestCreateHTTPClientWithTimeout_DefaultTimeout(t *testing.T) {
|
|
client := CreateHTTPClientWithTimeout(true, "", 0)
|
|
|
|
if client == nil {
|
|
t.Fatal("CreateHTTPClientWithTimeout returned nil")
|
|
}
|
|
|
|
// Should use default timeout when 0 is passed
|
|
if client.Timeout != 60*time.Second {
|
|
t.Errorf("Timeout = %v, want %v", client.Timeout, 60*time.Second)
|
|
}
|
|
}
|
|
|
|
func TestCreateHTTPClientWithTimeout_CustomTimeout(t *testing.T) {
|
|
customTimeout := 30 * time.Second
|
|
client := CreateHTTPClientWithTimeout(true, "", customTimeout)
|
|
|
|
if client == nil {
|
|
t.Fatal("CreateHTTPClientWithTimeout returned nil")
|
|
}
|
|
|
|
if client.Timeout != customTimeout {
|
|
t.Errorf("Timeout = %v, want %v", client.Timeout, customTimeout)
|
|
}
|
|
}
|
|
|
|
func TestCreateHTTPClientWithTimeout_NegativeTimeout(t *testing.T) {
|
|
client := CreateHTTPClientWithTimeout(true, "", -10*time.Second)
|
|
|
|
if client == nil {
|
|
t.Fatal("CreateHTTPClientWithTimeout returned nil")
|
|
}
|
|
|
|
// Should use default timeout when negative is passed
|
|
if client.Timeout != 60*time.Second {
|
|
t.Errorf("Timeout = %v, want %v (default)", client.Timeout, 60*time.Second)
|
|
}
|
|
}
|
|
|
|
func TestCreateHTTPClient_TransportSettings(t *testing.T) {
|
|
client := CreateHTTPClient(true, "")
|
|
|
|
transport, ok := client.Transport.(*http.Transport)
|
|
if !ok {
|
|
t.Fatal("Transport is not *http.Transport")
|
|
}
|
|
|
|
// Verify transport settings
|
|
if transport.MaxIdleConns != 100 {
|
|
t.Errorf("MaxIdleConns = %v, want 100", transport.MaxIdleConns)
|
|
}
|
|
if transport.MaxIdleConnsPerHost != 20 {
|
|
t.Errorf("MaxIdleConnsPerHost = %v, want 20", transport.MaxIdleConnsPerHost)
|
|
}
|
|
if transport.MaxConnsPerHost != 20 {
|
|
t.Errorf("MaxConnsPerHost = %v, want 20", transport.MaxConnsPerHost)
|
|
}
|
|
if transport.IdleConnTimeout != 90*time.Second {
|
|
t.Errorf("IdleConnTimeout = %v, want 90s", transport.IdleConnTimeout)
|
|
}
|
|
if !transport.DisableCompression {
|
|
t.Error("DisableCompression should be true")
|
|
}
|
|
if transport.TLSHandshakeTimeout != 10*time.Second {
|
|
t.Errorf("TLSHandshakeTimeout = %v, want 10s", transport.TLSHandshakeTimeout)
|
|
}
|
|
}
|
|
|
|
func TestShouldBypassProxyForTargetHost(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
host string
|
|
want bool
|
|
}{
|
|
{name: "single label Proxmox host", host: "delly", want: true},
|
|
{name: "localhost", host: "localhost", want: true},
|
|
{name: "mDNS local", host: "pve.local", want: true},
|
|
{name: "loopback IPv4", host: "127.0.0.1", want: true},
|
|
{name: "loopback IPv6", host: "::1", want: true},
|
|
{name: "RFC1918 IPv4", host: "192.168.0.5", want: true},
|
|
{name: "Tailscale CGNAT IPv4", host: "100.127.165.127", want: true},
|
|
{name: "public IPv4", host: "203.0.113.10", want: false},
|
|
{name: "public DNS name", host: "pve.example.com", want: false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := shouldBypassProxyForTargetHost(tt.host); got != tt.want {
|
|
t.Fatalf("shouldBypassProxyForTargetHost(%q) = %v, want %v", tt.host, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProxyFromEnvironmentBypassingLocalInfrastructure(t *testing.T) {
|
|
t.Setenv("HTTP_PROXY", "http://proxy.example:8080")
|
|
t.Setenv("HTTPS_PROXY", "http://proxy.example:8080")
|
|
t.Setenv("NO_PROXY", "")
|
|
|
|
localReq := &http.Request{URL: &url.URL{Scheme: "https", Host: "192.168.0.5:8006"}}
|
|
if proxyURL, err := proxyFromEnvironmentBypassingLocalInfrastructure(localReq); err != nil || proxyURL != nil {
|
|
t.Fatalf("local proxy = %v, err = %v; want direct", proxyURL, err)
|
|
}
|
|
|
|
publicReq := &http.Request{URL: &url.URL{Scheme: "https", Host: "pve.example.com:8006"}}
|
|
proxyURL, err := proxyFromEnvironmentBypassingLocalInfrastructure(publicReq)
|
|
if err != nil {
|
|
t.Fatalf("public proxy lookup failed: %v", err)
|
|
}
|
|
if proxyURL == nil || proxyURL.String() != "http://proxy.example:8080" {
|
|
t.Fatalf("public proxy = %v, want http://proxy.example:8080", proxyURL)
|
|
}
|
|
}
|
|
|
|
func TestFingerprintVerifier_IgnoresVerifiedChains(t *testing.T) {
|
|
mockCert := []byte("test certificate")
|
|
fingerprint := sha256.Sum256(mockCert)
|
|
expectedFP := hex.EncodeToString(fingerprint[:])
|
|
|
|
config := FingerprintVerifier(expectedFP)
|
|
|
|
// verifiedChains parameter should be ignored
|
|
mockChains := [][]*x509.Certificate{{&x509.Certificate{}}}
|
|
err := config.VerifyPeerCertificate([][]byte{mockCert}, mockChains)
|
|
if err != nil {
|
|
t.Errorf("Should ignore verifiedChains, got error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGetDNSResolver(t *testing.T) {
|
|
resolver := GetDNSResolver()
|
|
|
|
if resolver == nil {
|
|
t.Fatal("GetDNSResolver returned nil")
|
|
}
|
|
|
|
// Call again - should return same instance (singleton)
|
|
resolver2 := GetDNSResolver()
|
|
if resolver != resolver2 {
|
|
t.Error("GetDNSResolver should return same instance")
|
|
}
|
|
}
|
|
|
|
func TestFingerprintVerifier_ColonSeparatedFingerprint(t *testing.T) {
|
|
mockCert := []byte("test cert with colons")
|
|
fingerprint := sha256.Sum256(mockCert)
|
|
|
|
// Format with colons (common format from openssl)
|
|
fpBytes := fingerprint[:]
|
|
var parts []string
|
|
for _, b := range fpBytes {
|
|
parts = append(parts, hex.EncodeToString([]byte{b}))
|
|
}
|
|
colonSeparated := strings.ToUpper(strings.Join(parts, ":"))
|
|
|
|
config := FingerprintVerifier(colonSeparated)
|
|
|
|
err := config.VerifyPeerCertificate([][]byte{mockCert}, nil)
|
|
if err != nil {
|
|
t.Errorf("Should handle colon-separated fingerprint, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCreateHTTPClient_FingerprintTakesPrecedence(t *testing.T) {
|
|
// Even if verifySSL is true, fingerprint mode should be used if fingerprint is provided
|
|
fingerprint := "aabbccdd"
|
|
client := CreateHTTPClient(true, fingerprint)
|
|
|
|
transport, ok := client.Transport.(*http.Transport)
|
|
if !ok {
|
|
t.Fatal("Transport is not *http.Transport")
|
|
}
|
|
|
|
// Should use fingerprint verification
|
|
if transport.TLSClientConfig == nil {
|
|
t.Fatal("TLSClientConfig should be set")
|
|
}
|
|
if transport.TLSClientConfig.VerifyPeerCertificate == nil {
|
|
t.Error("Should use fingerprint verification when fingerprint is provided")
|
|
}
|
|
}
|
|
|
|
func TestFingerprintVerifier_TLSVersion(t *testing.T) {
|
|
config := FingerprintVerifier("aabbccdd")
|
|
|
|
// Check that config is a valid TLS config
|
|
if config == nil {
|
|
t.Fatal("FingerprintVerifier returned nil config")
|
|
}
|
|
|
|
if config.MinVersion != minimumTLSVersion {
|
|
t.Errorf("MinVersion should be %v, got %v", minimumTLSVersion, config.MinVersion)
|
|
}
|
|
}
|
|
|
|
// No service listens on these fixed ports in the isolated source-proof guest.
|
|
// The public helper's dial error exposes its actual selected authority, without
|
|
// requiring a privileged listener, a resolver override or an external probe.
|
|
func TestFetchFingerprintEndpointSelection(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name, input, address string
|
|
}{
|
|
{"explicit-https-default", "https://127.0.0.1", "127.0.0.1:443"},
|
|
{"explicit-http-default", "http://127.0.0.1", "127.0.0.1:80"},
|
|
{"bare-ipv6-default", "::1", "[::1]:8006"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
fingerprint, err := FetchFingerprint(tc.input)
|
|
if fingerprint != "" || err == nil || !strings.HasPrefix(err.Error(), "failed to connect to "+tc.address+":") {
|
|
t.Fatalf("capture did not dial the requested authority %s: fingerprint=%q error=%v", tc.address, fingerprint, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFingerprintTargetAuthority(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
input, address string
|
|
}{
|
|
{"pve.example", "pve.example:8006"},
|
|
{"pve.example:8007", "pve.example:8007"},
|
|
{"127.0.0.1", "127.0.0.1:8006"},
|
|
{"[::1]", "[::1]:8006"},
|
|
{"2001:db8::1", "[2001:db8::1]:8006"},
|
|
{"fe80::1%eth0", "[fe80::1%eth0]:8006"},
|
|
{"[::1]:8443", "[::1]:8443"},
|
|
{"https://pve.example", "pve.example:443"},
|
|
{"HTTPS://pve.example/proxy/", "pve.example:443"},
|
|
{"http://pve.example", "pve.example:80"},
|
|
{"https://pve.example:08006/proxy/", "pve.example:8006"},
|
|
{"https://[2001:db8::1]/proxy/", "[2001:db8::1]:443"},
|
|
{"https://[fe80::1%25eth0]:8443/proxy/", "[fe80::1%eth0]:8443"},
|
|
{" https://pve.example:65535/ ", "pve.example:65535"},
|
|
} {
|
|
t.Run(tc.input, func(t *testing.T) {
|
|
address, err := fingerprintTarget(tc.input)
|
|
if err != nil || address != tc.address {
|
|
t.Fatalf("capture authority = %q, %v; want %q", address, err, tc.address)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFetchFingerprintRejectsInvalidAuthorityBeforeDial(t *testing.T) {
|
|
for _, input := range []string{
|
|
"", "https://", "ftp://127.0.0.1:8006", "https://[::1", "https://::1",
|
|
"https://127.0.0.1:", "https://127.0.0.1:0", "https://127.0.0.1:65536",
|
|
"https://127.0.0.1:abc", "https://[not-an-ip]:8006", "127.0.0.1/proxy",
|
|
"https://fixture-user:fixture-password@127.0.0.1:8006",
|
|
"https://127.0.0.1:8006/?query=fixture", "https://127.0.0.1:8006/#fragment",
|
|
} {
|
|
t.Run(input, func(t *testing.T) {
|
|
fingerprint, err := FetchFingerprint(input)
|
|
if fingerprint != "" || err == nil || !strings.HasPrefix(err.Error(), "failed to parse host URL:") {
|
|
t.Fatalf("invalid authority reached dial or capture: fingerprint=%q error=%v", fingerprint, err)
|
|
}
|
|
if strings.Contains(err.Error(), "fixture-password") {
|
|
t.Fatal("invalid authority exposed userinfo in its error")
|
|
}
|
|
})
|
|
}
|
|
}
|