Allowing users to configure custom webhooks (callback URLs triggered by system events) is a standard feature for any SaaS platform. However, if your server-side handler executes a raw POST request to the provided URL without proper validation, you have just opened a critical vulnerability: Server-Side Request Forgery (SSRF). Here is the technical breakdown.
The Vulnerability: Bypassing Firewalls from the Inside
Since the outbound HTTP request originates from your own server (which resides within your private cloud network or VPN), an attacker can register webhook endpoints pointing to local IP addresses that are not exposed to the public internet.
• Cloud Metadata Endpoint: http://169.254.169.254/latest/meta-data/ (Exposes IAM server keys!)
• Internal DB Service: http://192.168.1.50:6379/
Your web server processes the request, passes through external firewall rules (since it's outbound traffic), queries the private service, and returns the response payload or headers to the attacker, leaking network design details.
Why Blacklists Fail
Blocking strings like "localhost" or "127.0.0.1" is easily bypassed. Attackers can use alternative representations (e.g. decimal http://2130706433), register custom DNS hostnames that resolve to local loopback addresses (e.g. spoof.attacker.com), or execute 302 HTTP redirects from their own external servers.
The Robust Solution: DNS Resolution & IP Range Filtering (RFC 1918)
The only secure approach is to resolve the domain to its underlying IP address inside your application layer before making the HTTP call, then validate that the resolved IP does not fall into private (RFC 1918) or reserved address ranges.
Here is the validation algorithm implemented in Node.js / TypeScript:
import dns from "dns/promises";
import ipaddr from "ipaddr.js";
async function isSafeUrl(inputUrl: string): Promise<boolean> {
try {
const parsed = new URL(inputUrl);
// 1. Force DNS resolution to obtain underlying IPs
const addresses = await dns.resolve(parsed.hostname);
for (const addr of addresses) {
const ip = ipaddr.parse(addr);
// 2. Assert resolved IP is not within a private or reserved range
const range = ip.range();
if (
range === "private" || // 10.x.x.x, 172.16.x.x, 192.168.x.x ranges
range === "loopback" || // 127.0.0.1 loopback
range === "linklocal" || // 169.254.x.x (Link-local metadata endpoint)
range === "unspecified"
) {
// Unsafe range detected, reject the request
return false;
}
}
return true; // Resolved IP is safe to query
} catch (err) {
return false; // Reject on DNS lookup failures
}
}Additional Webhook Safety Guidelines
- Disable Redirect Following: Force your HTTP client (e.g. Axios instance) to reject redirect hops (
maxRedirects: 0) to prevent 302 redirect-bypass tactics. - Network Isolation (DMZ): Run the webhook-dispatcher workers in an isolated subnet (VPC) with zero access to your primary database or Redis nodes.
- Strict Timeouts: Enforce short connection timeouts (e.g. 2 seconds maximum) to prevent resources from being tied up by malicious slow-responding hosts.
