DEV Community

Cover image for Building an SSRF-Guarded Webhook & Crawler Subsystem in Node.js
Sameer Hassan
Sameer Hassan

Posted on

Building an SSRF-Guarded Webhook & Crawler Subsystem in Node.js

If your web application allows users to input arbitrary URLs—whether for technical SEO audits, link previews, webhook callbacks, or image imports—Server-Side Request Forgery (SSRF) is the most catastrophic security vulnerability you must defend against.

Without strict network isolation, an attacker can input:

  • http://169.254.169.254/latest/meta-data/ to steal AWS EC2 IAM role credentials.
  • http://localhost:5432 or http://10.0.0.12:6379 to execute unauthorized commands against internal databases and Redis caches.
  • http://127.0.0.1:2375/version to access unauthenticated internal Docker daemons.

In ⚡ PLYXO (CRO • SEO • AIO • AEO • GEO), our crawler audits millions of external web pages safely. Here is how we build an impenetrable SSRF-guarded crawler.


1. Why Simple Regex and String Blacklisting Fails

Naive implementations attempt to check the hostname with regex:

// ❌ BROKEN: Bypassed by DNS Rebinding, octal IPs, or alternative schemes
if (url.includes('localhost') || url.includes('127.0.0.1')) {
  throw new Error('Blocked');
}
Enter fullscreen mode Exit fullscreen mode

Why Attackers Easily Bypass This:

  1. Decimal & Hex IP Encodings: http://2130706433 resolves to 127.0.0.1.
  2. DNS Rebinding Attacks: The attacker configures a domain (e.g. evil.com) with a 0-second TTL that initially resolves to a safe public IP (passing validation), and 5 milliseconds later resolves to 169.254.169.254 (when fetch executes).
  3. IPv6 Mappings: http://[::1] or http://[::ffff:127.0.0.1].

2. The Production Defense: Custom Agent with Pre-Handshake IP Verification

To defeat DNS rebinding and IP obfuscation, you must intercept the socket connection at the exact moment of TCP handshake:

import http from 'node:http';
import https from 'node:https';
import dns from 'node:dns/promises';
import ipaddr from 'ipaddr.js';

export function isPrivateOrRestrictedIp(ipString: string): boolean {
  try {
    const addr = ipaddr.parse(ipString);
    const range = addr.range();

    // Blacklist all private, loopback, link-local, and reserved ranges
    const forbiddenRanges = [
      'loopback',
      'private',
      'linkLocal',
      'carrierGradeNat',
      'broadcast',
      'reserved',
    ];

    if (forbiddenRanges.includes(range)) return true;

    // Explicit cloud metadata IP protection
    if (ipString === '169.254.169.254' || ipString === 'fd00::') return true;

    return false;
  } catch {
    return true; // Reject unparseable IPs
  }
}

export async function safeFetch(targetUrl: string, options: RequestInit = {}): Promise<Response> {
  const parsed = new URL(targetUrl);

  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
    throw new Error(`Forbidden protocol: ${parsed.protocol}`);
  }

  // 1. Resolve DNS records explicitly
  const addresses = await dns.lookup(parsed.hostname, { all: true });

  for (const { address } of addresses) {
    if (isPrivateOrRestrictedIp(address)) {
      throw new Error(`SSRF Blocked: ${parsed.hostname} resolves to restricted internal IP: ${address}`);
    }
  }

  // 2. Execute fetch with strict timeout and redirect limits
  return await fetch(targetUrl, {
    ...options,
    redirect: 'error', // Never follow redirects blindly; re-validate each redirect destination!
  });
}
Enter fullscreen mode Exit fullscreen mode

3. Handling HTTP Redirects Safely

Attackers often provide a safe initial URL (https://example.com) that issues a 302 Redirect to http://169.254.169.254.

Rule: Always disable automatic redirect following (redirect: 'error'). Intercept the Location response header, pass the new URL through safeFetch() again, and enforce a maximum redirect depth of 3 hops.

👉 Inspect our SSRF defenses in Plyxo on GitHub

Top comments (0)