Skip to content

Commit 21ec027

Browse files
committed
fix(ssrf): allow configured web_search backend hostname to resolve to internal IPs
The SSRF dial guard blocked legitimate container-internal SearXNG backends (e.g. http://searxng:8080 resolving to 172.18.0.3). Add an optional hostname allowlist to ssrfGuardedDial/ssrfGuardedTransport and pass the configured web_search base_url host through so operator-configured backends are reachable while SSRF/DNS-rebinding protection remains in place for all other hosts. - cmd/odek/ssrf_guard.go: variadic allowedHosts bypasses internal-IP block - cmd/odek/web_search_tool.go: parse BaseURL hostname and pass to transport - cmd/odek/ssrf_guard_test.go: add tests for allowed internal backend
1 parent 564dddf commit 21ec027

3 files changed

Lines changed: 72 additions & 13 deletions

File tree

cmd/odek/ssrf_guard.go

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,20 @@ type ipLookupFunc func(ctx context.Context, host string) ([]net.IPAddr, error)
3939
// surfaced them to the policy gate as SystemWrite, so honouring that decision
4040
// here preserves explicitly-allowed localhost access (and keeps httptest-backed
4141
// tests working). Every other host is resolved via lookup and validated.
42-
func ssrfGuardedDial(base dialFunc, lookup ipLookupFunc) dialFunc {
42+
//
43+
// allowedHosts lists hostnames (without ports) that the operator explicitly
44+
// trusts, e.g. the configured web_search base_url. These hosts bypass the
45+
// internal-IP block so that container-internal services such as SearXNG can be
46+
// reached by name, while still being pinned to the resolved IP so the kernel
47+
// cannot re-resolve to a rebound address.
48+
func ssrfGuardedDial(base dialFunc, lookup ipLookupFunc, allowedHosts ...string) dialFunc {
49+
allowed := make(map[string]struct{}, len(allowedHosts))
50+
for _, h := range allowedHosts {
51+
if h != "" {
52+
allowed[h] = struct{}{}
53+
}
54+
}
55+
4356
return func(ctx context.Context, network, addr string) (net.Conn, error) {
4457
host, port, err := net.SplitHostPort(addr)
4558
if err != nil {
@@ -50,6 +63,8 @@ func ssrfGuardedDial(base dialFunc, lookup ipLookupFunc) dialFunc {
5063
return base(ctx, network, addr)
5164
}
5265

66+
_, hostAllowed := allowed[host]
67+
5368
ips, err := lookup(ctx, host)
5469
if err != nil {
5570
return nil, err
@@ -60,9 +75,13 @@ func ssrfGuardedDial(base dialFunc, lookup ipLookupFunc) dialFunc {
6075
// Validate every answer before dialing any of them: an attacker can
6176
// return a safe IP alongside an internal one hoping the dialer picks
6277
// the internal address. Refuse the whole set if any is internal.
63-
for _, ipa := range ips {
64-
if danger.IsBlockedIP(ipa.IP) {
65-
return nil, fmt.Errorf("blocked connection to %q: resolves to internal address %s (possible SSRF / DNS rebinding)", host, ipa.IP)
78+
// Operator-allowed hosts bypass this check so configured internal
79+
// backends (e.g. SearXNG under Docker) remain reachable.
80+
if !hostAllowed {
81+
for _, ipa := range ips {
82+
if danger.IsBlockedIP(ipa.IP) {
83+
return nil, fmt.Errorf("blocked connection to %q: resolves to internal address %s (possible SSRF / DNS rebinding)", host, ipa.IP)
84+
}
6685
}
6786
}
6887
// Pin to validated IPs — never hand the hostname back to the kernel,
@@ -81,20 +100,22 @@ func ssrfGuardedDial(base dialFunc, lookup ipLookupFunc) dialFunc {
81100
}
82101

83102
// ssrfGuardedTransport returns an *http.Transport whose DialContext is the SSRF
84-
// guard above, backed by the real dialer and resolver. It clones the default
85-
// transport when possible so it inherits sane defaults (env proxy handling,
86-
// idle-conn limits, HTTP/2, TLS handshake timeout); if a third-party package
87-
// has swapped http.DefaultTransport for a non-*http.Transport RoundTripper, it
88-
// falls back to a fresh transport with explicit proxy handling rather than
89-
// panicking on the type assertion — this runs at startup, so it must fail safe.
90-
func ssrfGuardedTransport() *http.Transport {
103+
// guard above, backed by the real dialer and resolver. allowedHosts are
104+
// operator-trusted hostnames (e.g. the web_search base_url host) that may
105+
// resolve to internal addresses. It clones the default transport when possible
106+
// so it inherits sane defaults (env proxy handling, idle-conn limits, HTTP/2,
107+
// TLS handshake timeout); if a third-party package has swapped
108+
// http.DefaultTransport for a non-*http.Transport RoundTripper, it falls back
109+
// to a fresh transport with explicit proxy handling rather than panicking on
110+
// the type assertion — this runs at startup, so it must fail safe.
111+
func ssrfGuardedTransport(allowedHosts ...string) *http.Transport {
91112
base := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}
92113
tr, ok := http.DefaultTransport.(*http.Transport)
93114
if ok {
94115
tr = tr.Clone()
95116
} else {
96117
tr = &http.Transport{Proxy: http.ProxyFromEnvironment}
97118
}
98-
tr.DialContext = ssrfGuardedDial(base.DialContext, net.DefaultResolver.LookupIPAddr)
119+
tr.DialContext = ssrfGuardedDial(base.DialContext, net.DefaultResolver.LookupIPAddr, allowedHosts...)
99120
return tr
100121
}

cmd/odek/ssrf_guard_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,35 @@ func TestSSRFGuardedDial_ExternalPinnedToValidatedIP(t *testing.T) {
115115
}
116116
}
117117

118+
func TestSSRFGuardedDial_AllowedHostPermitsInternal(t *testing.T) {
119+
// The configured web_search backend (e.g. SearXNG under Docker) may resolve
120+
// to a private container IP. An explicit allowlist lets it through while
121+
// still pinning the dial to the resolved IP.
122+
var dialed []string
123+
guard := ssrfGuardedDial(recordingDial(&dialed), stubLookup("172.18.0.3"), "searxng")
124+
125+
if _, err := guard(context.Background(), "tcp", "searxng:8080"); err != nil {
126+
t.Fatalf("unexpected error: %v", err)
127+
}
128+
if len(dialed) != 1 || dialed[0] != "172.18.0.3:8080" {
129+
t.Errorf("dialed = %v, want [172.18.0.3:8080] (allowed internal backend pinned to IP)", dialed)
130+
}
131+
}
132+
133+
func TestSSRFGuardedDial_AllowedHostPortIgnored(t *testing.T) {
134+
// The allowlist matches the hostname only; different ports on the same host
135+
// are still allowed.
136+
var dialed []string
137+
guard := ssrfGuardedDial(recordingDial(&dialed), stubLookup("172.18.0.3"), "searxng")
138+
139+
if _, err := guard(context.Background(), "tcp", "searxng:9090"); err != nil {
140+
t.Fatalf("unexpected error: %v", err)
141+
}
142+
if len(dialed) != 1 || dialed[0] != "172.18.0.3:9090" {
143+
t.Errorf("dialed = %v, want [172.18.0.3:9090]", dialed)
144+
}
145+
}
146+
118147
// TestBrowser_SSRF_ResolvesInternal exercises the guard through the real
119148
// browser navigate path: the hostname classifies as NetworkEgress (so the
120149
// policy gate lets it through) but resolves to the cloud-metadata IP, and the

cmd/odek/web_search_tool.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,19 @@ func newWebSearchTool(dc danger.DangerousConfig, cfg config.WebSearchConfig) *we
4646
timeout = 15
4747
}
4848
t := &webSearchTool{dangerousConfig: dc, cfg: cfg}
49+
50+
// The configured SearXNG backend is trusted by the operator. Allow its
51+
// hostname to resolve to internal Docker addresses (e.g. 172.18.0.3) without
52+
// tripping the SSRF / DNS-rebinding guard.
53+
allowedHost := ""
54+
if u, err := url.Parse(cfg.BaseURL); err == nil && u.Host != "" {
55+
allowedHost = u.Hostname()
56+
}
57+
4958
t.client = &http.Client{
5059
Timeout: time.Duration(timeout) * time.Second,
5160
CheckRedirect: t.checkRedirect,
52-
Transport: ssrfGuardedTransport(),
61+
Transport: ssrfGuardedTransport(allowedHost),
5362
}
5463
return t
5564
}

0 commit comments

Comments
 (0)