http://2130706433/
is a valid URL. Your browser will happily resolve it to 127.0.0.1
, because the WHATWG URL parser normalizes decimal, hex (0x7f000001
), and octal integer forms into dotted-quad IPv4.
Our SSRF guard did not know that. It 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 sailed straight past the check. Loopback, link-local, cloud metadata: all reachable, in a server whose entire job is fetching URLs a model picked for you.
That is one bug out of the seven-phase internal audit that became ** CrawlForge MCP Server v5.0.0**. The unit suite went from
npm audit
went from | Phase | Theme | Headline result |
|---|---|---|
| 0 | Dependency currency |
npm audit 16 vulns β 4 moderate, zero code change |
| 1 | Critical security | SSRF IP-literal bypass, OAuth token minting, secret leakage, billing |
| 2 | Correctness | 52 fixes β including a crawl_deep rewrite |
| 3 | Leaks and timeouts | 24 fixes β browser contexts, unbounded caches, real deadlines |
| 4 | HTTP transport | 19 fixes β multi-session streamable HTTP, working prompts, webhook HMAC |
| 5 | Dependency modernization | Node β₯ 20 floor, 0 npm audit vulnerabilities |
| 6 | MCP spec adoption | Structured output, async tasks, tool whitelisting, registry server.json
|
MCP protocol compliance held at 100.0% COMPLIANT, 0 errors at every phase gate.
engines.node
moved from >=18.0.0
to >=20.16.0
.
Node 18 hit end-of-life in April 2025, and 20.16 is the floor required by pdf-parse
2.4.5 β the maintained ESM rewrite we needed to clear the last audit findings. Our Dockerfile (node:20-alpine
) and CI (Node 22) already satisfied it.
That is the entire breaking surface. No tool schema, output shape, or credit cost changed, and the tool count stays at 27.
node --version # must be >= 20.16.0
Read this phase if you run any MCP scraping server near a private network.
BEFORE: url -> parse -> DNS lookup -> ipBlocked(resolved)? -> fetch
|
+--> IP literal? no lookup happens.
guard never runs. request goes out.
AFTER: url -> parse -> ipBlocked(literal host)? --------+
-> DNS lookup -> ipBlocked(addrs)? -+-> fetch
-> per-connect check in the undici dispatcher
(catches every redirect hop too)
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.
Three more guard fixes landed with it:
::ffff:127.0.0.1
and ::ffff:169.254.169.254
are normalized to their embedded IPv4 before range checks, in both default and strict modes. Kills the DNS-controlled AAAA-record bypass.BLOCKED_DOMAINS
was dead config.We also wired the guard into five paths that never had it: scrape_with_actions
(with a post-navigation page.url()
re-check that closes the page on a redirect into a blocked range β that was a Playwright internal-network read primitive), map_site
, process_document
PDF downloads, webhook delivery and health checks, and deep_research
webhook notifications.
/oauth/authorize
now requires proof of the operator's API key before issuing a code, with constant-time digest comparison. The anonymous register β authorize β token flow that minted operator-billed bearer tokens is closed.
Secret leakage. Usage telemetry passes tool params through maskSecrets()
before the payload leaves the process β third-party API keys, auth headers, and webhook signing secrets no longer travel in plaintext. deep_research
stopped writing LLM API keys to Winston file logs.
Billing. A throw from the credit check itself now bills zero; the error-path half-charge only applies once the handler has actually started. checkCredits
distinguishes 401/403 (invalid or revoked key) from 5xx (grace window) instead of reporting both as "insufficient credits."
If you want the general version of this problem rather than our specific one, we wrote it up separately: SSRF in MCP servers.
This is the "passes smoke tests, returns misleading output" class β the one that never shows up as an error in your logs.
** crawl_deep is usable for real crawls again.** BFS child pages were awaited from inside an occupied queue slot, so the per-task queue timeout bounded the
Promise timed out
, and low concurrency settings (including concurrency: 1
) deadlocked outright. Both fixed.crawl_deep
's result-cache key now covers extract_content
, content length, include/exclude patterns, follow_external
, respect_robots
, concurrency
, domain filter, and session. map_site
's covers search
, domain filter, include_metadata
, and group_by_path
. Previously a cached call could contradict your parameters for a full hour-long TTL.Content-Type
header or <meta charset>
sniff) instead of always UTF-8. No more U+FFFD soup from ISO-8859-1 or Shift_JIS sites.options
schemas for extract_content
, summarize_content
, and analyze_content
now use .passthrough()
. Every documented option key was being stripped before it reached the handler β which is summarize_content
always returned the same 2-sentence fallback mislabeled extractive
. The extractive summarizer now actually runs, and summaryLength
changes the output.extract_links
resolves relative hrefs against the final page URL rather than the origin, honors <base href>
, and classifies protocol-relative links as external. The same fixes landed in scrape
's extractor, so the two finally agree.track_changes
similarity.search_web
scoring.ranking_weights
deep-merge over the defaults instead of replacing them wholesale, so no more NaN
final scores or silently disabled duplicate checks. The zero-result expansion retry is capped at one fallback instead of up to five billed backend searches. 24 findings in the class that only surfaces in long-running processes.
Browser lifecycle. Closing a Playwright page does not close its context β so every scrape_with_actions
call and every browser-rendered extract_content
leaked one context until shutdown. Contexts are now closed alongside their page, and a failed page.goto
(DNS error, timeout, blocked URL) tears down both instead of orphaning them.
Bounded caches. crawl_deep
destroys its per-crawl CacheManager
in a finally
. Previously N crawls permanently leaked N caches of up to 1,000 full HTML documents each β every one of them re-running a JSON.stringify
memory scan every 60 seconds, forever. Dropped instances are now GC-verified with a WeakRef
regression test.
Deadlines on every body read. The abort timer stays armed through the body stream, so timeout
finally covers a server that returns headers and then stalls. Chunk reassembly is single-pass β it was O(nΒ²), roughly 1.5 seconds of synchronous event-loop block on a 25 MB body. PDF downloads got a real AbortSignal.timeout
(the old timeout:
fetch-init option is silently ignored by undici).
One for Claude Desktop users: snapshot storage defaults to ~/.crawlforge/snapshots
instead of process.cwd()
. MCP clients launch the server with a working directory of /
, where every snapshot write silently failed.
If you deployed over npm run start:http
, it was worse than you thought. A single shared transport meant exactly one session ever existed, and any clean disconnect bricked /mcp
until you restarted the process.
Stateful mode now follows the SDK's documented per-session pattern β a Map<sessionId, {transport, server}>
with a fresh transport and cloned McpServer
per initialize
, disposal on DELETE, and a JSON-RPC 404 for unknown session IDs. Second concurrent client, reconnect after a network drop, DELETE then fresh initialize: all work now.
Also in Phase 4:
getting-started
prompt was argsSchema
overload, advertising a bogus required argument and failing every prompts/get
. The compliance suite now covers discovery and retrieval for all 6 prompts.data
sub-object was being signed, so standard receiver-side raw-body verification failed every single time.scrape
no longer inlines multi-megabyte base64 screenshots into the JSON result β it keeps metadata plus a crawlforge://screenshot/{id}
resource URI.With the Node 20 floor in place, we retired every abandoned dependency and took the security upgrades the old floor had blocked. 4 moderate β 0.
Removed outright: node-cron
(unused after Phase 3 moved scheduling to setInterval
; removal cleared its vulnerable uuid
chain), @googleapis/customsearch
(unused β the Google adapter calls the REST endpoint directly), and node-summarizer
(abandoned since 2019; the extractive summarizer was rewritten as a compromise
-based Luhn-style word-frequency scorer with identical result shapes).
The upgrade that mattered most: ** pdf-parse 1.1.1 β 2.4.5**.
PDFProcessor
was ported to the v2 class API, so the password
option now actually decrypts protected PDFs β v1 silently ignored it.On supply chain: this phase ran during the ChainDrop npm worm. Every install ran with --ignore-scripts
, every adopted version was publish-date-gated, and the full lockfile diff was cross-checked against public compromised-package lists with zero matches.
Structured output (MCP 2025-06-18). scrape
, map_site
, serp_rank
, search_web
, extract_structured
, and crawl_deep
declare an outputSchema
and return structuredContent
alongside the legacy JSON text. The schemas are permissive by design, so a legitimate result can never fail SDK output validation.
Async tasks. crawl_deep
, batch_scrape
, deep_research
, and agent
are registered with taskSupport: 'optional'
under the io.modelcontextprotocol/tasks
extension. Task-aware clients get a handle immediately and poll tasks/get
; clients without task support still get the synchronous result exactly as before. This is the fix for long crawls timing out inside a client's tool-call window.
Client-side tool selection. Two env vars let you expose a subset of the 27 tools and cut context bloat:
CRAWLFORGE_TOOLS=scrape,search_web,extract_content
CRAWLFORGE_TOOL_GROUPS=search,extract
Unset means all tools. Unknown names are ignored with a stderr warning, and batch_scrape
auto-enables get_batch_results
.
Protocol hygiene. Schemas advertised in JSON Schema 2020-12 instead of draft-07. tools/list
sorted deterministically for client prompt-cache stability. Invalid tool arguments come back as isError: true
tool results β which a calling model can self-correct from β rather than -32602
protocol errors. And server.json
is complete against the 2025-12-11 registry schema.
serp_rank
, the v4.10.0 also added server-level MCP instructions
: the server tells any connecting client to prefer CrawlForge tools over its own built-in web capabilities. It ships in the server binary, so every client picks it up on the next launch after upgrade β no re-init
. It is guidance, not enforcement; an MCP server cannot disable a client's built-in tools.
Nothing changed. All 27 tools are metered and require an API key, at 1-10 credits per call.
| Plan | Price | Credits |
|---|---|---|
| Free | $0 (no card) | 1,000 one-time trial (does not reset) |
| Hobby | $19/mo | 5,000 |
| Professional | $99/mo | 50,000 |
| Business | $399/mo | 250,000 |
Every plan gets every tool. LLM extraction defaults to local Ollama, so you do not need an OpenAI or Anthropic key unless you opt in.
npm install -g crawlforge-mcp-server@latest # or an /mcp reconnect
npm install -g crawlforge-mcp-server && npx crawlforge init
Because Phase 2 fixed tool behavior rather than tool contracts, your existing calls keep working β they just return correct results now.
Deferred rather than rushed: a hosted remote endpoint with OAuth, a keyless tier, scheduled monitoring as a service, persistent sessions, and PII redaction.
Writing up your own bugs is uncomfortable, but "advertised control, silently non-functional" is the single most common failure mode we found across all seven phases β and it is invisible from the outside. If you find a CrawlForge control that does not behave the way the docs claim, that is exactly the bug we want.
npm: crawlforge-mcp-server Β·