SSRF & Controlled Outbound HTTP
Server-Side Request Forgery (SSRF) occurs when a web application fetches a remote resource without validating the user-supplied URL. An attacker can coerce the application to send crafted requests to internal-only systems, cloud metadata services, or third-party APIs.
Core details
| Layer | Defenses | Impact |
|---|---|---|
| Network | Egress firewalls, default-deny, dedicated egress proxy | Prevents lateral movement to internal VPC resources |
| DNS | DNS Pinning / pre-resolution validation | Defeats DNS Rebinding exploits |
| Application | Hostname allowlists, scheme restrictions (https only) | Blocks protocol smuggling (e.g., file://, gopher://, dict://) |
| Cloud Infrastructure | Enforce IMDSv2 (session token header, hop limit = 1) | Secures AWS/GCP/Azure instance metadata endpoints |
The DNS Rebinding Trap
A simple hostname check is vulnerable to Time-of-Check to Time-of-Use (TOCTOU) DNS Rebinding. During validation, the hostname resolves to a benign public IP. When the client makes the actual HTTP fetch, the DNS server returns a private IP (e.g., 127.0.0.1 or 169.254.169.254).
[!IMPORTANT] To prevent DNS Rebinding, you must resolve the hostname first, validate that the resolved IP address is public and not within private CIDR ranges (RFC 1918 / RFC 4193), and then establish the connection directly to the validated IP, passing the original hostname in the
Hostheader for TLS SNI validation.
Understanding
When an application performs outbound requests (e.g., webhook delivery, PDF generation, image scraping), it is acting as a proxy. Naive implementations trust standard client libraries (e.g., Axios, fetch, or http.Get), which automatically follow redirects and resolve DNS records using the system resolver without boundaries.
If an attacker inputs http://169.254.169.254/latest/meta-data/ on an AWS-hosted server, they can retrieve temporary IAM credentials. If they use http://localhost:6379/, they can send arbitrary commands to Redis (protocol smuggling via HTTP headers).
Secure Egress Topology
The most robust architecture separates the application layer from the egress routing. All application outbound requests are forced through a secure egress proxy (like Squid, Envoy, or Smokescreen) which maintains strict IP and domain allowlists.
Senior understanding
Secure HTTP Client Implementation
Go Implementation
Using a custom dialer to validate resolved IPs before establishing a TCP socket:
package main
import (
"context"
"errors"
"net"
"net/http"
"time"
)
func isPrivateIP(ip net.IP) bool {
return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsPrivate()
}
func NewSecureClient() *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) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
if err != nil {
return nil, err
}
for _, ip := range ips {
if isPrivateIP(ip) {
return nil, errors.New("forbidden target: private IP detected")
}
}
// Force connection to the first resolved public IP to prevent DNS rebinding
targetAddr := net.JoinHostPort(ips[0].String(), port)
return dialer.DialContext(ctx, network, targetAddr)
},
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
return &http.Client{
Transport: transport,
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return errors.New("stopped after 3 redirects")
}
return nil // The custom DialContext will re-validate the redirect destination IP
},
}
}Diagram
See also
Mark this page when you finish learning it.
Spotted something unclear or wrong on this page?