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:
- 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 caps how long a slow or hanging destination can hold the request open
- Response size limit: Prevents resource exhaustion
- 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:
- 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 (
"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:
- Domain allowlist with suffix matching:
example.comallows subdomains likeapi.example.com; list unrelated domains such asraw.githubusercontent.comexplicitly - HTTPS-only: Prevents MitM attacks
- IP validation: Blocks private IPs before request
- Redirect blocking: No redirect can change the scheme, port or destination
- 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: 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 tohttp.Get()or a defaulthttp.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 customDialContextthat dials the already-validated IP directly, not a separate lookup beforehand. - Overriding
http.Client.CheckRedirectto log or count redirects without re-running the destination through the same host/IP allowlist used for the original request - the defaultCheckRedirect(nil) follows up to 10 redirects automatically, so any override that doesn't explicitly reject or revalidate eachLocationheader still lets a redirect chain reach an internal address. - Validating with
net/url.Parseand checkingu.Hostname()against a blocklist written only against IPv4 strings -::ffff:169.254.169.254is the metadata address tonet.ParseIP(measured on go1.25.5, it returns169.254.169.254), and a zone-suffixed literal such asfe80::1%eth0is onenet.ParseIPrejects butnet.LookupIPand 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 settingProxy: http.ProxyFromEnvironmenton the custom one -DefaultTransporthonoursHTTP_PROXY/HTTPS_PROXY, and through a proxyDialContextis handed the proxy'shost:port, so every check passes on the proxy's address and the target is resolved at the other end. A zero-valuehttp.Transporthas no proxy, which is why both fetch examples usecreateSecureHTTPClient.
Additional Resources
- AWS SSRF Attacks
- CWE-918
- net/http Transport -
DialContext, and theProxyfield that is nil on a zero-value transport andProxyFromEnvironmentonDefaultTransport - OWASP SSRF Prevention