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 proper validation. In Go applications using net/http, attackers can manipulate these requests to access internal resources, cloud metadata endpoints, or bypass firewall restrictions.

SSRF is particularly dangerous in cloud environments (AWS, GCP, Azure) where metadata services expose sensitive credentials and configuration at predictable IP addresses (169.254.169.254). An SSRF vulnerability can leak IAM credentials, database passwords, API keys, and other secrets. 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. Developers must implement URL validation, protocol allowlisting, and private IP blocking. The challenge is that URL parsing is complex - attackers use DNS rebinding, IPv6 notation, URL encoding, and alternative IP formats (decimal, octal, hex) to bypass naive filters.

Primary Defence: Implement strict URL allowlisting with protocol restrictions, validate and resolve hostnames before making requests, block private IP ranges (RFC 1918, loopback, link-local), and use network-level controls to restrict egress from application servers.

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
// -> May expose local files (if file:// supported)

Why this is vulnerable: The application makes HTTP requests to arbitrary URLs without validation. Attackers can target cloud metadata endpoints to steal credentials, access internal services on localhost or private IPs, scan internal network ranges, or exfiltrate data through DNS (http://attacker.com/?data=stolen). The response is returned directly to the attacker, providing full access to internal resources.

Insufficient Protocol Validation

// VULNERABLE - Only checks for http/https prefix
import (
    "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: Protocol validation alone is insufficient. While blocking file://, ftp://, and other protocols prevents some attacks, it doesn't stop access to private IPs over HTTP/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 IP address, not just the URL scheme.

Hostname-Based Allowlisting with DNS Rebinding

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

func fetchWithHostnameCheck(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: Time-of-check/time-of-use race condition
    // DNS can change between validation and request
    if parsedURL.Hostname() == "127.0.0.1" || 
       parsedURL.Hostname() == "localhost" ||
       parsedURL.Hostname() == "169.254.169.254" {
        http.Error(w, "Blocked hostname", 400)
        return
    }

    // RACE: DNS lookup occurs here, could resolve differently
    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:
// 1. First lookup: attacker.com -> 1.2.3.4 (public IP, passes check)
// 2. Second lookup: attacker.com -> 127.0.0.1 (private IP, used in request)
// Timing attack allows bypassing validation

Why this is vulnerable: There's a time-of-check/time-of-use (TOCTOU) race condition. The initial hostname validation checks the DNS at one point in time, but http.Get() performs its own DNS lookup which may return a different IP. An attacker controlling DNS can serve a safe public IP during validation and switch to a private IP for the actual request. This "DNS rebinding" attack bypasses hostname blocklists.

Redirect Following to Internal URLs

// VULNERABLE - Following redirects to internal resources
import (
    "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). Even if the initial URL passes validation, the application may follow redirects to malicious destinations. An attacker hosts a public URL that redirects to a private IP or cloud metadata endpoint, bypassing validation applied only to the initial URL. The application trusts redirects implicitly.

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
    }

    // Create client with timeout and no redirect following
    client := &http.Client{
        Timeout: 10 * time.Second,
        CheckRedirect: func(req *http.Request, via []*http.Request) error {
            return http.ErrUseLastResponse // Don't follow redirects
        },
    }

    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
}

func isPrivateIP(ip net.IP) bool {
    // Loopback
    if ip.IsLoopback() {
        return true
    }

    // Link-local
    if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
        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
        "::1/128",        // IPv6 loopback
        "fc00::/7",       // IPv6 private
        "fe80::/10",      // IPv6 link-local
    }

    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 prevents slowloris attacks
  6. Response size limit: Prevents resource exhaustion
  7. IPv6 support: Blocks private IPv6 ranges (fc00::/7, fe80::/10)

This approach prevents DNS rebinding because IP validation happens synchronously before the request.

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,
    }

    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)
                }
            }

            // Connect to first valid IP
            if len(ips) > 0 {
                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: Custom DialContext intercepts every connection attempt at the transport layer. IP validation occurs during the connection handshake, preventing DNS rebinding - even if DNS changes between checks, the transport layer validates again. This provides defense-in-depth: validation happens both before the request (application layer) and during connection (transport layer). All resolved IPs are checked, covering both IPv4 and IPv6.

Webhook URL Validation for User-Configured Callbacks

// SECURE - Validate webhook URLs with strict checks
import (
    "fmt"
    "net"
    "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 {
        http.Error(w, fmt.Sprintf("Invalid webhook URL: %v", err), http.StatusBadRequest)
        return
    }

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

    w.WriteHeader(http.StatusCreated)
}

Why this works: Comprehensive webhook validation prevents SSRF attacks 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 (
    "bytes"
    "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
    }

    // Create secure client
    client := &http.Client{
        Timeout: pc.Timeout,
        CheckRedirect: func(req *http.Request, via []*http.Request) error {
            // Allow redirects only within same domain
            if req.URL.Hostname() != parsedURL.Hostname() {
                return http.ErrUseLastResponse
            }
            if len(via) >= 3 {
                return http.ErrUseLastResponse
            }
            return nil
        },
    }

    // 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: api.github.com allows subdomains like raw.githubusercontent.com
  2. HTTPS-only: Prevents MitM attacks
  3. IP validation: Blocks private IPs before request
  4. Same-domain redirect policy: Allows redirects only within the same hostname (prevents redirect to attacker.com)
  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: Middleware centralizes SSRF protection, ensuring all routes benefit from validation. Early abort prevents execution of vulnerable handler logic. Gin's context allows storing validated URLs for handlers to use safely.

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 {
        return c.JSON(http.StatusBadRequest, map[string]string{
            "error": "Invalid webhook URL",
            "details": err.Error(),
        })
    }

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

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

Additional Resources