flawopen.com/Server-Side Request Forgery/Go

Server-Side Request Forgery in Go

High Severity CWE-918
ELI5

You send a messenger with an envelope. You tell him, 'Deliver this to whoever the address says.' The sender writes 'Room 102 next door'. The messenger delivers to your private internal server instead of the public internet.

Key terms on this page
DialContext
The hook in net/http.Transport that establishes the raw TCP connection, allowing inspection of resolved net.IP before dialing.
CheckRedirect
Callback on http.Client that decides whether to follow an HTTP 3xx redirect.

What's happening

Go's http.DefaultClient.Get(url) resolves DNS and connects without restriction. If an attacker submits http://169.254.169.254 or http://127.0.0.1:8080, Go connects and returns the response.

Real-world impact

In 2022, critical SSRF flaws in Kubernetes operators and cloud webhook integrations written in Go enabled lateral movement and cluster administrative privilege escalation.

CISA Cybersecurity Advisory & MITRE CVE repository.

Vulnerable vs. fixed

VULNERABLE
// VULNERABLE: Default http.Get allows internal network calls
package main

import (
	"io"
	"net/http"
)

func proxyHandler(w http.ResponseWriter, r *http.Request) {
	targetURL := r.URL.Query().Get("url")

	// Attacker passes: http://169.254.169.254/latest/meta-data/
	resp, err := http.Get(targetURL)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	defer resp.Body.Close()

	io.Copy(w, resp.Body)
}
FIXED
// HARDENED: Custom DialContext checking net.IP.IsPrivate and IsLoopback
package main

import (
	"context"
	"errors"
	"io"
	"net"
	"net/http"
	"net/url"
	"time"
)

func isSafeIP(ip net.IP) bool {
	return !ip.IsLoopback() &&
		!ip.IsPrivate() &&
		!ip.IsLinkLocalUnicast() &&
		!ip.IsLinkLocalMulticast() &&
		!ip.IsUnspecified()
}

func newSafeHTTPClient() *http.Client {
	dialer := &net.Dialer{
		Timeout: 3 * time.Second,
	}

	transport := &http.Transport{
		DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
			host, port, err := net.SplitHostPort(addr)
			if err != nil {
				return nil, err
			}

			// Resolve all IP addresses
			ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
			if err != nil {
				return nil, err
			}

			for _, ip := range ips {
				if !isSafeIP(ip) {
					return nil, errors.New("SSRF Blocked: connection to private/internal IP rejected")
				}
			}

			// Connect to the first validated IP
			targetAddr := net.JoinHostPort(ips[0].String(), port)
			return dialer.DialContext(ctx, network, targetAddr)
		},
	}

	return &http.Client{
		Transport: transport,
		Timeout:   5 * time.Second,
		CheckRedirect: func(req *http.Request, via []*http.Request) error {
			return errors.New("redirects are disabled to prevent SSRF bypass")
		},
	}
}

func proxyHandler(w http.ResponseWriter, r *http.Request) {
	targetURL := r.URL.Query().Get("url")
	parsed, err := url.Parse(targetURL)
	if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
		http.Error(w, "Invalid URL scheme", http.StatusBadRequest)
		return
	}

	client := newSafeHTTPClient()
	resp, err := client.Get(targetURL)
	if err != nil {
		http.Error(w, "Request failed: "+err.Error(), http.StatusForbidden)
		return
	}
	defer resp.Body.Close()

	io.Copy(w, resp.Body)
}

Why the fix works

The custom DialContext intercepts the destination IP right before the socket dial. It resolves DNS, tests with ip.IsPrivate() and ip.IsLoopback(), and connects directly to the validated IP. Setting CheckRedirect blocks redirect-based SSRF.

Gotchas

net.IP.IsPrivate in older Go versions

net.IP.IsPrivate() was added in Go 1.17. In older versions, manually check RFC 1918 CIDRs via net.ParseCIDR.

Common misconceptions

"Checking parsed.Host is enough"

Attackers can map custom domains to 127.0.0.1 or use hex IP notations (0x7f000001) that bypass hostname string checks.

How to check if you're affected

gosec -include=G107 ./...

Prevention checklist

FAQ

How does DialContext solve DNS Rebinding?

By resolving the IP inside DialContext and immediately calling dialer.DialContext on that specific IP string, the connection is pinned to the validated address.

References