mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-27 01:41:58 +00:00
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.
This commit is contained in:
parent
1e267566af
commit
64fb3d198d
3 changed files with 95 additions and 8 deletions
|
|
@ -2785,8 +2785,11 @@ a new API state machine, queue contract, or verification-accounting field.
|
|||
to block unauthenticated SSRF against internal hosts. That same probe path
|
||||
must also validate user-supplied addresses before probing, reject metadata,
|
||||
link-local, multicast, and unspecified destinations, and pin each outbound
|
||||
dial to the first permitted resolved IP so DNS rebinding cannot swap the
|
||||
target between validation and connect time. That same `/api/connections`
|
||||
dial to the permitted IPs from the validating resolution so DNS rebinding
|
||||
cannot swap the target between validation and connect time. Within that
|
||||
pinned set the dialer tries each permitted IP in resolution order, so a
|
||||
host whose first address family is unreachable (e.g. `localhost` resolving
|
||||
to `::1` while the service listens only on `127.0.0.1`) still connects. That same `/api/connections`
|
||||
payload now also owns the additive `systems[]` grouping contract for the
|
||||
infrastructure settings source manager. Those grouped rows must stay
|
||||
source-oriented and backend-authored: one primary source row may carry
|
||||
|
|
|
|||
|
|
@ -72,7 +72,12 @@ func resolveOutboundIPAddrs(ctx context.Context, host string, opts RestrictedOut
|
|||
return resolveOutboundFetchIPs(ctx, host)
|
||||
}
|
||||
|
||||
func resolvePermittedOutboundIP(ctx context.Context, host string, opts RestrictedOutboundHTTPOptions) (net.IP, error) {
|
||||
// resolvePermittedOutboundIPs resolves host and returns every permitted IP in
|
||||
// resolution order. Callers that dial must try each returned IP: a host like
|
||||
// "localhost" can resolve to ::1 first while the service listens only on
|
||||
// 127.0.0.1, so pinning the first permitted IP alone turns an address-family
|
||||
// mismatch into a hard connection failure.
|
||||
func resolvePermittedOutboundIPs(ctx context.Context, host string, opts RestrictedOutboundHTTPOptions) ([]net.IP, error) {
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" {
|
||||
return nil, fmt.Errorf("URL hostname is required")
|
||||
|
|
@ -87,7 +92,7 @@ func resolvePermittedOutboundIP(ctx context.Context, host string, opts Restricte
|
|||
if err := validateOutboundIP(ip, opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ip, nil
|
||||
return []net.IP{ip}, nil
|
||||
}
|
||||
|
||||
baseCtx := ctx
|
||||
|
|
@ -105,13 +110,17 @@ func resolvePermittedOutboundIP(ctx context.Context, host string, opts Restricte
|
|||
return nil, fmt.Errorf("hostname %s did not resolve", host)
|
||||
}
|
||||
|
||||
var permitted []net.IP
|
||||
var blockedErr error
|
||||
for _, addr := range addrs {
|
||||
if err := validateOutboundIP(addr.IP, opts); err != nil {
|
||||
blockedErr = err
|
||||
continue
|
||||
}
|
||||
return addr.IP, nil
|
||||
permitted = append(permitted, addr.IP)
|
||||
}
|
||||
if len(permitted) > 0 {
|
||||
return permitted, nil
|
||||
}
|
||||
|
||||
if blockedErr != nil {
|
||||
|
|
@ -120,6 +129,14 @@ func resolvePermittedOutboundIP(ctx context.Context, host string, opts Restricte
|
|||
return nil, fmt.Errorf("hostname %s did not resolve", host)
|
||||
}
|
||||
|
||||
func resolvePermittedOutboundIP(ctx context.Context, host string, opts RestrictedOutboundHTTPOptions) (net.IP, error) {
|
||||
ips, err := resolvePermittedOutboundIPs(ctx, host, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ips[0], nil
|
||||
}
|
||||
|
||||
// ValidateOutboundFetchURL validates a fully-qualified HTTP(S) URL against the restricted outbound policy.
|
||||
func ValidateOutboundFetchURL(ctx context.Context, raw string, opts RestrictedOutboundHTTPOptions) (*url.URL, error) {
|
||||
parsed, err := NormalizeAbsoluteHTTPURL(raw)
|
||||
|
|
@ -233,7 +250,7 @@ func (r *restrictedRoundTripper) RoundTrip(req *http.Request) (*http.Response, e
|
|||
}
|
||||
|
||||
// NewRestrictedOutboundHTTPClient returns an HTTP client that validates redirects and pins direct outbound dials
|
||||
// to the first permitted resolved IP for the requested host.
|
||||
// to the permitted resolved IPs for the requested host, trying each in resolution order until one connects.
|
||||
func NewRestrictedOutboundHTTPClient(timeout time.Duration, opts RestrictedOutboundHTTPOptions) *http.Client {
|
||||
transport := cloneRestrictedTransport(opts.TLSConfig)
|
||||
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
|
|
@ -242,13 +259,30 @@ func NewRestrictedOutboundHTTPClient(timeout time.Duration, opts RestrictedOutbo
|
|||
return nil, fmt.Errorf("parse outbound address %q: %w", addr, err)
|
||||
}
|
||||
|
||||
permittedIP, err := resolvePermittedOutboundIP(ctx, host, opts)
|
||||
permittedIPs, err := resolvePermittedOutboundIPs(ctx, host, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Try every permitted IP from the validating resolution, not just the
|
||||
// first: "localhost" commonly resolves to ::1 ahead of 127.0.0.1, and a
|
||||
// service bound only to one loopback family (e.g. Ollama on 127.0.0.1)
|
||||
// would otherwise be unreachable even though curl and browsers connect
|
||||
// fine. Each candidate was validated above, so rebinding protection is
|
||||
// unchanged.
|
||||
dialer := net.Dialer{Timeout: 10 * time.Second}
|
||||
return dialer.DialContext(ctx, network, net.JoinHostPort(permittedIP.String(), port))
|
||||
var dialErr error
|
||||
for _, permittedIP := range permittedIPs {
|
||||
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(permittedIP.String(), port))
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
dialErr = err
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil, dialErr
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ package securityutil
|
|||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
|
@ -31,6 +34,53 @@ func TestRestrictedOutboundHTTPClient_BlocksMetadataServiceEvenViaProxy(t *testi
|
|||
}
|
||||
}
|
||||
|
||||
// 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"},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue