@@ -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.
2023type 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.
74101func (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
0 commit comments