Skip to content

CWE-918: Server-Side Request Forgery (SSRF) - Go

Overview

Server-Side Request Forgery (SSRF) vulnerabilities occur when an application makes HTTP requests to URLs derived from user input without validating the destination. In Go applications using net/http, attackers can steer those requests at internal resources, cloud metadata endpoints, or hosts the firewall would not let them reach directly.

SSRF is especially damaging in cloud environments (AWS, GCP, Azure), where metadata services expose credentials and configuration at a predictable IP address (169.254.169.254) to anything that can make an HTTP request from the instance. In internal networks, SSRF bypasses perimeter security, allowing attackers to port-scan internal hosts, access admin interfaces, or exploit internal services that assume requests from the local network are trusted.

Go's net/http package makes HTTP requests straightforward with http.Get() and http.Client, but provides no built-in SSRF protection: scheme allowlisting, URL validation and private IP blocking are yours to write. Writing them so they hold is the hard part - attackers use DNS rebinding, IPv6 notation, URL encoding, and alternative IP formats (decimal, octal, hex) to get past filters that only compare strings.

Primary Defence: Allow only an explicit list of hosts and schemes, resolve the hostname and reject private ranges (RFC 1918, loopback, link-local), and dial the address you checked rather than the name - a custom DialContext is what makes the last part hold. Network-level egress controls from the application servers back this up.

Common Vulnerable Patterns

Direct URL Pass-Through

// VULNERABLE - No validation of user-provided URL
package main

import (
    "fmt"
    "io"
    "net/http"
)

func fetchURLHandler(w http.ResponseWriter, r *http.Request) {
    // DANGEROUS: User controls the URL completely
    url := r.URL.Query().Get("url")

    // Make request to user-provided URL
    resp, err := http.Get(url)
    if err != nil {
        http.Error(w, "Fetch failed", 500)
        return
    }
    defer resp.Body.Close()

    // Return response to user
    body, _ := io.ReadAll(resp.Body)
    w.Write(body)
}

// ATTACK EXAMPLES:
// /fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/
// -> Leaks AWS IAM credentials
//
// /fetch?url=http://localhost:6379/
// -> Access internal Redis server
//
// /fetch?url=file:///etc/passwd
// -> net/http rejects file://, but non-HTTP schemes must still be blocked
//    before alternate clients or future code paths handle them.

Why this is vulnerable: The application makes HTTP requests to arbitrary URLs without validation. Attackers can target cloud metadata endpoints to steal credentials, reach internal services on localhost or private IPs, scan internal network ranges, or push data out to a host they control (http://attacker.com/?data=stolen). The response body is written straight back, so whatever the internal service answers with is readable by the caller.

Insufficient Protocol Validation

// VULNERABLE - Only checks for http/https prefix
import (
    "io"
    "net/http"
    "strings"
)

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

    // INSUFFICIENT: Allows http and https but doesn't block private IPs
    if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
        http.Error(w, "Invalid protocol", 400)
        return
    }

    // Still vulnerable to internal network access
    resp, err := http.Get(url)
    if err != nil {
        http.Error(w, "Request failed", 500)
        return
    }
    defer resp.Body.Close()

    body, _ := io.ReadAll(resp.Body)
    w.Write(body)
}

// ATTACK:
// /fetch?url=http://169.254.169.254/latest/meta-data/
// -> Protocol is http://, passes validation, accesses metadata service
//
// /fetch?url=http://192.168.1.1/admin
// -> Accesses internal admin panel

Why this is vulnerable: Blocking file://, ftp:// and other schemes stops some attacks, but it does nothing about private IPs reached over HTTP or HTTPS. Attackers can still target cloud metadata (169.254.169.254), localhost (127.0.0.1), and private networks (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16). The application needs to validate the destination address, not just the URL scheme.

Validating the Resolved Address, Then Fetching by Name

// VULNERABLE - DNS rebinding attack
import (
    "io"
    "net"
    "net/http"
    "net/url"
)

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

    parsedURL, err := url.Parse(targetURL)
    if err != nil {
        http.Error(w, "Invalid URL", 400)
        return
    }

    // VULNERABLE - the answers are checked once, here. http.Get() below resolves
    // the name again when it connects, and nothing checks that answer
    ips, err := net.LookupIP(parsedURL.Hostname())
    if err != nil {
        http.Error(w, "DNS lookup failed", 400)
        return
    }
    for _, ip := range ips {
        if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() {
            http.Error(w, "Blocked destination", 400)
            return
        }
    }

    // RACE: a second, independent lookup happens inside http.Get()
    resp, err := http.Get(targetURL)
    if err != nil {
        http.Error(w, "Request failed", 500)
        return
    }
    defer resp.Body.Close()

    body, _ := io.ReadAll(resp.Body)
    w.Write(body)
}

// ATTACK: DNS rebinding
// Attacker controls attacker.com DNS and answers with a TTL of 0:
// 1. net.LookupIP above: attacker.com -> 1.2.3.4 (public, passes the check)
// 2. The lookup inside http.Get(): attacker.com -> 127.0.0.1 (used for the request)

Why this is vulnerable: There's a time-of-check/time-of-use (TOCTOU) race condition. net.LookupIP checks the answers at one point in time, but http.Get() performs its own DNS lookup when it connects, and that lookup may return a different address. An attacker controlling the zone serves a public address to the first lookup and a private one to the second. The range test here is also thin - it passes 100.64.0.1 and every IPv6 form that carries an IPv4 address - but completing it would leave the race exactly where it is; only dialling the address that was checked closes it.

Redirect Following to Internal URLs

// VULNERABLE - Following redirects to internal resources
import (
    "io"
    "net/http"
)

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

    // Validate initial URL (assume some basic validation)
    // ...

    // DANGEROUS: Default http.Client follows redirects
    client := &http.Client{
        // Default: follows up to 10 redirects automatically
    }

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

    body, _ := io.ReadAll(resp.Body)
    w.Write(body)
}

// ATTACK:
// /fetch?url=https://attacker.com/redirect.php
// (attacker.com responds with HTTP 302 to http://169.254.169.254/...)
// Application follows redirect to metadata service, validation bypassed

Why this is vulnerable: Go's http.Client follows HTTP redirects automatically, up to 10 by default, and nothing re-checks where they point. Validation applied only to the initial URL is therefore bypassed: the attacker hosts a public URL that passes the check and answers with a 302 to a private IP or the cloud metadata endpoint, and the client follows it.

Secure Patterns

URL Allowlist with Domain Validation

// SECURE - Strict domain allowlisting
package main

import (
    "fmt"
    "io"
    "net"
    "net/http"
    "net/url"
    "strings"
    "time"
)

var allowedDomains = map[string]bool{
    "api.example.com":     true,
    "cdn.example.com":     true,
    "partner.trusted.com": true,
}

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

    // Parse URL
    parsedURL, err := url.Parse(targetURL)
    if err != nil {
        http.Error(w, "Invalid URL", http.StatusBadRequest)
        return
    }

    // SECURE - Enforce HTTPS only
    if parsedURL.Scheme != "https" {
        http.Error(w, "Only HTTPS allowed", http.StatusBadRequest)
        return
    }

    // SECURE - Check domain against allowlist
    hostname := strings.ToLower(parsedURL.Hostname())
    if !allowedDomains[hostname] {
        http.Error(w, "Domain not allowed", http.StatusForbidden)
        return
    }

    // SECURE - Resolve hostname and validate IP isn't private
    if err := validatePublicIP(hostname); err != nil {
        http.Error(w, "Invalid destination", http.StatusForbidden)
        return
    }

    // Use the transport from "Custom HTTP Client with Dial Control" below.
    // Its dial-time check prevents a second DNS answer bypassing validation.
    client := createSecureHTTPClient()
    client.Timeout = 10 * time.Second

    resp, err := client.Get(parsedURL.String())
    if err != nil {
        http.Error(w, "Request failed", http.StatusBadGateway)
        return
    }
    defer resp.Body.Close()

    // Limit response size
    body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) // 10MB
    if err != nil {
        http.Error(w, "Read failed", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/octet-stream")
    w.Write(body)
}

func validatePublicIP(hostname string) error {
    // Resolve hostname
    ips, err := net.LookupIP(hostname)
    if err != nil {
        return fmt.Errorf("DNS lookup failed: %w", err)
    }

    // Check all resolved IPs
    for _, ip := range ips {
        if isPrivateIP(ip) {
            return fmt.Errorf("private IP not allowed: %s", ip)
        }
    }

    return nil
}

// The IPv4-compatible form carries an IPv4 address in the low 32 bits of an
// IPv6 one. net.IP.To4() folds the mapped form (::ffff:127.0.0.1) and leaves
// this one alone, so ::7f00:1 is 127.0.0.1 and ::a9fe:a9fe is the metadata
// address, and every check below returns false for both. :: and ::1 are the
// unspecified and loopback addresses rather than embedded IPv4 - unwrapping
// ::1 to 0.0.0.1 would lose the property that made it worth blocking.
func unwrapIPv4Compatible(ip net.IP) net.IP {
    v6 := ip.To16()
    if v6 == nil || ip.To4() != nil {
        return ip
    }
    for _, b := range v6[:12] {
        if b != 0 {
            return ip
        }
    }
    if v6[12] == 0 && v6[13] == 0 && v6[14] == 0 && v6[15] <= 1 {
        return ip
    }
    return net.IPv4(v6[12], v6[13], v6[14], v6[15])
}

func isPrivateIP(ip net.IP) bool {
    ip = unwrapIPv4Compatible(ip)

    // Loopback
    if ip.IsLoopback() {
        return true
    }

    // Link-local, and multicast as a whole: IsLinkLocalMulticast() covers only
    // 224.0.0.0/24 and ff02::/16, and 239.1.1.1 is no more a place to fetch from
    if ip.IsLinkLocalUnicast() || ip.IsMulticast() {
        return true
    }

    // Unspecified - http://0/ reaches services bound to localhost on Linux
    if ip.IsUnspecified() {
        return true
    }

    // Private ranges (RFC 1918)
    privateRanges := []string{
        "10.0.0.0/8",
        "172.16.0.0/12",
        "192.168.0.0/16",
        "169.254.0.0/16", // AWS metadata, link-local
        "127.0.0.0/8",    // Loopback
        "100.64.0.0/10",  // Carrier-grade NAT (RFC 6598)
        "0.0.0.0/8",      // "This network" - includes 0.0.0.0
        "192.0.0.0/24",   // IETF protocol assignments (RFC 6890)
        "240.0.0.0/4",    // Reserved
        "198.18.0.0/15",  // Benchmarking (RFC 2544)
        "192.0.2.0/24",   // Documentation (RFC 5737) - nothing legitimate lives here
        "198.51.100.0/24", // Documentation (RFC 5737)
        "203.0.113.0/24", // Documentation (RFC 5737)
        "::1/128",        // IPv6 loopback
        "fc00::/7",       // IPv6 private
        "fe80::/10",      // IPv6 link-local
        "fec0::/10",      // IPv6 site-local, deprecated in 2004 and still a private range
        "2001:db8::/32",  // Documentation (RFC 3849)
        "100::/64",       // Discard-only (RFC 6666)
        // IPv6 ranges that carry an IPv4 address somewhere in their bits.
        // net.IP folds ::ffff:127.0.0.1 into its IPv4 form, so the ranges above
        // already cover that one; these five it does not fold, and each can
        // spell 127.0.0.1. Only the NAT64 local-use prefix may carry a non-global
        // IPv4 address through a compliant translator (RFC 8215); RFC 6052
        // section 3.1 requires the well-known-prefix form to be dropped. All are
        // refused as encodings, without a claim about routing.
        //
        // Do not add ::ffff:0:0/96 to this list. Because net.IP stores IPv4 in
        // exactly that form, Contains() matches every IPv4 address and the
        // filter starts rejecting 8.8.8.8 - measured, not theorised.
        "64:ff9b::/96",   // NAT64 (RFC 6052)
        "64:ff9b:1::/48", // NAT64 local use (RFC 8215)
        "2002::/16",      // 6to4 (RFC 3056)
        "2001::/32",      // Teredo (RFC 4380)
        "::ffff:0:0:0/96", // IPv4-translated (RFC 6145) - one zero group on from the mapped form net.IP folds
    }

    for _, cidr := range privateRanges {
        _, block, _ := net.ParseCIDR(cidr)
        if block.Contains(ip) {
            return true
        }
    }

    return false
}

Why this works: Multiple layers of defense prevent SSRF:

  1. HTTPS-only: Prevents downgrade attacks and ensures encryption
  2. Domain allowlist: Only predefined domains allowed - no user-controlled hosts
  3. IP validation: Resolves hostname before request, blocks private IPs including AWS metadata (169.254.0.0/16)
  4. No redirects: CheckRedirect returns ErrUseLastResponse, preventing redirect-based bypass
  5. Timeout: 10-second limit caps how long a slow or hanging destination can hold the request open
  6. Response size limit: Prevents resource exhaustion
  7. IPv6 support: Blocks private IPv6 ranges (fc00::/7, fe80::/10)

The client uses the DialContext pattern below, so the actual connection uses an address validated at dial time. Redirects and environment proxies are disabled.

Custom HTTP Client with Dial Control

// SECURE - Control DNS resolution and connections at transport layer
import (
    "context"
    "fmt"
    "net"
    "net/http"
    "time"
)

func createSecureHTTPClient() *http.Client {
    dialer := &net.Dialer{
        Timeout:   5 * time.Second,
        KeepAlive: 30 * time.Second,
    }

    // Proxy is left nil on purpose. http.DefaultTransport sets it to
    // ProxyFromEnvironment, and through a proxy DialContext is handed the
    // proxy's host:port - the target is resolved at the proxy, where none of
    // this runs
    transport := &http.Transport{
        DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
            // Extract host and port
            host, port, err := net.SplitHostPort(addr)
            if err != nil {
                return nil, err
            }

            // Resolve IP
            ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
            if err != nil {
                return nil, err
            }

            // SECURE - Validate all resolved IPs before connecting
            for _, ip := range ips {
                if isPrivateIP(ip.IP) {
                    return nil, fmt.Errorf("connection to private IP blocked: %s", ip.IP)
                }
            }

            if len(ips) == 0 {
                return nil, fmt.Errorf("host has no usable addresses")
            }
            // Connect to a checked numeric address; never fall back to the name.
            addr = net.JoinHostPort(ips[0].IP.String(), port)

            return dialer.DialContext(ctx, network, addr)
        },
        MaxIdleConns:          10,
        IdleConnTimeout:       30 * time.Second,
        TLSHandshakeTimeout:   5 * time.Second,
        ExpectContinueTimeout: 1 * time.Second,
    }

    return &http.Client{
        Transport: transport,
        Timeout:   15 * time.Second,
        CheckRedirect: func(req *http.Request, via []*http.Request) error {
            return http.ErrUseLastResponse
        },
    }
}

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

    // Validate URL structure
    // (domain allowlist check, HTTPS-only, etc.)

    client := createSecureHTTPClient()

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

    // Process response...
}

Why this works: The custom DialContext runs on every connection the transport makes. It resolves the host, checks every answer with isPrivateIP, and then dials the numeric address it just checked, so there is no second, unchecked lookup for a rebinding attack to win. That gives defense in depth - the destination is validated once at the application layer before the request and again here at the transport layer - and IPv4 and IPv6 answers go through the same check.

Webhook URL Validation for User-Configured Callbacks

// SECURE - Validate webhook URLs with strict checks
import (
    "fmt"
    "log"
    "net"
    "net/http"
    "net/url"
    "regexp"
    "strings"
)

type WebhookConfig struct {
    URL    string
    Secret string
}

func validateWebhookURL(webhookURL string) error {
    // Parse URL
    parsed, err := url.Parse(webhookURL)
    if err != nil {
        return fmt.Errorf("invalid URL format: %w", err)
    }

    // SECURE - HTTPS only for webhooks
    if parsed.Scheme != "https" {
        return fmt.Errorf("webhooks must use HTTPS")
    }

    // Check for username/password in URL (credential exfiltration)
    if parsed.User != nil {
        return fmt.Errorf("credentials in URL not allowed")
    }

    hostname := strings.ToLower(parsed.Hostname())

    // Block direct IP addresses
    if net.ParseIP(hostname) != nil {
        return fmt.Errorf("IP addresses not allowed, use domain names")
    }

    // Block localhost variations
    localhostPatterns := []string{
        "localhost",
        "127.0.0.1",
        "::1",
        "0.0.0.0",
        "[::]",
    }

    for _, blocked := range localhostPatterns {
        if hostname == blocked {
            return fmt.Errorf("localhost not allowed")
        }
    }

    // Block common internal TLDs
    internalTLDs := []string{".local", ".internal", ".corp", ".lan"}
    for _, tld := range internalTLDs {
        if strings.HasSuffix(hostname, tld) {
            return fmt.Errorf("internal TLDs not allowed")
        }
    }

    // Resolve and validate IPs
    ips, err := net.LookupIP(hostname)
    if err != nil {
        return fmt.Errorf("DNS resolution failed: %w", err)
    }

    for _, ip := range ips {
        if isPrivateIP(ip) {
            return fmt.Errorf("webhook points to private IP: %s", ip)
        }
    }

    // Validate domain format (basic check for malformed domains)
    domainRegex := regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?(\.[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?)*\.[a-zA-Z]{2,}$`)
    if !domainRegex.MatchString(hostname) {
        return fmt.Errorf("invalid domain format")
    }

    return nil
}

func registerWebhook(w http.ResponseWriter, r *http.Request) {
    var config WebhookConfig
    // Parse JSON body...
    // json.NewDecoder(r.Body).Decode(&config)

    if err := validateWebhookURL(config.URL); err != nil {
        // The error names the address the host resolved to. That is a map of
        // the internal network, one submission at a time - log it, return a
        // fixed string
        log.Printf("webhook URL rejected: %v", err)
        http.Error(w, "Invalid webhook URL", http.StatusBadRequest)
        return
    }

    // Store webhook configuration
    // db.SaveWebhook(config)

    w.WriteHeader(http.StatusCreated)
}

Why this works: Webhook validation closes off SSRF through user-configured callbacks:

  1. HTTPS enforcement: Prevents plaintext credential leakage
  2. Credential blocking: Rejects URLs with embedded user:pass@ (prevents exfiltration)
  3. IP address blocking: Forces use of domain names (easier to audit and block)
  4. Localhost blocklist: Prevents loopback attacks
  5. Internal TLD blocking: Blocks .local, .internal, .corp domains common in internal networks
  6. DNS + IP validation: Resolves domain and checks all IPs against private range list
  7. Domain format validation: Prevents malformed domains that might bypass parsing

URL Proxying with Content Filtering

// SECURE - Proxy external content with validation
import (
    "io"
    "net/http"
    "net/url"
    "strings"
    "time"
)

type ProxyConfig struct {
    AllowedDomains  []string
    MaxResponseSize int64
    Timeout         time.Duration
}

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

    parsedURL, err := url.Parse(targetURL)
    if err != nil {
        http.Error(w, "Invalid URL", http.StatusBadRequest)
        return
    }

    // SECURE - Domain allowlist
    allowed := false
    hostname := strings.ToLower(parsedURL.Hostname())
    for _, domain := range pc.AllowedDomains {
        if hostname == strings.ToLower(domain) ||
            strings.HasSuffix(hostname, "."+strings.ToLower(domain)) {
            allowed = true
            break
        }
    }

    if !allowed {
        http.Error(w, "Domain not allowed", http.StatusForbidden)
        return
    }

    // SECURE - Protocol validation
    if parsedURL.Scheme != "https" {
        http.Error(w, "HTTPS required", http.StatusBadRequest)
        return
    }

    // SECURE - IP validation
    if err := validatePublicIP(hostname); err != nil {
        http.Error(w, "Invalid destination", http.StatusForbidden)
        return
    }

    // Pin the dial-time resolution and disable proxies and redirects. Checking
    // only a redirect's hostname would still allow HTTP downgrades and new ports.
    client := createSecureHTTPClient()
    client.Timeout = pc.Timeout

    // Make request
    resp, err := client.Get(parsedURL.String())
    if err != nil {
        http.Error(w, "Fetch failed", http.StatusBadGateway)
        return
    }
    defer resp.Body.Close()

    // SECURE - Limit response size
    limitedReader := io.LimitReader(resp.Body, pc.MaxResponseSize)
    body, err := io.ReadAll(limitedReader)
    if err != nil {
        http.Error(w, "Read failed", http.StatusInternalServerError)
        return
    }

    // SECURE - Content-Type validation (only allow expected types)
    contentType := resp.Header.Get("Content-Type")
    allowedTypes := []string{"application/json", "text/plain", "application/xml"}

    typeAllowed := false
    for _, allowed := range allowedTypes {
        if strings.HasPrefix(contentType, allowed) {
            typeAllowed = true
            break
        }
    }

    if !typeAllowed {
        http.Error(w, "Content type not allowed", http.StatusBadRequest)
        return
    }

    // Return proxied content
    w.Header().Set("Content-Type", contentType)
    w.Header().Set("X-Content-Type-Options", "nosniff")
    w.Write(body)
}

func main() {
    config := &ProxyConfig{
        AllowedDomains: []string{
            "api.github.com",
            "api.twitter.com",
        },
        MaxResponseSize: 5 * 1024 * 1024, // 5MB
        Timeout:         10 * time.Second,
    }

    http.HandleFunc("/proxy", config.ProxyHandler)
    http.ListenAndServe(":8080", nil)
}

Why this works: Defense-in-depth for content proxying:

  1. Domain allowlist with suffix matching: example.com allows subdomains like api.example.com; list unrelated domains such as raw.githubusercontent.com explicitly
  2. HTTPS-only: Prevents MitM attacks
  3. IP validation: Blocks private IPs before request
  4. Redirect blocking: No redirect can change the scheme, port or destination
  5. Response size limit: Prevents resource exhaustion
  6. Content-Type validation: Only allows expected MIME types, blocks HTML that could execute scripts
  7. Security headers: X-Content-Type-Options: nosniff prevents MIME sniffing attacks

Framework-Specific Guidance

Gin with SSRF Protection Middleware

// SECURE - Gin middleware for SSRF protection
package main

import (
    "net"
    "net/http"
    "net/url"

    "github.com/gin-gonic/gin"
)

func SSRFProtectionMiddleware(allowedDomains map[string]bool) gin.HandlerFunc {
    return func(c *gin.Context) {
        targetURL := c.Query("url")
        if targetURL == "" {
            c.Next()
            return
        }

        // Parse and validate
        parsed, err := url.Parse(targetURL)
        if err != nil {
            c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid URL"})
            c.Abort()
            return
        }

        // Check scheme
        if parsed.Scheme != "https" {
            c.JSON(http.StatusBadRequest, gin.H{"error": "HTTPS required"})
            c.Abort()
            return
        }

        // Check domain
        hostname := parsed.Hostname()
        if !allowedDomains[hostname] {
            c.JSON(http.StatusForbidden, gin.H{"error": "Domain not allowed"})
            c.Abort()
            return
        }

        // Validate IP
        ips, err := net.LookupIP(hostname)
        if err != nil {
            c.JSON(http.StatusBadRequest, gin.H{"error": "DNS lookup failed"})
            c.Abort()
            return
        }

        for _, ip := range ips {
            if isPrivateIP(ip) {
                c.JSON(http.StatusForbidden, gin.H{"error": "Private IP not allowed"})
                c.Abort()
                return
            }
        }

        c.Next()
    }
}

func main() {
    r := gin.Default()

    allowed := map[string]bool{
        "api.example.com": true,
    }

    r.Use(SSRFProtectionMiddleware(allowed))

    r.GET("/fetch", fetchHandler)

    r.Run(":8080")
}

func fetchHandler(c *gin.Context) {
    // URL already validated by middleware
    targetURL := c.Query("url")

    client := createSecureHTTPClient()
    resp, err := client.Get(targetURL)
    if err != nil {
        c.JSON(http.StatusBadGateway, gin.H{"error": "Fetch failed"})
        return
    }
    defer resp.Body.Close()

    // Process response...
    c.String(http.StatusOK, "Success")
}

Why this works: The middleware puts the scheme, domain and IP checks in one place, so every route registered behind r.Use gets them. c.Abort() stops the chain before the handler runs, so a URL that failed validation never reaches the fetch.

Echo with Webhook Validation

// SECURE - Echo webhook endpoint with validation
package main

import (
    "net/http"

    "github.com/labstack/echo/v4"
)

type Webhook struct {
    URL    string   `json:"url" validate:"required,https_url"`
    Events []string `json:"events" validate:"required,min=1"`
}

func main() {
    e := echo.New()

    e.POST("/webhooks", createWebhookHandler)

    e.Start(":8080")
}

func createWebhookHandler(c echo.Context) error {
    var webhook Webhook

    if err := c.Bind(&webhook); err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
    }

    // SECURE - Validate webhook URL
    if err := validateWebhookURL(webhook.URL); err != nil {
        // err names the resolved address; it goes to the log, not the caller
        c.Logger().Warnf("webhook URL rejected: %v", err)
        return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid webhook URL"})
    }

    // Store webhook
    // db.SaveWebhook(webhook)

    return c.JSON(http.StatusCreated, map[string]string{
        "status": "created",
        "url":    webhook.URL,
    })
}

Common Pitfalls

  • Calling net.LookupHost() (or similar) to validate a hostname's resolved addresses, then passing the original URL to http.Get() or a default http.Client - the transport's dialer performs its own independent DNS resolution when the connection is actually made, so the validated address and the connected address can differ; closing this gap needs a custom DialContext that dials the already-validated IP directly, not a separate lookup beforehand.
  • Overriding http.Client.CheckRedirect to log or count redirects without re-running the destination through the same host/IP allowlist used for the original request - the default CheckRedirect (nil) follows up to 10 redirects automatically, so any override that doesn't explicitly reject or revalidate each Location header still lets a redirect chain reach an internal address.
  • Validating with net/url.Parse and checking u.Hostname() against a blocklist written only against IPv4 strings - ::ffff:169.254.169.254 is the metadata address to net.ParseIP (measured on go1.25.5, it returns 169.254.169.254), and a zone-suffixed literal such as fe80::1%eth0 is one net.ParseIP rejects but net.LookupIP and the dialer accept, so a guard that reads "not an IP" as "a hostname" and skips the address check lets it through. Neither form matches a string comparison against dotted-decimal addresses; the resolved address is what to test.
  • Building the pinned client on http.DefaultTransport, or setting Proxy: http.ProxyFromEnvironment on the custom one - DefaultTransport honours HTTP_PROXY/HTTPS_PROXY, and through a proxy DialContext is handed the proxy's host:port, so every check passes on the proxy's address and the target is resolved at the other end. A zero-value http.Transport has no proxy, which is why both fetch examples use createSecureHTTPClient.

Additional Resources