All posts
28 Aug 2026

Securing Node.js Against SSRF: Defending Internal Networks from Malicious URL Fetches

A practical, code-heavy guide to preventing Server-Side Request Forgery vulnerabilities in Node.js by implementing robust IP validation, blocking private networks, and mitigating DNS rebinding.

Server-Side Request Forgery (SSRF) remains one of the most critical vulnerabilities facing modern backend applications. When a Node.js application accepts a user-provided URL and fetches its contents—whether for link previews, PDF generation, or webhook delivery—it trusts the user input to dictate network traffic. If left unmitigated, an attacker can exploit this capability to scan internal services, access cloud metadata endpoints (such as AWS IMDS), or interact with sensitive internal microservices hidden behind a corporate firewall.

In this guide, we will explore the mechanics of SSRF, analyze why naive URL validation fails, and implement a defense-in-depth architecture in Node.js using modern native fetch and custom IP validation layers.

The Anatomy of an SSRF Attack

Consider a standard feature in a Node.js application: generating a link preview. The client sends a URL to the backend, which fetches the HTML and parses the OpenGraph tags.

javascript
// VULNERABLE IMPLEMENTATION
import express from 'express';

const app = express();
app.use(express.json());

app.post('/api/preview', async (req, res) => {
  const { url } = req.body;

  try {
    // DANGEROUS: Unvalidated user input passed directly to fetch
    const response = await fetch(url);
    const html = await response.text();
    // ... parse preview ...
    res.json({ success: true, html: html.substring(0, 500) });
  } catch (error) {
    res.status(500).json({ error: 'Failed to fetch URL' });
  });
});

An attacker can supply malicious payloads instead of a public website URL:

  • http://localhost:8080/admin (Accessing internal admin panels)
  • http://169.254.169.254/latest/meta-data/ (Exfiltrating AWS IAM credentials)
  • file:///etc/passwd (If the client library or protocol handlers aren’t restricted)

Why Naive URL Validation Fails

Developers often attempt to secure these endpoints using Regular Expressions or standard URL parsing libraries to check if a domain is “allowed” or if it doesn’t contain the word “localhost”. These checks are almost always bypassed.

1. IP Encoding Variations

An IP address can be represented in multiple ways that bypass simple string matching:

  • Decimal (Integer): http://2130706433 resolves to 127.0.0.1
  • Octal: http://0177.0.0.1 resolves to 127.0.0.1
  • Hexadecimal: http://0x7f000001 resolves to 127.0.0.1

2. Alternative Loopback Addresses

Blocking 127.0.0.1 is insufficient when 0.0.0.0, [::1], or custom loopback aliases exist.

3. DNS Rebinding

A sophisticated attacker controls a domain name with a very low Time-To-Live (TTL). When the application validates the domain, it resolves to a harmless public IP address. By the time the HTTP client executes the actual request milliseconds later, the DNS record has changed to point to 127.0.0.1 or an internal IP address.


Building a Robust Defense-in-Depth Strategy

To safely fetch user-provided URLs in Node.js, we must implement a multi-layered validation pipeline:

  1. Protocol Restrictions: Explicitly allow only http: and https:. Reject file:, ftp:, gopher:, etc.
  2. Hostname & IP Validation: Resolve the hostname to an IP address before making the request, and check that IP against a blocklist of private, loopback, and link-local ranges.
  3. DNS Rebinding Mitigation: Ensure the IP used for validation is the exact same IP used in the HTTP connection socket.
  4. Network-Level Defensives: Utilize egress filtering (firewalls/security groups) on the hosting infrastructure to restrict what the Node.js server can reach.

Step 1: Protocol and URL Parsing

Always parse URLs using the native URL API to normalize the input and prevent parsing discrepancies.

import { URL } from 'node:url';

function parseAndValidateUrl(rawUrl) {
  let parsedUrl;
  try {
    parsedUrl = new URL(rawUrl);
  } catch (err) {
    throw new Error('Invalid URL format');
  }

  // Enforce safe protocols
  if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
    throw new Error(`Unsupported protocol: ${parsedUrl.protocol}`);
  }

  return parsedUrl;
}

Step 2: IP Range Checking and Blocklists

We need to check if an IP address falls within private or reserved ranges (RFC 1918, RFC 4193, loopback, link-local, etc.). We can use the ipaddr.js npm package to handle IPv4 and IPv6 parsing and range matching reliably.

npm install ipaddr.js

Let’s write a utility to verify that a resolved IP address is safe:

import ipaddr from 'ipaddr.js';

function isIpSafe(ipString) {
  try {
    const addr = ipaddr.parse(ipString);

    // Check if the IP is loopback, private, carrier-grade NAT, link-local, or reserved
    const range = addr.range();
    
    const unsafeRanges = [
      'loopback',
      'private',
      'linkLocal',
      'carrierGradeNat',
      'uniqueLocal',
      'unspecified',
      'reserved'
    ];

    if (unsafeRanges.includes(range)) {
      return false;
    }

    // Additional check for AWS/Cloud metadata service IP
    if (ipString === '169.254.169.254') {
      return false;
    }

    return true;
  } catch (err) {
    return false;
  }
}

Step 3: Resolving DNS Explicitly and Preventing Rebinding

To defeat DNS rebinding, we resolve the domain name to an IP address using Node.js’s dns/promises module, validate that IP address, and then force the HTTP request to use that exact IP address.

In Node.js, we can achieve socket pinning or force IP routing by using a custom http.Agent / https.Agent or by overriding the connection lookup logic.

Here is a complete, secure URL fetcher module:

import dns from 'node:dns/promises';
import { URL } from 'node:url';
import http from 'node:http';
import https from 'node:https';
import ipaddr from 'ipaddr.js';

// Helper to check if IP is safe
function isIpSafe(ipString) {
  try {
    const addr = ipaddr.parse(ipString);
    const range = addr.range();
    const unsafeRanges = ['loopback', 'private', 'linkLocal', 'carrierGradeNat', 'uniqueLocal', 'unspecified', 'reserved'];
    if (unsafeRanges.includes(range) || ipString === '169.254.169.254') {
      return false;
    }
    return true;
  } catch {
    return false;
  }
}

export async function safeFetch(rawUrl, options = {}) {
  const parsedUrl = new URL(rawUrl);
  const hostname = parsedUrl.hostname;

  // 1. Resolve DNS explicitly
  let lookupResult;
  try {
    // family: 0 means resolve both IPv4 and IPv6
    lookupResult = await dns.lookup(hostname, { family: 0 });
  } catch (err) {
    throw new Error(`DNS resolution failed for hostname: ${hostname}`);
  }

  const resolvedIp = lookupResult.address;

  // 2. Validate the resolved IP address
  if (!isIpSafe(resolvedIp)) {
    throw new Error(`Access to private or reserved IP address is blocked: ${resolvedIp}`);
  }

  // 3. Create a custom agent that forces the request to the validated IP address
  // This prevents DNS rebinding between validation and connection time.
  const AgentClass = parsedUrl.protocol === 'https:' ? https.Agent : http.Agent;
  
  const agent = new AgentClass({
    keepAlive: false,
    maxSockets: 1,
    // Override socket creation to bind directly to the validated IP
    createConnection: (opts, callback) => {
      opts.servername = hostname; // Preserve SNI for TLS
      opts.host = resolvedIp;     // Connect directly to the safe IP
      
      const originalCreateConnection = (parsedUrl.protocol === 'https:' ? https : http);
      return originalCreateConnection.globalAgent.createConnection(opts, callback);
    }
  });

  try {
    // Execute fetch using Node.js native fetch with the custom dispatcher/agent
    // Note: Node's native fetch uses undici. For custom agent mapping in undici,
    // or for older patterns, utilizing http/https request wrappers or undici Agents is recommended.
    // Below is the standard fetch approach with custom dispatcher if using Node 18+ undici.
    
    const response = await fetch(parsedUrl, {
      ...options,
      // Pass custom dispatcher if using Node.js built-in fetch (Undici based)
      // dispatcher: ... 
    });
    
    return response;
  } finally {
    agent.destroy();
  }
}

Note on Undici and Node.js fetch: Node.js’s native fetch is powered by the undici library. Undici supports custom Agent and Pool configurations. If you prefer using libraries like axios, you can achieve similar socket pinning using custom http.Agent / https.Agent implementations directly passed to the Axios config.


Alternative: Using Axios with a Custom Agent

If your Node.js application uses axios, you can enforce IP validation and pinning via custom agents without rewriting your fetch logic:

import axios from 'axios';
import http from 'node:http';
import https from 'node:https';
import dns from 'node:dns/promises';

async function secureAxiosGet(targetUrl) {
  const parsed = new URL(targetUrl);
  if (!['http:', 'https:'].includes(parsed.protocol)) {
    throw new Error('Invalid protocol');
  }

  // Resolve and validate IP
  const { address: ip } = await dns.lookup(parsed.hostname);
  if (!isIpSafe(ip)) {
    throw new Error('Blocked IP address');
  }

  // Pin the IP using custom agents
  const httpAgent = new http.Agent({ keepAlive: false });
  const httpsAgent = new https.Agent({ keepAlive: false });

  // Intercept socket lookup to point directly to validated IP
  const lookupFn = (hostname, options, callback) => {
    callback(null, ip, ip.includes(':') ? 6 : 4);
  };

  httpAgent.defaultPort = parsed.protocol === 'https:' ? 443 : 80;
  
  // Configure axios request with overridden lookup
  return axios.get(targetUrl, {
    httpAgent: new http.Agent({ lookup: lookupFn }),
    httpsAgent: new https.Agent({ lookup: lookupFn }),
    maxRedirects: 0 // CRITICAL: Prevent redirect-based SSRF bypasses
  });
}

Crucial Security Edge Cases

Implementing IP validation is only half the battle. Watch out for these common oversights:

1. Handling HTTP Redirects (3xx)

An attacker might supply a URL pointing to a public, harmless domain (e.g., https://example.com/redirect), which immediately issues an HTTP 302 Found pointing to http://169.254.169.254/latest/meta-data/.

  • Mitigation: Disable automatic redirects (maxRedirects: 0 in Axios, or handling redirect responses manually). If you must support redirects, every single redirect target URL must pass through the complete URL parsing, DNS resolution, and IP validation pipeline before the client follows it.

2. IPv6 Mappings and Special Addresses

Ensure your IP validation library covers IPv6 loopback (::1), Unique Local Addresses (fc00::/7), and IPv4-mapped IPv6 addresses (e.g., ::ffff:127.0.0.1). Using robust parsers like ipaddr.js handles these transformations automatically.

3. Infrastructure-Level Egress Filtering

Defense-in-depth dictates that your application code shouldn’t be the only layer of protection. Configure your cloud provider security groups, VPC route tables, and container firewalls (e.g., Kubernetes NetworkPolicies) to block outgoing connections from application pods to internal metadata IPs (169.254.169.254) and internal subnets unless explicitly required.

Conclusion

Server-Side Request Forgery is a severe vulnerability that bridges the gap between public-facing web apps and private internal infrastructure. Simple regex checks and blacklist strings are entirely inadequate against determined attackers using IP encoding and DNS rebinding.

By enforcing strict protocol checks, performing explicit DNS resolution prior to connection, validating IP addresses against comprehensive private ranges, and disabling unchecked HTTP redirects, you can build secure Node.js applications that safely interact with the wider web while keeping your internal network locked down.

More posts