Skip to content

Commit 40839ed

Browse files
jkyberneeesclaude
andauthored
fix(security): add dial-time IP guard to stop SSRF / DNS-rebinding (#31)
* fix(security): add dial-time IP guard to stop SSRF / DNS-rebinding danger.ClassifyURL inspects only the literal hostname, so a domain whose A/AAAA record resolves to 169.254.169.254, 10.x, 192.168.x, or fd00::/8 classified as plain NetworkEgress and the HTTP clients used the default transport with no post-resolution check — an SSRF to cloud metadata / internal services, plus a DNS-rebinding window between the gate's view and the kernel's dial. - Add danger.IsBlockedIP (single source of truth for internal ranges: loopback, RFC1918/RFC4193 private incl. IPv6 ULA, link-local incl. the metadata endpoint, unspecified) and HostIsImplicitlyInternal; refactor ClassifyURL to share them (behavior unchanged). - Add a shared ssrfGuardedDial transport: for a host that presents as external it resolves the name, refuses if ANY answer is internal (fail closed), then pins the dial to a validated IP so the kernel cannot re-resolve to a rebound address. Hosts already internal by inspection are left to the policy gate (preserves explicitly-allowed localhost). - Install the guarded transport on the browser and http_batch clients. Because every redirect hop dials through it, this also re-checks redirect targets at the IP layer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * harden(ssrf): fail safe if http.DefaultTransport was globally replaced vprotocol axis 2.4: ssrfGuardedTransport ran at startup with an unchecked http.DefaultTransport.(*http.Transport) assertion. A third-party package that swaps the global for a custom RoundTripper would panic odek at boot. Use a checked assertion with a fresh-transport fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 90a0477 commit 40839ed

6 files changed

Lines changed: 439 additions & 18 deletions

File tree

cmd/odek/browser_tool.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,10 @@ func newBrowserTool(dc danger.DangerousConfig) *browserTool {
6666
state: &browserState{nextRef: 1},
6767
dangerousConfig: dc,
6868
}
69-
t.client = &http.Client{CheckRedirect: t.checkRedirect}
69+
t.client = &http.Client{
70+
CheckRedirect: t.checkRedirect,
71+
Transport: ssrfGuardedTransport(),
72+
}
7073
return t
7174
}
7275

@@ -162,7 +165,10 @@ func (t *browserTool) Call(argsJSON string) (string, error) {
162165
t.state = &browserState{nextRef: 1}
163166
}
164167
if t.client == nil {
165-
t.client = &http.Client{CheckRedirect: t.checkRedirect}
168+
t.client = &http.Client{
169+
CheckRedirect: t.checkRedirect,
170+
Transport: ssrfGuardedTransport(),
171+
}
166172
}
167173

168174
switch args.Action {

cmd/odek/perf_tools.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,7 @@ func newHTTPBatchTool(dc danger.DangerousConfig) *httpBatchTool {
426426
t.client = &http.Client{
427427
Timeout: 30 * time.Second,
428428
CheckRedirect: t.checkRedirect,
429+
Transport: ssrfGuardedTransport(),
429430
}
430431
return t
431432
}

cmd/odek/ssrf_guard.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net"
7+
"net/http"
8+
"time"
9+
10+
"github.com/BackendStack21/odek/internal/danger"
11+
)
12+
13+
// SSRF / DNS-rebinding dial guard.
14+
//
15+
// danger.ClassifyURL inspects only the literal hostname of a URL, so a domain
16+
// whose A/AAAA record points at 169.254.169.254 (cloud metadata), 10.x,
17+
// 192.168.x, fd00::/8, etc. sails through the pre-request gate as plain
18+
// NetworkEgress. With the default HTTP transport the kernel then resolves and
19+
// connects with no second check — an SSRF. The same literal-only gate leaves a
20+
// DNS-rebinding window: the address the policy saw and the address actually
21+
// dialed can differ.
22+
//
23+
// ssrfGuardedDial closes both holes at the transport layer. For a host that
24+
// presented as external it resolves the name itself, refuses the connection if
25+
// ANY answer is an internal range (fail closed), and then pins the dial to an
26+
// already-validated IP so the kernel cannot re-resolve to a rebound address.
27+
// Because every redirect hop dials through the same transport, this also
28+
// re-checks redirect targets without any per-tool redirect logic.
29+
30+
// dialFunc matches (*net.Dialer).DialContext and http.Transport.DialContext.
31+
type dialFunc func(ctx context.Context, network, addr string) (net.Conn, error)
32+
33+
// ipLookupFunc matches (*net.Resolver).LookupIPAddr; injectable for testing.
34+
type ipLookupFunc func(ctx context.Context, host string) ([]net.IPAddr, error)
35+
36+
// ssrfGuardedDial wraps a base dial function with a post-resolution IP check.
37+
// Hosts that are already internal by inspection (a literal internal IP or a
38+
// known-internal hostname) are dialed through unchanged: ClassifyURL already
39+
// surfaced them to the policy gate as SystemWrite, so honouring that decision
40+
// here preserves explicitly-allowed localhost access (and keeps httptest-backed
41+
// tests working). Every other host is resolved via lookup and validated.
42+
func ssrfGuardedDial(base dialFunc, lookup ipLookupFunc) dialFunc {
43+
return func(ctx context.Context, network, addr string) (net.Conn, error) {
44+
host, port, err := net.SplitHostPort(addr)
45+
if err != nil {
46+
return nil, err
47+
}
48+
49+
if danger.HostIsImplicitlyInternal(host) {
50+
return base(ctx, network, addr)
51+
}
52+
53+
ips, err := lookup(ctx, host)
54+
if err != nil {
55+
return nil, err
56+
}
57+
if len(ips) == 0 {
58+
return nil, fmt.Errorf("no addresses found for %q", host)
59+
}
60+
// Validate every answer before dialing any of them: an attacker can
61+
// return a safe IP alongside an internal one hoping the dialer picks
62+
// 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)
66+
}
67+
}
68+
// Pin to validated IPs — never hand the hostname back to the kernel,
69+
// which would resolve a second time and reopen the rebinding window.
70+
// Try each in order so a single unreachable answer still fails over.
71+
var dialErr error
72+
for _, ipa := range ips {
73+
conn, err := base(ctx, network, net.JoinHostPort(ipa.IP.String(), port))
74+
if err == nil {
75+
return conn, nil
76+
}
77+
dialErr = err
78+
}
79+
return nil, dialErr
80+
}
81+
}
82+
83+
// 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 {
91+
base := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}
92+
tr, ok := http.DefaultTransport.(*http.Transport)
93+
if ok {
94+
tr = tr.Clone()
95+
} else {
96+
tr = &http.Transport{Proxy: http.ProxyFromEnvironment}
97+
}
98+
tr.DialContext = ssrfGuardedDial(base.DialContext, net.DefaultResolver.LookupIPAddr)
99+
return tr
100+
}

cmd/odek/ssrf_guard_test.go

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"net"
6+
"net/http"
7+
"strings"
8+
"testing"
9+
10+
"github.com/BackendStack21/odek/internal/danger"
11+
)
12+
13+
// recordingDial returns a dialFunc that records the addr it was asked to dial
14+
// and never actually connects (returns nil, nil). Tests assert on *what* the
15+
// guard decided to dial, not on a live connection.
16+
func recordingDial(dialed *[]string) dialFunc {
17+
return func(_ context.Context, _ string, addr string) (net.Conn, error) {
18+
*dialed = append(*dialed, addr)
19+
return nil, nil
20+
}
21+
}
22+
23+
func stubLookup(ips ...string) ipLookupFunc {
24+
return func(_ context.Context, _ string) ([]net.IPAddr, error) {
25+
out := make([]net.IPAddr, 0, len(ips))
26+
for _, s := range ips {
27+
out = append(out, net.IPAddr{IP: net.ParseIP(s)})
28+
}
29+
return out, nil
30+
}
31+
}
32+
33+
func TestSSRFGuardedDial_ImplicitlyInternalDialedDirect(t *testing.T) {
34+
// A literal internal IP was already gated as SystemWrite by ClassifyURL;
35+
// the guard must dial it through unchanged without a DNS lookup (this is
36+
// what keeps httptest-on-127.0.0.1 tests working).
37+
var dialed []string
38+
lookupCalled := false
39+
lookup := func(_ context.Context, _ string) ([]net.IPAddr, error) {
40+
lookupCalled = true
41+
return nil, nil
42+
}
43+
guard := ssrfGuardedDial(recordingDial(&dialed), lookup)
44+
45+
if _, err := guard(context.Background(), "tcp", "127.0.0.1:8080"); err != nil {
46+
t.Fatalf("unexpected error: %v", err)
47+
}
48+
if lookupCalled {
49+
t.Error("lookup must NOT be called for an implicitly-internal literal IP")
50+
}
51+
if len(dialed) != 1 || dialed[0] != "127.0.0.1:8080" {
52+
t.Errorf("dialed = %v, want [127.0.0.1:8080]", dialed)
53+
}
54+
}
55+
56+
func TestSSRFGuardedDial_ExternalResolvingInternalRefused(t *testing.T) {
57+
// The SSRF / rebinding core case: a host that presents as external but
58+
// resolves to an internal address must be refused, and no dial attempted.
59+
cases := []struct {
60+
name string
61+
ip string
62+
}{
63+
{"cloud metadata", "169.254.169.254"},
64+
{"rfc1918", "10.1.2.3"},
65+
{"loopback", "127.0.0.1"},
66+
{"ipv6 ula", "fd00::1"},
67+
{"unspecified", "0.0.0.0"},
68+
}
69+
for _, tc := range cases {
70+
t.Run(tc.name, func(t *testing.T) {
71+
var dialed []string
72+
guard := ssrfGuardedDial(recordingDial(&dialed), stubLookup(tc.ip))
73+
74+
_, err := guard(context.Background(), "tcp", "evil.example.com:80")
75+
if err == nil {
76+
t.Fatal("expected the connection to be refused, got nil error")
77+
}
78+
if !strings.Contains(err.Error(), "internal address") {
79+
t.Errorf("error %q should mention the internal address", err)
80+
}
81+
if len(dialed) != 0 {
82+
t.Errorf("no dial must be attempted, dialed = %v", dialed)
83+
}
84+
})
85+
}
86+
}
87+
88+
func TestSSRFGuardedDial_MixedAnswersFailClosed(t *testing.T) {
89+
// An answer set mixing a safe and an internal IP must be refused entirely —
90+
// an attacker cannot smuggle an internal target past the guard by padding
91+
// the DNS response with a public address.
92+
var dialed []string
93+
guard := ssrfGuardedDial(recordingDial(&dialed), stubLookup("93.184.216.34", "10.0.0.1"))
94+
95+
if _, err := guard(context.Background(), "tcp", "evil.example.com:80"); err == nil {
96+
t.Fatal("expected refusal for mixed safe+internal answers")
97+
}
98+
if len(dialed) != 0 {
99+
t.Errorf("no dial must be attempted, dialed = %v", dialed)
100+
}
101+
}
102+
103+
func TestSSRFGuardedDial_ExternalPinnedToValidatedIP(t *testing.T) {
104+
// A genuinely external host is dialed — but pinned to the validated IP, not
105+
// the hostname, so the kernel cannot re-resolve to a rebound address.
106+
var dialed []string
107+
guard := ssrfGuardedDial(recordingDial(&dialed), stubLookup("93.184.216.34"))
108+
109+
if _, err := guard(context.Background(), "tcp", "example.com:443"); err != nil {
110+
t.Fatalf("unexpected error: %v", err)
111+
}
112+
if len(dialed) != 1 || dialed[0] != "93.184.216.34:443" {
113+
t.Errorf("dialed = %v, want [93.184.216.34:443] (pinned IP, not hostname)", dialed)
114+
}
115+
}
116+
117+
// TestBrowser_SSRF_ResolvesInternal exercises the guard through the real
118+
// browser navigate path: the hostname classifies as NetworkEgress (so the
119+
// policy gate lets it through) but resolves to the cloud-metadata IP, and the
120+
// dial guard refuses it.
121+
func TestBrowser_SSRF_ResolvesInternal(t *testing.T) {
122+
b := &browserTool{state: &browserState{nextRef: 1}}
123+
b.client = &http.Client{
124+
CheckRedirect: b.checkRedirect,
125+
Transport: &http.Transport{
126+
DialContext: ssrfGuardedDial((&net.Dialer{}).DialContext, stubLookup("169.254.169.254")),
127+
},
128+
}
129+
130+
result := callJSON(t, b, `{"action":"navigate","url":"http://internal-disguised.example.com/"}`)
131+
var r struct {
132+
Error string `json:"error"`
133+
}
134+
mustUnmarshal(t, result, &r)
135+
if r.Error == "" {
136+
t.Fatal("expected navigate to be blocked by the dial guard")
137+
}
138+
if !strings.Contains(r.Error, "internal address") && !strings.Contains(r.Error, "SSRF") {
139+
t.Errorf("error %q should explain the SSRF block", r.Error)
140+
}
141+
}
142+
143+
// TestHTTPBatch_SSRF_ResolvesInternal exercises the guard through the real
144+
// http_batch fetch path.
145+
func TestHTTPBatch_SSRF_ResolvesInternal(t *testing.T) {
146+
tool := newHTTPBatchTool(danger.DangerousConfig{})
147+
tool.client = &http.Client{
148+
CheckRedirect: tool.checkRedirect,
149+
Transport: &http.Transport{
150+
DialContext: ssrfGuardedDial((&net.Dialer{}).DialContext, stubLookup("10.0.0.1")),
151+
},
152+
}
153+
154+
result := callJSON(t, tool, `{"requests":[{"url":"http://internal-disguised.example.com/"}]}`)
155+
var r struct {
156+
Results []struct {
157+
Error string `json:"error"`
158+
} `json:"results"`
159+
}
160+
mustUnmarshal(t, result, &r)
161+
if len(r.Results) != 1 {
162+
t.Fatalf("Results = %d, want 1", len(r.Results))
163+
}
164+
if r.Results[0].Error == "" {
165+
t.Fatal("expected fetch to be blocked by the dial guard")
166+
}
167+
}
168+
169+
// TestSSRFGuardedTransport_Installed is a guard against regressions that would
170+
// silently drop the SSRF protection from the production constructors.
171+
func TestSSRFGuardedTransport_Installed(t *testing.T) {
172+
b := newBrowserTool(danger.DangerousConfig{})
173+
if b.client.Transport == nil {
174+
t.Error("browser tool client has no Transport — SSRF guard not installed")
175+
}
176+
if tr, ok := b.client.Transport.(*http.Transport); !ok || tr.DialContext == nil {
177+
t.Error("browser tool Transport is missing the guarded DialContext")
178+
}
179+
180+
h := newHTTPBatchTool(danger.DangerousConfig{})
181+
if h.client.Transport == nil {
182+
t.Error("http_batch tool client has no Transport — SSRF guard not installed")
183+
}
184+
if tr, ok := h.client.Transport.(*http.Transport); !ok || tr.DialContext == nil {
185+
t.Error("http_batch tool Transport is missing the guarded DialContext")
186+
}
187+
}

0 commit comments

Comments
 (0)