Back to Blog
securityssrfwebhooksinfrastructure

SSRF Prevention in Webhook Delivery Infrastructure

When your platform lets users register arbitrary destination URLs, you've built a potential server-side request forgery vector. Here's how to close it — with URL validation, DNS rebinding defense, and safe HTTP client construction.

N
Nadia Kowalski
Security Engineer
April 20, 2026
10 min read

Every webhook platform that accepts user-supplied destination URLs has an SSRF surface. The flow is simple: your customer registers https://169.254.169.254/latest/meta-data/ as a webhook destination. Your delivery worker dutifully POSTs to it. You've just handed them your AWS instance metadata — or worse, your internal network.

Server-side request forgery (SSRF) in webhook delivery is distinct from the classic SSRF in web applications. You're not bypassing an access control on a user-facing page. You're abusing a system that is supposed to make outbound HTTP requests. That makes it harder to detect and easier to exploit at scale.

This post covers the specific attack vectors, the defense layers you need, and the code patterns that make SSRF structurally difficult rather than something you patch reactively.


The Threat Model

When your platform delivers webhooks to customer-supplied URLs, you're a proxy. Your outbound HTTP client has access to:

  • Cloud metadata endpoints169.254.169.254 (AWS, GCP, Azure) and their IPv6 equivalents (fd00:ec2::254). These expose IAM credentials, instance identity documents, and — on poorly scoped instances — secrets.
  • Internal network services — databases, caches, internal APIs, admin panels. Anything reachable from your delivery worker's network segment.
  • Localhost127.0.0.1, ::1, and DNS names that resolve to loopback. Useful for port-scanning or hitting services bound to localhost only.
  • RFC 1918 ranges10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16. Your VPC, your Kubernetes pod network.

The attack doesn't require a sophisticated payload. It requires a registered destination URL, and patience. An attacker can enumerate your internal network by registering a new destination for each IP they want to probe, triggering a test delivery, and reading the response or timing data.

Attack surfaceExample targetWhat leaks
AWS IMDS v1http://169.254.169.254/latest/meta-data/iam/security-credentials/Temporary IAM credentials
GCP metadatahttp://metadata.google.internal/computeMetadata/v1/instance/service-accounts/Service account tokens
Internal APIhttp://10.0.1.42:8080/internal/adminAdmin endpoints, PII
Localhost scanhttp://127.0.0.1:5432Database error messages, port exposure
DNS rebindingResolves to public IP at registration, resolves to internal IP at deliveryBypass IP allowlist

Layer 1: URL Validation at Registration Time

The first gate is syntactic. Reject URLs that are structurally invalid or obviously dangerous before they reach your database.

go
import (
    "fmt"
    "net/url"
    "strings"
)

var blockedSchemes = map[string]bool{
    "file":    true,
    "ftp":     true,
    "gopher":  true,
    "dict":    true,
    "sftp":    true,
    "tftp":    true,
    "ldap":    true,
}

func validateDestinationURL(raw string) error {
    u, err := url.Parse(raw)
    if err != nil {
        return fmt.Errorf("invalid URL: %w", err)
    }

    if u.Scheme != "https" && u.Scheme != "http" {
        return fmt.Errorf("unsupported scheme %q: only http and https are allowed", u.Scheme)
    }

    if blockedSchemes[u.Scheme] {
        return fmt.Errorf("scheme %q is not permitted", u.Scheme)
    }

    // Reject URLs with userinfo (http://user:pass@host is a red flag)
    if u.User != nil {
        return fmt.Errorf("URLs with embedded credentials are not permitted")
    }

    // Reject fragments — they have no meaning in HTTP requests
    // and can sometimes confuse parsers
    if u.Fragment != "" {
        return fmt.Errorf("URL fragments are not permitted")
    }

    host := u.Hostname()
    if host == "" {
        return fmt.Errorf("missing host")
    }

    return nil
}

This catches the obvious cases. It does not catch http://169.254.169.254 — that's a valid URL by syntax. For that, you need the next layer.


Layer 2: IP Range Blocking After DNS Resolution

You cannot safely block IPs based on the hostname string. An attacker can register http://my-webhook.attacker.com where my-webhook.attacker.com resolves to 169.254.169.254. The hostname check passes. DNS resolution reveals the target.

The correct approach: resolve the hostname before registering the destination, check the resolved IP against your blocklist, and then use that pre-resolved IP when making the delivery request. This prevents DNS rebinding — where the hostname resolves to a public IP during validation but an internal IP at delivery time.

go
import (
    "context"
    "fmt"
    "net"
    "net/url"
    "time"
)

// SSRFBlockedRanges contains all ranges that must never be delivery targets.
var ssrfBlockedRanges = []string{
    "127.0.0.0/8",       // Loopback
    "::1/128",            // IPv6 loopback
    "10.0.0.0/8",        // RFC 1918
    "172.16.0.0/12",     // RFC 1918
    "192.168.0.0/16",    // RFC 1918
    "169.254.0.0/16",    // Link-local (AWS IMDS, etc.)
    "fd00::/8",           // IPv6 unique local
    "fe80::/10",          // IPv6 link-local
    "fc00::/7",           // IPv6 private
    "100.64.0.0/10",     // Shared address space (RFC 6598)
    "0.0.0.0/8",         // "This" network
    "240.0.0.0/4",       // Reserved (RFC 1112)
    "255.255.255.255/32", // Broadcast
}

var blockedNets []*net.IPNet

func init() {
    for _, cidr := range ssrfBlockedRanges {
        _, network, err := net.ParseCIDR(cidr)
        if err != nil {
            panic(fmt.Sprintf("invalid CIDR in blocklist: %s", cidr))
        }
        blockedNets = append(blockedNets, network)
    }
}

func isBlockedIP(ip net.IP) bool {
    for _, network := range blockedNets {
        if network.Contains(ip) {
            return true
        }
    }
    return false
}

// resolveAndValidate resolves the host and returns the IP if it is safe.
// Returns an error if the host resolves to a blocked range.
func resolveAndValidate(ctx context.Context, rawURL string) (net.IP, error) {
    u, err := url.Parse(rawURL)
    if err != nil {
        return nil, err
    }

    host := u.Hostname()

    // If the host is already an IP literal, check it directly
    if ip := net.ParseIP(host); ip != nil {
        if isBlockedIP(ip) {
            return nil, fmt.Errorf("destination IP %s is in a blocked range", ip)
        }
        return ip, nil
    }

    // Resolve the hostname
    resolver := &net.Resolver{}
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

    addrs, err := resolver.LookupIPAddr(ctx, host)
    if err != nil {
        return nil, fmt.Errorf("DNS resolution failed for %s: %w", host, err)
    }
    if len(addrs) == 0 {
        return nil, fmt.Errorf("no addresses found for %s", host)
    }

    // Check every resolved address — reject if any is blocked
    for _, addr := range addrs {
        if isBlockedIP(addr.IP) {
            return nil, fmt.Errorf(
                "destination %s resolves to blocked IP %s", host, addr.IP,
            )
        }
    }

    return addrs[0].IP, nil
}

Layer 3: The Safe HTTP Client

Resolving and validating at registration time is necessary but not sufficient. The delivery HTTP client must also be hardened. Otherwise, a race condition exists: hostname resolves clean at registration, then resolves to an internal IP by the time delivery fires.

The defense is a custom Dialer that re-validates the IP during the actual TCP connection. Go's net/http transport makes this straightforward:

go
import (
    "context"
    "fmt"
    "net"
    "net/http"
    "time"
)

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

    // safeDialContext wraps the standard dialer and blocks connections
    // to private/internal IP ranges — even after successful validation.
    safeDialContext := func(ctx context.Context, network, addr string) (net.Conn, error) {
        host, port, err := net.SplitHostPort(addr)
        if err != nil {
            return nil, fmt.Errorf("invalid address %s: %w", addr, err)
        }

        // At dial time, addr may already be a resolved IP (from http.Transport)
        // or still a hostname. Handle both.
        ip := net.ParseIP(host)
        if ip == nil {
            ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
            if err != nil || len(ips) == 0 {
                return nil, fmt.Errorf("cannot resolve %s", host)
            }
            ip = ips[0].IP
        }

        if isBlockedIP(ip) {
            return nil, fmt.Errorf("connection to %s blocked: IP %s is in a restricted range", host, ip)
        }

        return dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
    }

    transport := &http.Transport{
        DialContext:           safeDialContext,
        TLSHandshakeTimeout:   10 * time.Second,
        ResponseHeaderTimeout: 15 * time.Second,
        IdleConnTimeout:       90 * time.Second,
        MaxIdleConnsPerHost:   10,
        ForceAttemptHTTP2:     true,
    }

    return &http.Client{
        Transport: transport,
        Timeout:   30 * time.Second,
        // Do not follow redirects without re-validating the new URL
        CheckRedirect: func(req *http.Request, via []*http.Request) error {
            if len(via) >= 3 {
                return fmt.Errorf("too many redirects")
            }
            if err := validateDestinationURL(req.URL.String()); err != nil {
                return fmt.Errorf("redirect target blocked: %w", err)
            }
            return nil
        },
    }
}

The CheckRedirect hook is often missed. A destination that returns 301 → http://169.254.169.254/ would bypass validation without it.


Layer 4: Redirect Policy and TLS Enforcement

A few additional policies matter in production:

Enforce HTTPS in production. HTTP destinations leak the webhook payload and signatures in transit and are a common pivot point in SSRF chains. Make HTTPS mandatory for production accounts, with an explicit exception for local development environments.

Reject redirects that change scheme. A redirect from https:// to http:// should be blocked. Your safe dialer already protects you from the IP being internal, but stripping TLS removes signature confidentiality.

Limit redirect depth. Redirect chains longer than 3 hops are almost always malicious or broken. Fail hard.

Do not leak response bodies. Your delivery worker should record the HTTP status code and response headers for debugging, but truncate response bodies to a safe limit (e.g., 4 KB). Returning the full response body to the caller of your delivery API could expose internal service output if an SSRF succeeds.

PolicyRecommendation
SchemeHTTPS required in production; HTTP allowed in development with explicit flag
RedirectsMaximum 3, must re-validate IP on each hop
Scheme downgradeBlock redirects from HTTPS to HTTP
Response body loggingTruncate to 4 KB maximum
Timeout30s total; 15s response headers; 10s TLS handshake
DNS resolution TTLRe-resolve on each delivery, not from registration-time cache

Testing Your Defenses

Automated tests for SSRF defenses are easy to write and frequently skipped. Add them to your test suite and run them in CI.

go
func TestSSRFBlocklist(t *testing.T) {
    cases := []struct {
        url      string
        wantErr  bool
        desc     string
    }{
        {"https://example.com/webhook", false, "public HTTPS URL"},
        {"http://169.254.169.254/latest/meta-data/", true, "AWS IMDS"},
        {"http://metadata.google.internal/", true, "GCP metadata"},
        {"http://127.0.0.1:8080/internal", true, "localhost"},
        {"http://10.0.0.1/admin", true, "RFC 1918 - 10/8"},
        {"http://172.16.0.1/internal", true, "RFC 1918 - 172.16/12"},
        {"http://192.168.1.1/api", true, "RFC 1918 - 192.168/16"},
        {"http://[::1]/api", true, "IPv6 loopback"},
        {"http://[fd00::1]/api", true, "IPv6 unique local"},
        {"https://evil.com/redir-to-internal", false, "public URL (redirect check is runtime)"},
        {"file:///etc/passwd", true, "file scheme"},
        {"ftp://internal-ftp.corp", true, "ftp scheme"},
    }

    ctx := context.Background()
    for _, tc := range cases {
        t.Run(tc.desc, func(t *testing.T) {
            err := validateDestinationURL(tc.url)
            if err == nil {
                _, err = resolveAndValidate(ctx, tc.url)
            }
            if tc.wantErr && err == nil {
                t.Errorf("expected error for %q, got nil", tc.url)
            }
            if !tc.wantErr && err != nil {
                t.Errorf("unexpected error for %q: %v", tc.url, err)
            }
        })
    }
}

Add a test for DNS rebinding by running a local DNS server that returns different IPs on successive queries. Tools like dnschef or a simple custom net.Resolver mock work well for this.


Operational Signals

Beyond code, add monitoring for SSRF probe attempts:

  • Log and alert on blocked deliveries. When your safe dialer blocks a connection, emit a structured log event with the destination URL, resolved IP, and account ID. A single blocked attempt might be an accident. Ten attempts from the same account against different RFC 1918 addresses is a probe.
  • Alert on internal hostnames in destination URLs. Regex-match destination URLs against patterns like *.internal, *.local, *.corp, *.cluster.local at registration time. These are not always malicious, but they warrant review.
  • Rate-limit destination registration. An account registering 50 destinations in 5 minutes is unusual. A human setting up integrations registers 1–5 at a time.

GetHook enforces SSRF protections at both the registration and delivery layers — blocked attempts are visible in the delivery attempt log so you can audit them from your dashboard.


Summary

SSRF in webhook delivery is a structural problem, not a one-time patch. Defense requires:

  1. Syntactic validation at URL registration time — reject bad schemes, fragments, embedded credentials.
  2. DNS resolution and IP blocklist check at registration time — resolve the hostname and block any address in RFC 1918, link-local, and loopback ranges.
  3. A hardened HTTP client with a custom dialer that re-validates the resolved IP at connection time — this closes the DNS rebinding window.
  4. Redirect policy enforcement — re-validate on each hop, block scheme downgrades, cap depth.
  5. Operational monitoring — log blocked attempts, alert on probing patterns.

None of these layers is sufficient on its own. Together, they make SSRF structurally difficult — not a configuration you can accidentally skip.


Ready to run a webhook gateway that handles SSRF prevention, secret signing, and retry logic without building it yourself? Get started with GetHook or read the security architecture docs to see how delivery hardening works in practice.

Stop losing webhook events.

GetHook gives you reliable delivery, automatic retry, and full observability — in minutes.