Skip to main content
Phisherman accepts arbitrary URLs as input and resolves their hostnames during heuristic analysis. Without safeguards, an attacker could submit URLs pointing to internal services and cause the server to make requests on their behalf — a Server-Side Request Forgery (SSRF) attack. Phisherman blocks SSRF at two levels: a static IP check for URLs that already contain a raw IP address, and a safe DNS resolver that validates resolved addresses before any connection is made.
Phisherman is designed to analyze public URLs only. Submitting URLs that point to internal services, loopback addresses, or cloud metadata endpoints will be blocked and scored accordingly.

Private IP ranges blocked

IPv4

The following ranges are blocked:
// src/utils/network.ts
const PRIVATE_IP_RANGES = [
  /^10\./,          // RFC 1918 — 10.0.0.0/8
  /^127\./,         // Loopback — 127.0.0.0/8
  /^172\.(1[6-9]|2[0-9\D]|3[0-1])\./,  // RFC 1918 — 172.16.0.0/12
  /^192\.168\./,    // RFC 1918 — 192.168.0.0/16
  /^169\.254\./,    // Link-local — 169.254.0.0/16
  /^0\./,           // This network — 0.0.0.0/8
];
RangeDescription
10.0.0.0/8RFC 1918 private network
127.0.0.0/8Loopback
172.16.0.0/12RFC 1918 private network
192.168.0.0/16RFC 1918 private network
169.254.0.0/16Link-local (cloud metadata endpoints use this range)
0.0.0.0/8This network

IPv6

// src/utils/network.ts
const PRIVATE_IPV6 = [
  /^fc00:/, // Unique local
  /^fd00:/,
  /^fe80:/, // Link-local
  /^::1$/,  // Loopback
];
PrefixDescription
fc00::/7Unique local (fc00: and fd00:)
fe80::/10Link-local
::1Loopback

blockIfPrivate

blockIfPrivate() is a synchronous guard applied before any DNS resolution. It handles the case where the user-supplied URL already contains a raw IP address:
// src/utils/network.ts
export function blockIfPrivate(host: string) {
  if (isIP(host) && isPrivateIP(host)) {
    throw new Error("Blocked private IP address (SSRF protection)");
  }
}
In the heuristics checker, blockIfPrivate is called immediately after the URL is parsed:
// src/checkers/heuristics.ts
try {
  blockIfPrivate(hostname);
} catch {
  return { score: 50, reasons: ["Private/Internal network address"] };
}
If the hostname is a literal private IP, scanning stops immediately and a score of 50 is returned.

safeResolveHost

safeResolveHost() resolves a hostname to its A and AAAA records and validates every returned IP before returning. If any resolved address falls within a private range, an error is thrown.
// src/utils/network.ts
export async function safeResolveHost(host: string): Promise<string[]> {
  try {
    const cached = await dnsCache.get<string[]>(host);
    if (cached) return cached;
  } catch (err) { }

  // If host is already an IP address
  if (isIP(host)) {
    if (isPrivateIP(host)) {
      throw new Error(`Blocked direct IP access to private range: ${host}`);
    }
    return [host];
  }

  // Resolve A & AAAA records
  let ips: string[] = [];
  try {
    const A = await dns.resolve4(host).catch(() => []);
    const AAAA = await dns.resolve6(host).catch(() => []);
    ips = [...A, ...AAAA];
  } catch (e) {
    throw new Error("DNS resolution failed: " + (e as any).message);
  }

  if (ips.length === 0) {
    throw new Error("Host resolved but no valid A/AAAA records found");
  }

  // Block private IPs
  for (const ip of ips) {
    if (isPrivateIP(ip)) {
      throw new Error(`Blocked private IP address: ${ip}`);
    }
  }

  // ...
}
The function rejects any host where any resolved address is private — not just the first one.

DNS rebinding protection

DNS rebinding is an attack where a hostname first resolves to a public IP (passing the check above), then immediately resolves to a private IP when the actual connection is made. Phisherman mitigates this with a second, independent lookup:
// src/utils/network.ts
// DNS Rebinding Protection (re-resolve to check consistency)
const secondLookup = await dns.lookup(host, { all: true }).catch(() => []);
const reboundIPs = secondLookup.map((r) => r.address);

for (const ip of reboundIPs) {
  if (isPrivateIP(ip)) {
    throw new Error(`Blocked via DNS rebinding check: ${ip}`);
  }
}
The second lookup uses dns.lookup() — which goes through the OS resolver rather than querying authoritative servers directly — to simulate what a subsequent connection attempt would see. If any address in the second lookup is private, the host is blocked.

DNS result caching

Successful DNS resolutions are cached in the dnsCache HashCache instance for 1 hour:
// src/utils/network.ts
const DNS_CACHE_TTL = 3600; // 1 hour

// src/utils/hashCache.ts
export const dnsCache = new HashCache("dns_cache", 3600);
Caching avoids repeating the full two-lookup sequence for every scan of a recently seen hostname. The HashCache stores results in dns_cache_hash with expiry tracked in dns_cache_expiry, and expired entries are purged on every CacheManager cycle.

Build docs developers (and LLMs) love