How to Build a Secure Serverless Port Scanner in Node.js (and Prevent SSRF)

dev.to

Every network engineer and systems developer needs to verify connection ports. Whether you're debugging why a remote database connection is failing, checking if an SSH daemon is running, or auditing active firewall rules, programmatically checking TCP ports is a core developer task.

However, writing a port scanner in Node.js comes with a massive, critical security risk: Server-Side Request Forgery (SSRF).

If you allow users to pass a host parameter directly into a network socket connection, an attacker can input localhost or local IPs (like 127.0.0.1 or 192.168.1.1) to map and scan your own server's internal networks, databases, and microservices.

Here is how to build a high-performance TCP port scanner in Node.js that runs in a serverless environment and is fully hardened against SSRF attacks.


1. The Core Port Scanner logic (TCP & Banner Grabbing)

We use Node's built-in net module to attempt quick TCP socket connections.

If a connection succeeds, we wait up to 250ms to read any greeting bytes sent by the server. This allows our scanner to perform banner grabbing—allowing us to extract software versions (like SSH-2.0-OpenSSH_8.9p1) directly.

import net from 'net';

function checkPort(ipAddress, port, timeout = 1000) {
  return new Promise((resolve) => {
    const socket = new net.Socket();
    let status = 'closed';
    let banner = null;
    let completed = false;

    socket.setTimeout(timeout);

    socket.once('connect', () => {
      status = 'open';
      // Wait briefly for greeting banner (e.g. SSH/SMTP welcome banners)
      socket.setTimeout(250);
    });

    socket.on('data', (data) => {
      banner = data.toString('utf8').trim().substring(0, 128);
      cleanup();
    });

    socket.on('timeout', () => {
      status = status === 'open' ? 'open' : 'filtered';
      cleanup();
    });

    socket.on('error', () => {
      status = 'closed';
      cleanup();
    });

    function cleanup() {
      if (completed) return;
      completed = true;
      try { socket.destroy(); } catch (e) {}
      resolve({ port, status, banner });
    }

    try {
      socket.connect(port, ipAddress);
    } catch (err) {
      status = 'closed';
      cleanup();
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

2. Preventing SSRF: Hardening the DNS Lookup

Before you let Node connect to any hostname (like google.com or an IP address), you must:

  1. Resolve the hostname to an IP address.
  2. Check if the resolved IP belongs to a private, loopback, or link-local subnet.
  3. Terminate the request if it points to a private subnet.

Here is the subnet validation check:
import dns from 'dns';


// Check if IPv4 matches loopback, private subnets (Class A/B/C), or link-local
function isPrivateIPv4(ip) {
  const parts = ip.split('.').map(Number);
  if (parts.length !== 4 || parts.some(isNaN)) return true;

  return (
    parts[0] === 127 || // Loopback (127.0.0.0/8)
    parts[0] === 10 ||  // Private Class A (10.0.0.0/8)
    (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || // Private Class B
    (parts[0] === 192 && parts[1] === 168) || // Private Class C (192.168.0.0/16)
    (parts[0] === 169 && parts[1] === 254) || // Link-local (169.254.0.0/16)
    parts[0] === 0      // Local broadcast
  );
}

// Check if IPv6 points to loopback, link-local, or unique local ranges
function isPrivateIPv6(ip) {
  const normalized = ip.toLowerCase().trim();
  return (
    normalized === '::1' || 
    normalized === '::' ||
    normalized.startsWith('fe80:') || // Link-local
    normalized.startsWith('fc00:') || // Unique local
    normalized.startsWith('fd00:')
  );
}
Enter fullscreen mode Exit fullscreen mode

Now, integrate this check into your routing logic:


async function secureScanHandler(host, portsToScan = [80, 443]) {
  // 1. Resolve host to target IP
  let targetIp;
  try {
    const lookup = await dns.promises.lookup(host);
    targetIp = lookup.address;
  } catch (err) {
    throw new Error(`Could not resolve host: ${err.message}`);
  }

  // 2. Enforce SSRF blocklist
  const isIPv6 = targetIp.includes(':');
  const isPrivate = isIPv6 ? isPrivateIPv6(targetIp) : isPrivateIPv4(targetIp);

  if (isPrivate) {
    throw new Error('Access denied: Scanning internal network targets is prohibited.');
  }

  // 3. Scan ports in parallel
  const results = await Promise.all(
    portsToScan.map(port => checkPort(targetIp, port))
  );

  return { host, ip: targetIp, results };
}
Enter fullscreen mode Exit fullscreen mode

3. Best Practices for Production Scanners

  1. Scan Limits: Serverless runtimes (like Vercel or AWS Lambda) have strict execution timeouts (typically 10-15s on free plans). Restrict developers to scanning a maximum of 20 ports concurrently to ensure your handler finishes quickly.
  2. Timeout Boundaries: Keep the connection timeout boundary low (e.g., 1000ms). Firewalls that drop packages silently (resulting in a filtered status) will cause sockets to hang until they hit this threshold.

Try it Live

If you don't want to build, host, and maintain your own scraping servers and IP subnets lists, I have deployed a fully hardened version of this tool.

You can try out, test code snippets, and call this service with a free sandbox tier (up to 100 queries a month) directly at the Port Scanner & Network Diagnostics API on RapidAPI.

How are you managing network diagnostic validations in your deployment flows? Let me know in the comments below!

Source: dev.to

arrow_back Back to News