Skip to content

Commit a962ad1

Browse files
committed
move SSRF IP check into DialContext (close DNS rebinding TOCTOU) (H3/H4 follow-up)
Signed-off-by: Adam Martin <adam.martin@ranchergovernment.com>
1 parent c2a559a commit a962ad1

2 files changed

Lines changed: 121 additions & 21 deletions

File tree

pkg/getter/https.go

Lines changed: 73 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,18 @@ import (
1616
"hauler.dev/go/hauler/pkg/consts"
1717
)
1818

19+
// dialTimeout is the TCP connect and keep-alive timeout used by safeDial.
20+
const dialTimeout = 30 * time.Second
21+
1922
// HttpOptions configures the behaviour of the Http getter.
2023
type HttpOptions struct {
21-
// AllowInternalTargets disables the default SSRF guard that rejects
22-
// requests whose resolved IP falls in RFC-1918, loopback, link-local, or
23-
// unique-local space. Set to true only for isolated internal CI
24-
// environments that intentionally fetch from private hosts.
24+
// AllowInternalTargets disables the SSRF guard that is enforced at dial
25+
// time by the custom DialContext. When false (the default), every IP
26+
// address returned by DNS resolution is validated against isInternalIP
27+
// before any connection is attempted, and the connection is made to the
28+
// resolved IP literal directly so the check and the connect target the
29+
// same address. Set to true only for isolated internal CI environments
30+
// that intentionally fetch from private or loopback hosts.
2531
AllowInternalTargets bool
2632

2733
// Timeout overrides the default HTTP client timeout.
@@ -59,45 +65,91 @@ func NewHttpWithOptions(opts HttpOptions) *Http {
5965
}
6066

6167
h := &Http{opts: opts, maxBytes: maxBytes}
68+
69+
baseDialer := &net.Dialer{
70+
Timeout: dialTimeout,
71+
KeepAlive: dialTimeout,
72+
}
73+
transport := &http.Transport{
74+
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
75+
return h.safeDial(ctx, baseDialer, network, address)
76+
},
77+
TLSHandshakeTimeout: 10 * time.Second,
78+
ResponseHeaderTimeout: 30 * time.Second,
79+
ExpectContinueTimeout: 1 * time.Second,
80+
MaxIdleConns: 10,
81+
IdleConnTimeout: 90 * time.Second,
82+
// Do NOT set TLSClientConfig: Go derives tls.Config.ServerName from
83+
// the request URL hostname, so TLS cert verification continues to use
84+
// the hostname even though we dial by IP literal.
85+
}
86+
6287
h.client = &http.Client{
63-
Timeout: timeout,
88+
Timeout: timeout,
89+
Transport: transport,
6490
CheckRedirect: func(req *http.Request, via []*http.Request) error {
6591
return h.validateRequest(req)
6692
},
6793
}
6894
return h
6995
}
7096

71-
// validateRequest enforces scheme and (when AllowInternalTargets is false)
72-
// private-IP restrictions. It is called for the initial request and each
73-
// redirect hop via CheckRedirect.
97+
// validateRequest enforces scheme restrictions. It is called for the initial
98+
// request and each redirect hop via CheckRedirect. IP/host validation is
99+
// performed at dial time by safeDial so the checked address is exactly the
100+
// address we connect to, eliminating the DNS-rebinding TOCTOU.
74101
func (h *Http) validateRequest(req *http.Request) error {
75102
switch req.URL.Scheme {
76103
case "http", "https":
77104
default:
78105
return fmt.Errorf("scheme %q is not allowed; only http and https are permitted", req.URL.Scheme)
79106
}
107+
return nil
108+
}
80109

81-
if h.opts.AllowInternalTargets {
82-
return nil
110+
// safeDial resolves address to candidate IPs, rejects internal IPs (when
111+
// AllowInternalTargets=false), and dials the resolved IP literal directly.
112+
// Performing both the IP check and the connect against the same resolved
113+
// address eliminates the DNS-rebinding TOCTOU that exists when validation
114+
// and connect each perform their own independent resolution.
115+
func (h *Http) safeDial(ctx context.Context, dialer *net.Dialer, network, address string) (net.Conn, error) {
116+
host, port, err := net.SplitHostPort(address)
117+
if err != nil {
118+
return nil, err
83119
}
84120

85-
host := req.URL.Hostname()
86-
addrs, err := net.LookupHost(host)
121+
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
87122
if err != nil {
88-
// If we cannot resolve, let the transport fail naturally.
89-
return nil
123+
return nil, fmt.Errorf("failed to resolve %s: %w", host, err)
124+
}
125+
if len(ips) == 0 {
126+
return nil, fmt.Errorf("no addresses found for %s", host)
127+
}
128+
129+
if !h.opts.AllowInternalTargets {
130+
// Reject if ANY candidate is internal — prevents an attacker from
131+
// returning [public, private] and hoping fallback hits the private IP.
132+
for _, ipAddr := range ips {
133+
if isInternalIP(ipAddr.IP) {
134+
return nil, fmt.Errorf("dial to %s rejected: resolved to internal address %s (use --allow-internal-targets to override)", host, ipAddr.IP)
135+
}
136+
}
90137
}
91-
for _, addr := range addrs {
92-
ip := net.ParseIP(addr)
93-
if ip == nil {
94-
continue
138+
139+
// Dial each candidate by IP literal until one succeeds. Bracket IPv6.
140+
var lastErr error
141+
for _, ipAddr := range ips {
142+
ipStr := ipAddr.IP.String()
143+
if ipAddr.IP.To4() == nil {
144+
ipStr = "[" + ipStr + "]"
95145
}
96-
if isInternalIP(ip) {
97-
return fmt.Errorf("request to %s rejected: resolved to internal address %s (use --allow-internal-targets to override)", host, addr)
146+
conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ipStr, port))
147+
if dialErr == nil {
148+
return conn, nil
98149
}
150+
lastErr = dialErr
99151
}
100-
return nil
152+
return nil, lastErr
101153
}
102154

103155
// isInternalIP reports whether ip is in a private, loopback, link-local, or

pkg/getter/https_security_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,51 @@ func TestHttp_Open_RejectsRedirectToPrivateIP(t *testing.T) {
148148
t.Fatal("Open() expected error when redirect targets private IP, got nil")
149149
}
150150
}
151+
152+
// TestHttp_Open_RejectsIPLiteralPrivate verifies that URLs containing a private,
153+
// loopback, link-local, or IMDS IP literal are rejected at dial time without
154+
// any external network round-trip.
155+
func TestHttp_Open_RejectsIPLiteralPrivate(t *testing.T) {
156+
cases := []string{
157+
"http://127.0.0.1:9/anything",
158+
"http://10.0.0.1:9/anything",
159+
"http://192.168.0.1:9/anything",
160+
"http://169.254.169.254/latest/meta",
161+
}
162+
h := getter.NewHttp() // default AllowInternalTargets=false
163+
for _, raw := range cases {
164+
t.Run(raw, func(t *testing.T) {
165+
u, _ := url.Parse(raw)
166+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
167+
defer cancel()
168+
_, err := h.Open(ctx, u)
169+
if err == nil {
170+
t.Fatalf("Open(%s) expected SSRF rejection, got nil", raw)
171+
}
172+
})
173+
}
174+
}
175+
176+
// TestHttp_Open_RejectsHostnameResolvingToLoopback verifies that the dial-time
177+
// check inspects the *resolved* IP, not just IP literals. This is the
178+
// meaningful demonstration that DNS rebinding is closed: even when the URL
179+
// hostname is "localhost" (not an IP literal), the dial fires the SSRF check
180+
// against the resolved 127.0.0.1.
181+
func TestHttp_Open_RejectsHostnameResolvingToLoopback(t *testing.T) {
182+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
183+
fmt.Fprint(w, "secret")
184+
}))
185+
defer srv.Close()
186+
187+
parsed, err := url.Parse(srv.URL)
188+
if err != nil {
189+
t.Fatalf("parse: %v", err)
190+
}
191+
parsed.Host = "localhost:" + parsed.Port()
192+
193+
h := getter.NewHttp() // default AllowInternalTargets=false
194+
_, err = h.Open(context.Background(), parsed)
195+
if err == nil {
196+
t.Fatal("Open() expected SSRF rejection for hostname resolving to loopback, got nil")
197+
}
198+
}

0 commit comments

Comments
 (0)