FlawOpen

flawopen.com/ssrf/Go

● CWE-918 · High
FlawOpen Security Research

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.

💡 Plain English Explainer (ELI5)

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 defaults
http.Get uses http.DefaultClient, which has no timeout and follows up to 10 redirects. A public URL that answers Location: http://169.254.169.254/ reaches the metadata service after any check on the original URL.
net.IP.IsPrivate is not enough
IsPrivate covers RFC 1918 and IPv6 unique-local ranges only. It returns false for 169.254.169.254 (link-local), 0.0.0.0 and 100.64.0.0/10, and ignoring the error from LookupIP lets a name that fails to resolve pass with an empty list.
Check-then-fetch and DNS rebinding
net.LookupIP resolves 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 and 127.0.0.1 second, so the check says nothing about the connection.
net.Dialer.Control
Control runs after DNS resolution and before connect(), with the exact ip:port being dialled. Every connection the transport opens, including redirects and retries, passes through it. Set Proxy: nil, otherwise the proxy's address is what gets checked.
netip.Addr.Unmap and address classes
netip.ParseAddrPort parses the dialled address, Unmap() turns ::ffff:127.0.0.1 back into 127.0.0.1, and IsLoopback, IsPrivate, IsLinkLocalUnicast, IsUnspecified plus an explicit 100.64.0.0/10 prefix reject every non-public range.

Framework mappings

Step-by-Step Attack Flow

Step 1

A user supplies a URL

A link-preview or webhook handler decodes url from JSON and passes it to http.Get or http.Post.

Step 2

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.

Step 3

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.

Step 4

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

UNPATCHED FLAW
// 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})
}
HARDENED SECURE PATCH
// 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

Sources