mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 10:35:51 +00:00
64fb3d198d
The restricted outbound dialer resolved a hostname, validated the results, then dialed only the first permitted IP. When localhost resolves to ::1 ahead of 127.0.0.1 and the target service binds only 127.0.0.1 (Ollama's default), the dial failed with 'connect: connection refused' even though curl and browsers connect fine via address-family fallback. This broke the AI provider setup quickstart: the blessed http://localhost:11434 Ollama URL failed Pulse's connection test on IPv6-first hosts (setup-friction class of #847/#1003). Try each permitted IP from the validating resolution in order until one connects. Every candidate is still validated against the outbound policy from the same resolution, so the DNS-rebinding pinning guarantee is unchanged. Fixes all consumers of the shared client (Ollama provider, OIDC, SSO, connection probes, availability poller); api-contracts wording updated to match. Live-verified against Ollama 0.32.1 bound to 127.0.0.1 only: TestConnection via http://localhost:11434 failed before, passes after.
107 lines
3.2 KiB
Go
107 lines
3.2 KiB
Go
package securityutil
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestRestrictedOutboundHTTPClient_BlocksMetadataServiceEvenViaProxy(t *testing.T) {
|
|
client := NewRestrictedOutboundHTTPClient(0, RestrictedOutboundHTTPOptions{
|
|
AllowedSchemes: []string{"http", "https"},
|
|
AllowPrivateIPs: true,
|
|
AllowLoopback: true,
|
|
})
|
|
|
|
req, err := http.NewRequestWithContext(context.Background(), "GET", "http://169.254.169.254/api/version", nil)
|
|
if err != nil {
|
|
t.Fatalf("failed to create request: %v", err)
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if resp != nil {
|
|
resp.Body.Close()
|
|
}
|
|
if err == nil {
|
|
t.Fatal("expected error for metadata service address, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "metadata service address is not allowed") {
|
|
t.Fatalf("expected 'metadata service address is not allowed', got %v", err)
|
|
}
|
|
}
|
|
|
|
// A host like "localhost" can resolve to ::1 ahead of 127.0.0.1 while the
|
|
// target service (e.g. Ollama's default bind) listens only on 127.0.0.1. The
|
|
// restricted dialer must fall through to later permitted IPs instead of
|
|
// failing on the first refused address family.
|
|
func TestRestrictedOutboundHTTPClient_TriesAllPermittedResolvedIPs(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.WriteString(w, "ok")
|
|
}))
|
|
defer srv.Close()
|
|
|
|
serverHost, port, err := net.SplitHostPort(srv.Listener.Addr().String())
|
|
if err != nil {
|
|
t.Fatalf("failed to parse test server address: %v", err)
|
|
}
|
|
if serverHost != "127.0.0.1" {
|
|
t.Skipf("test server bound to %s, need an IPv4 loopback listener", serverHost)
|
|
}
|
|
|
|
client := NewRestrictedOutboundHTTPClient(0, RestrictedOutboundHTTPOptions{
|
|
AllowedSchemes: []string{"http", "https"},
|
|
AllowPrivateIPs: true,
|
|
AllowLoopback: true,
|
|
ResolveIPAddrs: func(ctx context.Context, host string) ([]net.IPAddr, error) {
|
|
return []net.IPAddr{
|
|
{IP: net.ParseIP("::1")},
|
|
{IP: net.ParseIP("127.0.0.1")},
|
|
}, nil
|
|
},
|
|
})
|
|
|
|
resp, err := client.Get("http://ipv6-first.test:" + port + "/")
|
|
if err != nil {
|
|
t.Fatalf("expected fallback to the second permitted IP to succeed, got %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatalf("failed to read response body: %v", err)
|
|
}
|
|
if string(body) != "ok" {
|
|
t.Fatalf("expected body %q, got %q", "ok", string(body))
|
|
}
|
|
}
|
|
|
|
func TestRestrictedOutboundHTTPClient_BlocksLinkLocalEvenViaProxy(t *testing.T) {
|
|
client := NewRestrictedOutboundHTTPClient(0, RestrictedOutboundHTTPOptions{
|
|
AllowedSchemes: []string{"http", "https"},
|
|
AllowPrivateIPs: true,
|
|
AllowLoopback: true,
|
|
})
|
|
|
|
req, err := http.NewRequestWithContext(context.Background(), "GET", "http://169.254.10.20/test", nil)
|
|
if err != nil {
|
|
t.Fatalf("failed to create request: %v", err)
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if resp != nil {
|
|
resp.Body.Close()
|
|
}
|
|
if err == nil {
|
|
t.Fatal("expected error for link-local address, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "link-local addresses are not allowed") {
|
|
t.Fatalf("expected 'link-local addresses are not allowed', got %v", err)
|
|
}
|
|
}
|