Files
pulse/pkg/securityutil/outbound_http_test.go
rcourtman 64fb3d198d fix(securityutil): dial every permitted resolved IP instead of pinning the first
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.
2026-07-17 23:35:45 +01:00

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)
}
}