Published Build3 min read
CrawlForge's SSRF guard checked DNS, so a decimal IP walked straight past it
The v5.0.0 audit log - 480 tests to 914, 16 npm advisories to zero, 27 tools unchanged - is a fair price list for hardening a fetch-anything tool an agent drives.
Written for builders.See today for builders
What happened
- http://2130706433/ is a valid URL that a browser resolves to 127.0.0.1, because the WHATWG URL parser normalizes decimal, hex (0x7f000001) and octal integer host forms into dotted-quad IPv4.
- CrawlForge's SSRF guard resolved hostnames through DNS and range-checked the resulting addresses, but Node never routes an IP literal through lookup, so a URL whose host was already an IP bypassed the check entirely and the request went out.
- As a result, loopback, link-local and cloud metadata addresses were all reachable in a server whose entire job is fetching URLs a model picked.
- v5.0.0 runs ipBlocked() on IP-literal hostnames at pre-flight and wraps the undici dispatcher's buildConnector with a per-connect check, so a redirect hop straight to an internal address is blocked as well.
- IPv4-mapped IPv6 addresses such as ::ffff:127.0.0.1 and ::ffff:169.254.169.254 are now normalized to their embedded IPv4 before range checks, in both default and strict modes, killing the DNS-controlled AAAA-record bypass.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
CrawlForge's MCP scraping server shipped an SSRF guard that resolved hostnames through DNS and range-checked the resulting addresses, and according to the project's own v5.0.0 writeup a URL of the form `http://2130706433/` sailed past it untouched [1][2]. It matters because Node does not route an IP literal through `lookup`, so on the cheapest input an attacker can supply, the guard never ran at all [2].
The parsing behaviour is not obscure. The WHATWG URL parser normalizes decimal, hex (`0x7f000001`) and octal integer hosts into dotted-quad IPv4, so a browser resolves that decimal literal to 127.0.0.1 [1], which is exactly what the arithmetic gives [1]. The consequence, per the writeup, was that loopback, link-local and cloud metadata addresses were all reachable in a server whose entire job is fetching URLs a model picked [3].
The fix is two-layered rather than one more string check. v5.0.0 runs `ipBlocked()` against IP-literal hostnames at pre-flight and wraps the undici dispatcher's `buildConnector` with a per-connect check, so a redirect hop into an internal address is blocked too [4]. Three related holes closed alongside it: IPv4-mapped IPv6 forms such as `::ffff:169.254.169.254` are now normalized to the embedded IPv4 before range checks, killing a DNS-controlled AAAA-record bypass [5]; `BLOCKED_DOMAINS` was declared and read by nothing and is now enforced at pre-flight [6]; and the allowlist is evaluated per hop, where an allowlisted first hop previously unguarded every redirect after it [7]. The guard was also wired into five code paths that never had it, including `scrape_with_actions`, which the writeup describes as having been a Playwright internal-network read primitive, `map_site`, `process_document` PDF downloads, webhook delivery, and `deep_research` notifications [8].
The bill for all this is the useful part. The unit suite went from 480 tests to 914 [9], an increase of 434 [2]; `npm audit` went from 16 vulnerabilities to zero [10]; and almost none of the release is new features [11]. The single breaking change is `engines.node` moving from `>=18.0.0` to `>=20.16.0` [12], driven by Node 18's April 2025 end-of-life and by `pdf-parse` 2.4.5, the maintained ESM rewrite needed to clear the last audit findings [13]. The project's own Dockerfile and CI already met that floor [14]. Tool schemas, output shapes and credit costs did not change, and the tool count stays at 27 [15]. MCP protocol compliance was reported at 100.0 percent with zero errors at every phase gate [16].
The other phases read like the same lesson applied elsewhere. The audit found 52 cases where tools were silently wrong [17]; `crawl_deep` awaited BFS child pages from inside an occupied queue slot, so the 30-second per-task timeout bounded the whole recursive crawl and discarded every page already fetched, while low concurrency settings deadlocked [18]. HTTP mode had only ever supported one session [19]. An anonymous register-authorize-token flow could mint operator-billed bearer tokens until `/oauth/authorize` began requiring proof of the operator's API key with constant-time comparison [20]. Telemetry now runs `maskSecrets()` before payloads leave the process [21], `deep_research` stopped writing LLM API keys to Winston file logs [22], and a throw from the credit check itself now bills zero [23].
Worth watching: the Playwright path is guarded by a post-navigation `page.url()` re-check [8], which acts after the request rather than before it, and all of the above is the vendor's own account of its own audit. If `BLOCKED_DOMAINS` was dead config, the question for any similar server is how much other declared-and-unread security configuration is sitting in it.
Claim ledger
Ranked by verification strength, evidence, and original report placement.
- [1]
http://2130706433/ is a valid URL that a browser resolves to 127.0.0.1, because the WHATWG URL parser normalizes decimal, hex (0x7f000001) and octal integer host forms into dotted-quad IPv4.
- [2]
CrawlForge's SSRF guard resolved hostnames through DNS and range-checked the resulting addresses, but Node never routes an IP literal through lookup, so a URL whose host was already an IP bypassed the check entirely and the request went out.
- [3]
As a result, loopback, link-local and cloud metadata addresses were all reachable in a server whose entire job is fetching URLs a model picked.
- [4]
v5.0.0 runs ipBlocked() on IP-literal hostnames at pre-flight and wraps the undici dispatcher's buildConnector with a per-connect check, so a redirect hop straight to an internal address is blocked as well.
ReportedView cited source - [5]
IPv4-mapped IPv6 addresses such as ::ffff:127.0.0.1 and ::ffff:169.254.169.254 are now normalized to their embedded IPv4 before range checks, in both default and strict modes, killing the DNS-controlled AAAA-record bypass.
ReportedView cited source - [6]
BLOCKED_DOMAINS was dead config: it was declared and read by nothing. It is now enforced at pre-flight.
ReportedView cited source
Sources & coverage · 1 publisher
The reporting this story was synthesized from, earliest first. Every link goes to the original.
- dev.toSimonAug 14CrawlForge v5.0.0: Security, Correctness, MCP Spec
Cited in this coverage: CrawlForge v5.0.0 writeup on dev.to


