Server-side request forgery (SSRF)

Server-side request forgery (SSRF)

Description

Server-side request forgery occurs when user input directly controls the URL or host of an outbound server request. An attacker can make the server request loopback addresses such as 127.0.0.1, metadata endpoints, private IP addresses or services that should not be externally accessible. Depending on the destination, this may bypass access controls, reveal internal information or enable port scanning.

Potential impact

  • Users may send requests through the server to private APIs, administration consoles or other internal services, potentially exploiting additional vulnerabilities there.
  • Requests to metadata or configuration services may expose tokens, credentials or settings.
  • Using the application server as a proxy may bypass network controls that block direct external access.
  • Repeated requests to slow destinations or large responses may exhaust resources and disrupt service.

Remediation

  • Restrict outbound destinations to a code-defined domain and host allow-list.
  • Permit only required http/https schemes and ports; reject other schemes.
  • Restrict disallowed loopback, private, link-local and metadata ranges for the actual IPv4/IPv6 connection address. Prevent DNS rebinding by connecting to the address that was validated.
  • Accept an identifier for a fixed server-defined destination rather than a complete URL. If paths or queries are needed, restrict and encode them appropriately.
  • Set timeouts, a redirect policy and a response-size limit.
  • Test SSRF bypass inputs and monitor suspicious outbound requests.

Examples

Before

go
package main

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

// Before: an unsafe SSRF example
func proxyHandler(w http.ResponseWriter, r *http.Request) {
    // Accept the complete URL directly from user input
    target := r.URL.Query().Get("url")
    if target == "" {
        http.Error(w, "missing url", http.StatusBadRequest)
        return
    }

    // Unsafe: the server requests the user-supplied URL
    // Example: http://myserver/proxy?url=http://169.254.169.254/latest/meta-data/
    resp, err := http.Get(target)
    if err != nil {
        http.Error(w, "request failed", http.StatusBadGateway)
        return
    }
    defer resp.Body.Close()

    // Relay the response directly
    w.WriteHeader(resp.StatusCode)
    if _, err := io.Copy(w, resp.Body); err != nil {
        log.Println("write response error:", err)
    }
}

func main() {
    http.HandleFunc("/proxy", proxyHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

After

go
package main

import (
    "io"
    "log"
    "net/http"
    "time"
)

var outboundClient = &http.Client{
    Timeout: 5 * time.Second,
    CheckRedirect: func(req *http.Request, via []*http.Request) error {
        // Do not follow redirects from the allowed host to internal addresses or other hosts.
        return http.ErrUseLastResponse
    },
}

func safeProxyHandler(w http.ResponseWriter, r *http.Request) {
    endpointID := r.URL.Query().Get("endpoint")
    var target string
    // Input selects only a fixed server-managed endpoint, not an arbitrary URL.
    switch endpointID {
    case "users":
        target = "https://api.example.com/v1/users"
    case "status":
        target = "https://status.example.com/api/status"
    default:
        http.Error(w, "unknown endpoint", http.StatusBadRequest)
        return
    }

    req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target, nil)
    if err != nil {
        http.Error(w, "request build failed", http.StatusInternalServerError)
        return
    }

    resp, err := outboundClient.Do(req)
    if err != nil {
        http.Error(w, "request failed", http.StatusBadGateway)
        return
    }
    defer resp.Body.Close()

    w.Header().Set("Content-Type", "application/octet-stream")
    w.Header().Set("X-Content-Type-Options", "nosniff")
    w.WriteHeader(resp.StatusCode)
    const maxResponseBytes = 1024 * 1024
    if _, err := io.Copy(w, io.LimitReader(resp.Body, maxResponseBytes)); err != nil {
        log.Println("write response error:", err)
    }
}

func main() {
    http.HandleFunc("/proxy", safeProxyHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Explanation:

  • Before: The url query parameter is passed directly to http.Get. Attackers may make the server request internal or metadata endpoints and relay responses from systems they cannot reach directly.
  • After: The input selects only a fixed endpoint ID. Code-defined URLs prevent it from changing the scheme, host, port or path. CheckRedirect refuses redirects; a timeout and response limit bound resource use. Binary content type and nosniff prevent the upstream content from being served as HTML on the application origin.

The fixed destinations' DNS and services must also be trusted; apply required outbound network restrictions separately. A more dynamic design that validates DNS/IP addresses must connect through a custom DialContext using the validated IP. Checking LookupIP and then letting the default client resolve again does not prevent DNS rebinding.

References