flawopen.com/ssrf/Go
Go SSRF: secure net/http with net.Dialer.Control
Go SSRF lets user URLs reach internal services and cloud metadata. Reject non-public peers in net.Dialer.Control; disable redirects and set timeouts.
Imagine a rail dispatcher who checks a passenger’s ticket, but the switch operator still chooses the track when the train departs. A station name can resolve to a different track later, and a reroute note can send the train elsewhere. In Go, checking the URL before http.Get is like checking only the ticket: the client can resolve DNS again and follow redirects. net.Dialer.Control checks the actual IP after DNS and immediately before connect(); netip.ParseAddrPort and Unmap() help classify that dialed address. Set Proxy: nil so a proxy cannot hide the real destination, and use CheckRedirect to refuse reroute notes.
Core Concepts & Subsystem Terms
http.DefaultClient defaultshttp.Getuseshttp.DefaultClient, which has no timeout and follows up to 10 redirects. A public URL that answersLocation: http://169.254.169.254/reaches the metadata service after any check on the original URL.net.IP.IsPrivate is not enoughIsPrivatecovers RFC 1918 and IPv6 unique-local ranges only. It returnsfalsefor169.254.169.254(link-local),0.0.0.0and100.64.0.0/10, and ignoring the error fromLookupIPlets a name that fails to resolve pass with an empty list.Check-then-fetch and DNS rebindingnet.LookupIPresolves the name at check time and the HTTP client resolves it again when it dials. A DNS server with a short TTL can answer with a public address first and127.0.0.1second, so the check says nothing about the connection.net.Dialer.ControlControlruns after DNS resolution and beforeconnect(), with the exactip:portbeing dialled. Every connection the transport opens, including redirects and retries, passes through it. SetProxy: nil, otherwise the proxy's address is what gets checked.netip.Addr.Unmap and address classesnetip.ParseAddrPortparses the dialled address,Unmap()turns::ffff:127.0.0.1back into127.0.0.1, andIsLoopback,IsPrivate,IsLinkLocalUnicast,IsUnspecifiedplus an explicit100.64.0.0/10prefix reject every non-public range.
Framework mappings
- CWE reference for the weakness described in this guide: CWE-918
- OWASP Top 10:2025 — A01:2025 Broken Access Control
- OWASP API Security Top 10 — API7:2023 Server Side Request Forgery
- D3-OTF Outbound Traffic Filtering — This guide's outbound network restriction limits which destinations the service or host can reach.
Step-by-Step Attack Flow
A user supplies a URL
A link-preview or webhook handler decodes url from JSON and passes it to http.Get or http.Post.
The service connects to an internal address
A DNS precheck does not pin the socket: the HTTP client resolves the host when it connects, so rebinding can change a public answer to a private destination. net.Dialer.Control can inspect the exact IP selected for each dial.
The internal service answers the trusted caller
http.DefaultClient follows up to 10 redirects by default. A public preview or webhook URL can therefore send the backend to 169.254.169.254 after the original URL was checked.
The attacker reads secrets or pivots
The response path creates the disclosure: an http.Handler that copies the response body or status. Return only the data the feature needs, and keep internal credentials and response bodies out of caller-controlled fetch results.
Source Code: Flaw vs. Secure Implementation
// fetch.go: both handlers let the caller choose the destination
package fetch
import (
"encoding/json"
"io"
"net"
"net/http"
"net/url"
"strings"
)
type fetchRequest struct {
URL string `json:"url"`
}
// default client: no timeout, follows redirects
func Preview(w http.ResponseWriter, r *http.Request) {
var in fetchRequest
json.NewDecoder(r.Body).Decode(&in)
resp, err := http.Get(in.URL)
if err != nil {
http.Error(w, "fetch failed", http.StatusBadGateway)
return
}
defer resp.Body.Close()
io.Copy(w, io.LimitReader(resp.Body, 5000))
}
// IsPrivate misses 169.254.169.254; then the client resolves again
func TestWebhook(w http.ResponseWriter, r *http.Request) {
var in fetchRequest
json.NewDecoder(r.Body).Decode(&in)
u, err := url.Parse(in.URL)
if err != nil {
http.Error(w, "bad url", http.StatusBadRequest)
return
}
ips, _ := net.LookupIP(u.Hostname())
for _, ip := range ips {
if ip.IsPrivate() || ip.IsLoopback() {
http.Error(w, "blocked", http.StatusBadRequest)
return
}
}
resp, err := http.Post(in.URL, "application/json", strings.NewReader(`{"ping":true}`))
if err != nil {
http.Error(w, "fetch failed", http.StatusBadGateway)
return
}
defer resp.Body.Close()
json.NewEncoder(w).Encode(map[string]int{"status": resp.StatusCode})
}
// fetch.go: check the address of every connection as it is dialled
package fetch
import (
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"syscall"
"time"
)
type fetchRequest struct {
URL string `json:"url"`
}
var (
errBlocked = errors.New("destination is not a public address")
cgnat = netip.MustParsePrefix("100.64.0.0/10")
)
// runs after DNS, before connect, for every dial including redirects
func publicOnly(network, address string, _ syscall.RawConn) error {
ap, err := netip.ParseAddrPort(address)
if err != nil {
return err
}
ip := ap.Addr().Unmap()
if !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() ||
ip.IsUnspecified() || cgnat.Contains(ip) {
return errBlocked
}
return nil
}
// no proxy, no redirects, timeouts on every phase
var client = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
Proxy: nil,
DialContext: (&net.Dialer{Timeout: 3 * time.Second, Control: publicOnly}).DialContext,
TLSHandshakeTimeout: 3 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
},
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
func checkedURL(raw string) (string, error) {
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" {
return "", errors.New("only http and https URLs are allowed")
}
return u.String(), nil
}
// only http and https reach the guarded client
func Preview(w http.ResponseWriter, r *http.Request) {
var in fetchRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
target, err := checkedURL(in.URL)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
resp, err := client.Get(target)
if err != nil {
http.Error(w, "fetch failed", http.StatusBadGateway)
return
}
defer resp.Body.Close()
io.Copy(w, io.LimitReader(resp.Body, 5000))
}
func TestWebhook(w http.ResponseWriter, r *http.Request) {
var in fetchRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
target, err := checkedURL(in.URL)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
resp, err := client.Post(target, "application/json", strings.NewReader(`{"ping":true}`))
if err != nil {
http.Error(w, "fetch failed", http.StatusBadGateway)
return
}
defer resp.Body.Close()
json.NewEncoder(w).Encode(map[string]int{"status": resp.StatusCode})
}
Engineering & System Hardening Checklist
- ✓Enable gosec G107 (URL provided to HTTP request as taint input) and review every outbound call that takes a user-supplied URL.
- ✓Use a dedicated
http.Clientwhosenet.Dialer.Controlrejects loopback, private, link-local, unspecified and CGNAT addresses afterUnmap(). - ✓Set
Proxy: nilon that transport, allow onlyhttpandhttps, and returnhttp.ErrUseLastResponsefromCheckRedirect. - ✓Set
Timeout,TLSHandshakeTimeoutandResponseHeaderTimeout, and read bodies throughio.LimitReader. - ✓Enforce IMDSv2 with a hop limit of 1 and restrict egress with a network policy or firewall as a second layer.