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
];
| Range | Description |
|---|
10.0.0.0/8 | RFC 1918 private network |
127.0.0.0/8 | Loopback |
172.16.0.0/12 | RFC 1918 private network |
192.168.0.0/16 | RFC 1918 private network |
169.254.0.0/16 | Link-local (cloud metadata endpoints use this range) |
0.0.0.0/8 | This network |
IPv6
// src/utils/network.ts
const PRIVATE_IPV6 = [
/^fc00:/, // Unique local
/^fd00:/,
/^fe80:/, // Link-local
/^::1$/, // Loopback
];
| Prefix | Description |
|---|
fc00::/7 | Unique local (fc00: and fd00:) |
fe80::/10 | Link-local |
::1 | Loopback |
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.