crawlforge-mcp-server 4.9.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CLAUDE.md +6 -5
  2. package/README.md +19 -3
  3. package/package.json +10 -12
  4. package/server.js +315 -214
  5. package/src/core/ActionExecutor.js +117 -33
  6. package/src/core/AgentOrchestrator.js +8 -2
  7. package/src/core/AuthManager.js +51 -17
  8. package/src/core/ChangeTracker.js +26 -10
  9. package/src/core/JobManager.js +9 -1
  10. package/src/core/LocalizationManager.js +19 -6
  11. package/src/core/ResearchOrchestrator.js +173 -35
  12. package/src/core/SnapshotManager.js +162 -165
  13. package/src/core/StealthBrowserManager.js +25 -3
  14. package/src/core/WebhookDispatcher.js +19 -14
  15. package/src/core/analysis/ContentAnalyzer.js +52 -7
  16. package/src/core/crawlers/BFSCrawler.js +27 -3
  17. package/src/core/processing/BrowserProcessor.js +19 -1
  18. package/src/core/processing/PDFProcessor.js +129 -65
  19. package/src/core/queue/QueueManager.js +3 -2
  20. package/src/schemas/toolOutputSchemas.js +269 -0
  21. package/src/server/auth/oauth.js +37 -7
  22. package/src/server/specHygiene.js +192 -0
  23. package/src/server/taskSupport.js +233 -0
  24. package/src/server/toolFilter.js +98 -0
  25. package/src/server/transports/streamableHttp.js +148 -11
  26. package/src/server/withAuth.js +11 -4
  27. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +15 -0
  28. package/src/tools/advanced/ScrapeWithActionsTool.js +43 -52
  29. package/src/tools/advanced/batchScrape/index.js +128 -27
  30. package/src/tools/advanced/batchScrape/worker.js +55 -5
  31. package/src/tools/advanced/scrapeWithActions/recorder.js +3 -0
  32. package/src/tools/basic/_fetch.js +125 -70
  33. package/src/tools/basic/extractLinks.js +14 -12
  34. package/src/tools/basic/scrapeStructured.js +21 -4
  35. package/src/tools/crawl/crawlDeep.js +110 -48
  36. package/src/tools/crawl/mapSite.js +25 -6
  37. package/src/tools/extract/_fetchAndParse.js +98 -1
  38. package/src/tools/extract/extractContent.js +7 -4
  39. package/src/tools/extract/extractStructured.js +125 -84
  40. package/src/tools/extract/extractWithLlm.js +10 -2
  41. package/src/tools/extract/processDocument.js +54 -6
  42. package/src/tools/extract/summarizeContent.js +7 -1
  43. package/src/tools/llmstxt/generateLLMsTxt.js +8 -6
  44. package/src/tools/research/deepResearch.js +51 -31
  45. package/src/tools/scrape/_brandingExtractor.js +49 -11
  46. package/src/tools/scrape/unifiedScrape.js +27 -17
  47. package/src/tools/search/providers/searxng.js +5 -1
  48. package/src/tools/search/ranking/ResultDeduplicator.js +9 -1
  49. package/src/tools/search/ranking/ResultRanker.js +17 -2
  50. package/src/tools/search/searchWeb.js +31 -14
  51. package/src/tools/search/serpRank.js +23 -0
  52. package/src/tools/templates/TemplateRegistry.js +7 -1
  53. package/src/tools/tracking/trackChanges/index.js +87 -26
  54. package/src/tools/tracking/trackChanges/schema.js +2 -2
  55. package/src/utils/CircuitBreaker.js +11 -9
  56. package/src/utils/contentUtils.js +66 -53
  57. package/src/utils/secretMask.js +1 -1
  58. package/src/utils/sitemapParser.js +11 -9
  59. package/src/utils/ssrfGuard.js +212 -40
  60. package/src/utils/urlNormalizer.js +2 -2
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * This wires the (previously unused) SSRF protections into the actual scraping
5
5
  * fetch helpers. Enforcement happens at TCP connect time via a custom undici
6
- * dispatcher `lookup`, so it covers the initial request, every redirect hop, and
7
- * closes the DNS-rebinding window (the validated IP is the one connected to —
8
- * there is no second, unchecked resolution).
6
+ * connector, so it covers the initial request, every redirect hop (including
7
+ * hops that land on a bare IP literal, which Node's `net.connect` never hands
8
+ * to a `lookup` callback), and closes the DNS-rebinding window (the validated
9
+ * IP is the one connected to — there is no second, unchecked resolution).
9
10
  *
10
11
  * Two levels:
11
12
  * - Stage 1 (default): blocks connections to loopback, link-local /
@@ -14,14 +15,24 @@
14
15
  * - Stage 2 (SSRF_STRICT=true): full private-range enforcement (RFC1918, ULA,
15
16
  * multicast, CGNAT, etc.) via the existing SSRFProtection range logic.
16
17
  *
18
+ * IP-literal hostnames (loopback/metadata expressed as dotted-quad, decimal,
19
+ * hex, or IPv4-mapped/compatible IPv6 such as `::ffff:127.0.0.1`) are checked
20
+ * directly against the same rules — DNS resolution is not the only path in.
21
+ *
17
22
  * Controls (backwards-compatible defaults):
18
23
  * - SSRF_PROTECTION_ENABLED=false -> disable the guard entirely (kill switch).
19
24
  * - ALLOWED_DOMAINS=a.com,b.com -> bypass the guard for trusted hosts
20
25
  * (e.g. a local dev server at localhost). Matches host or any subdomain.
26
+ * Checked fresh for every hop (initial request and each redirect), so an
27
+ * allowlisted first hop does not unguard a subsequent hop to a different,
28
+ * non-allowlisted host.
29
+ * - BLOCKED_DOMAINS=a.com,b.com -> extra hostname denylist (host or
30
+ * subdomain match), checked at pre-flight alongside the IP-literal checks.
21
31
  * - SSRF_STRICT=true -> Stage 2 full enforcement.
22
32
  */
23
33
  import dns from 'node:dns';
24
- import { Agent } from 'undici';
34
+ import net from 'node:net';
35
+ import { Agent, buildConnector } from 'undici';
25
36
  import { config } from '../constants/config.js';
26
37
  import { SSRFProtection } from './ssrfProtection.js';
27
38
 
@@ -38,36 +49,144 @@ function strictMode() {
38
49
  return process.env.SSRF_STRICT === 'true';
39
50
  }
40
51
 
52
+ function stripBrackets(host) {
53
+ return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
54
+ }
55
+
56
+ /**
57
+ * Extracts the embedded IPv4 address from an IPv4-mapped/compatible IPv6
58
+ * literal (e.g. `::ffff:127.0.0.1` or its fully-expanded hex-group form
59
+ * `0:0:0:0:0:ffff:7f00:1`), returning null if `ip` isn't one of those forms.
60
+ * @param {string} ip
61
+ * @returns {string|null}
62
+ */
63
+ function extractMappedIPv4(ip) {
64
+ let s = ip.toLowerCase().split('%')[0]; // drop a zone id, if present
65
+
66
+ // A dotted-quad tail (e.g. "::ffff:127.0.0.1") -> two hex groups, so the
67
+ // rest of this function only has to deal with one address shape.
68
+ const dottedTail = s.match(/^(.*:)(\d{1,3}(?:\.\d{1,3}){3})$/);
69
+ if (dottedTail) {
70
+ const quad = dottedTail[2].split('.').map(Number);
71
+ if (quad.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return null;
72
+ const hi = ((quad[0] << 8) | quad[1]).toString(16);
73
+ const lo = ((quad[2] << 8) | quad[3]).toString(16);
74
+ s = `${dottedTail[1]}${hi}:${lo}`;
75
+ }
76
+
77
+ let groups;
78
+ if (s.includes('::')) {
79
+ const [left, right] = s.split('::');
80
+ const leftParts = left ? left.split(':').filter(Boolean) : [];
81
+ const rightParts = right ? right.split(':').filter(Boolean) : [];
82
+ const missing = 8 - leftParts.length - rightParts.length;
83
+ if (missing < 0) return null;
84
+ groups = [...leftParts, ...Array(missing).fill('0'), ...rightParts];
85
+ } else {
86
+ groups = s.split(':');
87
+ }
88
+ if (groups.length !== 8) return null;
89
+
90
+ const first5AreZero = groups.slice(0, 5).every((g) => parseInt(g || '0', 16) === 0);
91
+ if (!first5AreZero || groups[5] !== 'ffff') return null;
92
+
93
+ const hi = parseInt(groups[6], 16);
94
+ const lo = parseInt(groups[7], 16);
95
+ if (Number.isNaN(hi) || Number.isNaN(lo)) return null;
96
+ return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
97
+ }
98
+
41
99
  /**
42
- * Whether a resolved IP must be blocked for the current mode.
100
+ * Whether a resolved (or literal) IP must be blocked for the current mode.
101
+ * IPv4-mapped/compatible IPv6 literals are normalized to their embedded IPv4
102
+ * address first, so they can't slip past the same checks a bare IPv4 gets.
43
103
  * @param {string} ip
44
104
  * @returns {boolean}
45
105
  */
46
106
  export function ipBlocked(ip) {
107
+ const mapped = net.isIPv6(ip) ? extractMappedIPv4(ip) : null;
108
+ const effectiveIp = mapped || ip;
47
109
  if (strictMode()) {
48
110
  // Full enforcement: anything not explicitly allowed by SSRFProtection.
49
- return !_ssrf.isIPAllowed(ip);
111
+ return !_ssrf.isIPAllowed(effectiveIp);
112
+ }
113
+ if (effectiveIp === '127.0.0.1' || effectiveIp === '::1' || effectiveIp === '0.0.0.0') return true;
114
+ return STAGE1_RANGES.some((range) => _ssrf.isIPInRange(effectiveIp, range));
115
+ }
116
+
117
+ function ssrfBlockedError(message) {
118
+ return Object.assign(new Error(`SSRF Protection: ${message}`), { code: 'SSRF_BLOCKED' });
119
+ }
120
+
121
+ function throwBlocked(message) {
122
+ throw ssrfBlockedError(message);
123
+ }
124
+
125
+ function hostMatchesList(host, list) {
126
+ return (list || []).some((d) => {
127
+ const dd = String(d).trim().toLowerCase();
128
+ return dd && (host === dd || host.endsWith('.' + dd));
129
+ });
130
+ }
131
+
132
+ function isAllowlisted(host, allowed) {
133
+ return hostMatchesList(host, allowed);
134
+ }
135
+
136
+ function isBlockedDomain(host, blocked) {
137
+ return hostMatchesList(host, blocked);
138
+ }
139
+
140
+ /**
141
+ * Shared, synchronous pre-flight: protocol, allowlist, metadata hosts,
142
+ * BLOCKED_DOMAINS, and (for IP-literal hosts) ipBlocked(). Throws
143
+ * (code SSRF_BLOCKED) on any violation. Used by both `ssrfGuard()` and
144
+ * `assertUrlAllowed()` so the two never drift.
145
+ * @param {URL} u
146
+ * @param {object} sec config.security.ssrfProtection
147
+ * @returns {{ host: string, allowlisted: boolean, ipLiteral: number }}
148
+ */
149
+ function preflightHostCheck(u, sec) {
150
+ if (!['http:', 'https:'].includes(u.protocol)) {
151
+ throwBlocked(`protocol '${u.protocol}' is not allowed`);
152
+ }
153
+
154
+ const host = stripBrackets(u.hostname.toLowerCase());
155
+ if (isAllowlisted(host, sec.allowedDomains)) {
156
+ return { host, allowlisted: true, ipLiteral: net.isIP(host) };
157
+ }
158
+
159
+ if (METADATA_HOSTS.has(host)) {
160
+ throwBlocked(`blocked metadata host '${host}'`);
50
161
  }
51
- if (ip === '127.0.0.1' || ip === '::1' || ip === '0.0.0.0') return true;
52
- return STAGE1_RANGES.some((range) => _ssrf.isIPInRange(ip, range));
162
+ if (isBlockedDomain(host, sec.blockedDomains)) {
163
+ throwBlocked(`blocked hostname '${host}'`);
164
+ }
165
+
166
+ const ipLiteral = net.isIP(host);
167
+ if (ipLiteral && ipBlocked(host)) {
168
+ throwBlocked(`blocked IP literal '${host}'`);
169
+ }
170
+
171
+ return { host, allowlisted: false, ipLiteral };
53
172
  }
54
173
 
55
174
  /**
56
175
  * undici connect-time lookup: resolves the host, rejects if ANY resolved address
57
176
  * is blocked, otherwise hands undici the validated address(es) — so the socket
58
- * connects to exactly what we checked (rebinding-safe).
177
+ * connects to exactly what we checked (rebinding-safe). Allowlisted hostnames
178
+ * are resolved normally, with no address filtering.
59
179
  */
60
180
  function ssrfLookup(hostname, opts, callback) {
181
+ const sec = config.security?.ssrfProtection;
182
+ if (isAllowlisted(hostname.toLowerCase(), sec?.allowedDomains)) {
183
+ return dns.lookup(hostname, opts, callback);
184
+ }
61
185
  dns.lookup(hostname, { all: true, verbatim: true }, (err, addresses) => {
62
186
  if (err) return callback(err);
63
187
  for (const { address } of addresses) {
64
188
  if (ipBlocked(address)) {
65
- return callback(
66
- Object.assign(
67
- new Error(`SSRF Protection: ${hostname} resolves to blocked address ${address}`),
68
- { code: 'SSRF_BLOCKED' }
69
- )
70
- );
189
+ return callback(ssrfBlockedError(`${hostname} resolves to blocked address ${address}`));
71
190
  }
72
191
  }
73
192
  if (opts && opts.all) return callback(null, addresses);
@@ -76,26 +195,43 @@ function ssrfLookup(hostname, opts, callback) {
76
195
  });
77
196
  }
78
197
 
198
+ /**
199
+ * Wraps undici's base connector to also cover IP-literal hosts, which
200
+ * `net.connect` never routes through `lookup` — so hostname-based DNS
201
+ * rebinding checks (ssrfLookup, above) alone can't catch a redirect straight
202
+ * to e.g. `http://127.0.0.1/`. Checked per-connect, so every hop (initial
203
+ * request and each redirect) is validated independently.
204
+ */
205
+ function guardedConnect(baseConnect) {
206
+ return function connect(opts, callback) {
207
+ const hostname = stripBrackets(String(opts.hostname || opts.host || '').toLowerCase());
208
+ if (net.isIP(hostname)) {
209
+ const sec = config.security?.ssrfProtection;
210
+ if (!isAllowlisted(hostname, sec?.allowedDomains) && ipBlocked(hostname)) {
211
+ return callback(ssrfBlockedError(`blocked IP literal '${hostname}'`));
212
+ }
213
+ }
214
+ return baseConnect(opts, callback);
215
+ };
216
+ }
217
+
79
218
  let _agent = null;
80
219
  function guardedDispatcher() {
81
220
  if (!_agent) {
82
- _agent = new Agent({ connect: { lookup: ssrfLookup } });
221
+ const baseConnect = buildConnector({ lookup: ssrfLookup });
222
+ _agent = new Agent({ connect: guardedConnect(baseConnect) });
83
223
  }
84
224
  return _agent;
85
225
  }
86
226
 
87
- function isAllowlisted(host, allowed) {
88
- return (allowed || []).some((d) => {
89
- const dd = String(d).trim().toLowerCase();
90
- return dd && (host === dd || host.endsWith('.' + dd));
91
- });
92
- }
93
-
94
227
  /**
95
228
  * Pre-flight check + dispatcher selection for an outbound scrape target.
96
- * Returns `{ dispatcher }` to spread into fetch options. `dispatcher` is
97
- * undefined when the guard is disabled or the host is explicitly allowlisted.
98
- * Throws (code SSRF_BLOCKED) for protocol / metadata-host pre-flight violations.
229
+ * Returns `{ dispatcher }` to spread into fetch options; the dispatcher is
230
+ * always the guarded one (it enforces allowlist/blocklist/IP checks per-hop
231
+ * internally) so redirects can never escape it. `{}` is returned only when
232
+ * the guard is disabled outright (kill switch). Throws (code SSRF_BLOCKED)
233
+ * for pre-flight violations (protocol, metadata host, BLOCKED_DOMAINS, or a
234
+ * blocked IP-literal host) on the initial URL.
99
235
  *
100
236
  * @param {string} url
101
237
  * @returns {{ dispatcher?: import('undici').Agent }}
@@ -111,22 +247,52 @@ export function ssrfGuard(url) {
111
247
  return {}; // let fetch surface its own invalid-URL error
112
248
  }
113
249
 
114
- if (!['http:', 'https:'].includes(u.protocol)) {
115
- throw Object.assign(new Error(`SSRF Protection: protocol '${u.protocol}' is not allowed`), {
116
- code: 'SSRF_BLOCKED',
117
- });
118
- }
250
+ preflightHostCheck(u, sec); // throws on violation
119
251
 
120
- const host = u.hostname.toLowerCase();
121
- if (isAllowlisted(host, sec.allowedDomains)) return {}; // explicit escape hatch
252
+ return { dispatcher: guardedDispatcher() };
253
+ }
122
254
 
123
- if (METADATA_HOSTS.has(host)) {
124
- throw Object.assign(new Error(`SSRF Protection: blocked metadata host '${host}'`), {
125
- code: 'SSRF_BLOCKED',
126
- });
255
+ /**
256
+ * Shared pre-flight helper for subsystems that don't go through `safeFetch`
257
+ * (e.g. Playwright navigation). Resolves (returns undefined) when the URL is
258
+ * allowed; throws an Error with `code: 'SSRF_BLOCKED'` (message starting
259
+ * `SSRF Protection:`) when blocked.
260
+ *
261
+ * Kill switch and allowlist behave exactly as in `ssrfGuard()`. When
262
+ * `resolveDns` is true and the host is a (non-allowlisted, non-IP-literal)
263
+ * hostname, it is resolved via `dns.promises.lookup` and every returned
264
+ * address is checked with `ipBlocked()`; a DNS failure here is not itself
265
+ * treated as a block (the caller's own fetch/connect will surface it).
266
+ *
267
+ * @param {string} urlString
268
+ * @param {{ resolveDns?: boolean }} [opts]
269
+ * @returns {Promise<void>}
270
+ */
271
+ export async function assertUrlAllowed(urlString, { resolveDns = false } = {}) {
272
+ const sec = config.security?.ssrfProtection;
273
+ if (!sec || sec.enabled === false) return; // kill switch
274
+
275
+ let u;
276
+ try {
277
+ u = new URL(urlString);
278
+ } catch {
279
+ return; // let the caller's own URL parsing surface the error
127
280
  }
128
281
 
129
- return { dispatcher: guardedDispatcher() };
282
+ const { host, allowlisted, ipLiteral } = preflightHostCheck(u, sec); // throws on violation
283
+ if (allowlisted || ipLiteral || !resolveDns) return;
284
+
285
+ let addresses;
286
+ try {
287
+ addresses = await dns.promises.lookup(host, { all: true, verbatim: true });
288
+ } catch {
289
+ return; // DNS failures are not ours to enforce; let the caller's own fetch surface them
290
+ }
291
+ for (const { address } of addresses) {
292
+ if (ipBlocked(address)) {
293
+ throwBlocked(`${host} resolves to blocked address ${address}`);
294
+ }
295
+ }
130
296
  }
131
297
 
132
298
  /** True if an error (or its fetch `cause`) came from the SSRF guard. */
@@ -146,7 +312,7 @@ export function isSsrfError(err) {
146
312
  * @returns {Promise<Response>}
147
313
  */
148
314
  export async function safeFetch(url, options = {}) {
149
- const guard = ssrfGuard(url); // throws on protocol / metadata-host violations
315
+ const guard = ssrfGuard(url); // throws on protocol / metadata-host / blocklist / IP-literal violations
150
316
  try {
151
317
  return await fetch(url, { ...options, ...guard });
152
318
  } catch (err) {
@@ -158,4 +324,10 @@ export async function safeFetch(url, options = {}) {
158
324
  }
159
325
 
160
326
  // Exposed for unit tests.
161
- export const __ssrfInternals = { ssrfLookup, isAllowlisted, STAGE1_RANGES };
327
+ export const __ssrfInternals = {
328
+ ssrfLookup,
329
+ isAllowlisted,
330
+ isBlockedDomain,
331
+ extractMappedIPv4,
332
+ STAGE1_RANGES,
333
+ };
@@ -20,8 +20,8 @@ export function normalizeUrl(url) {
20
20
  if (urlObj.search) {
21
21
  const params = new URLSearchParams(urlObj.search);
22
22
  const sortedParams = new URLSearchParams();
23
- [...params.keys()].sort().forEach(key => {
24
- sortedParams.append(key, params.get(key));
23
+ [...params.entries()].sort(([a], [b]) => a.localeCompare(b)).forEach(([key, value]) => {
24
+ sortedParams.append(key, value);
25
25
  });
26
26
  urlObj.search = sortedParams.toString();
27
27
  }