crawlforge-mcp-server 6.14.0 → 6.16.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.
- package/README.md +1 -1
- package/package.json +3 -3
- package/server.js +63 -18
- package/src/cli/commands/stealth.js +2 -2
- package/src/core/ActionExecutor.js +140 -38
- package/src/core/AuthManager.js +12 -0
- package/src/core/LLMsTxtAnalyzer.js +1 -0
- package/src/core/ResearchOrchestrator.js +10 -4
- package/src/core/StealthBrowserManager.js +25 -4
- package/src/core/browser/actionQueue.js +4 -1
- package/src/core/browser/windowState.js +88 -0
- package/src/core/crawlers/BFSCrawler.js +12 -3
- package/src/core/processing/BrowserProcessor.js +19 -7
- package/src/core/processing/PDFProcessor.js +15 -6
- package/src/server/fallbackHints.js +7 -4
- package/src/server/inlineThreshold.js +81 -19
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -1
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +25 -6
- package/src/tools/advanced/BrowserSessionTool.js +38 -13
- package/src/tools/advanced/batchScrape/worker.js +3 -1
- package/src/tools/basic/_fetch.js +3 -1
- package/src/tools/crawl/_sessionContext.js +2 -1
- package/src/tools/crawl/mapSite.js +2 -1
- package/src/tools/extract/_fetchAndParse.js +1 -0
- package/src/tools/extract/extractContent.js +2 -1
- package/src/tools/extract/extractEmbeddedState.js +320 -71
- package/src/tools/extract/processDocument.js +5 -3
- package/src/tools/llmstxt/generateLLMsTxt.js +2 -1
- package/src/tools/templates/ScrapeTemplateTool.js +11 -3
- package/src/tools/tracking/trackChanges/differ.js +1 -0
- package/src/utils/complianceAudit.js +35 -5
- package/src/utils/impitRung.js +16 -5
- package/src/utils/resignedFetch.js +165 -0
- package/src/utils/robotsChecker.js +6 -1
- package/src/utils/robotsGate.js +124 -11
- package/src/utils/sitemapParser.js +2 -1
- package/src/utils/ssrfGuard.js +88 -11
package/src/utils/ssrfGuard.js
CHANGED
|
@@ -35,6 +35,7 @@ import net from 'node:net';
|
|
|
35
35
|
import { Agent, buildConnector } from 'undici';
|
|
36
36
|
import { config } from '../constants/config.js';
|
|
37
37
|
import { SSRFProtection } from './ssrfProtection.js';
|
|
38
|
+
import { fetchResigned } from './resignedFetch.js';
|
|
38
39
|
|
|
39
40
|
// Reused only for its (well-tested) CIDR range math — no network state.
|
|
40
41
|
const _ssrf = new SSRFProtection();
|
|
@@ -307,14 +308,18 @@ export function isSsrfError(err) {
|
|
|
307
308
|
* blocked targets it throws a clear `SSRF Protection: ...` error (pre-flight) or
|
|
308
309
|
* the fetch rejects at connect time with an SSRF_BLOCKED cause.
|
|
309
310
|
*
|
|
311
|
+
* A crawl request (one carrying a Web Bot Auth signature, or the gate's
|
|
312
|
+
* `onRedirect`) has its redirects followed one hop at a time, each through the
|
|
313
|
+
* same dispatcher, each gated and each signed for itself (see resignedFetch.js).
|
|
314
|
+
*
|
|
310
315
|
* @param {string} url
|
|
311
|
-
* @param {RequestInit} [options]
|
|
316
|
+
* @param {RequestInit & { onRedirect?: (to: string) => Promise<void> }} [options]
|
|
312
317
|
* @returns {Promise<Response>}
|
|
313
318
|
*/
|
|
314
319
|
export async function safeFetch(url, options = {}) {
|
|
315
320
|
const guard = ssrfGuard(url); // throws on protocol / metadata-host / blocklist / IP-literal violations
|
|
316
321
|
try {
|
|
317
|
-
return await
|
|
322
|
+
return await fetchResigned(url, { ...options, ...guard });
|
|
318
323
|
} catch (err) {
|
|
319
324
|
if (isSsrfError(err)) {
|
|
320
325
|
throw new Error(err.cause?.message || err.message);
|
|
@@ -323,27 +328,99 @@ export async function safeFetch(url, options = {}) {
|
|
|
323
328
|
}
|
|
324
329
|
}
|
|
325
330
|
|
|
331
|
+
/**
|
|
332
|
+
* Where a navigation went after the URL it was asked for: every HTTP redirect
|
|
333
|
+
* hop in order, then the URL the page stands on when that is somewhere else
|
|
334
|
+
* again (a client-side redirect). Fragments are ignored; they never reach a
|
|
335
|
+
* server.
|
|
336
|
+
*
|
|
337
|
+
* @param {string} landed the page's current URL
|
|
338
|
+
* @param {string} [url] the URL the navigation was asked for
|
|
339
|
+
* @param {import('playwright').Response|null} [response] what `page.goto` returned
|
|
340
|
+
* @returns {string[]}
|
|
341
|
+
*/
|
|
342
|
+
export function navigationHops(landed, url, response) {
|
|
343
|
+
const bare = (value) => {
|
|
344
|
+
try {
|
|
345
|
+
const parsed = new URL(value);
|
|
346
|
+
parsed.hash = '';
|
|
347
|
+
return parsed.href;
|
|
348
|
+
} catch {
|
|
349
|
+
return value;
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
const chain = [];
|
|
353
|
+
for (let request = response?.request?.(); request; request = request.redirectedFrom()) {
|
|
354
|
+
chain.unshift(request.url());
|
|
355
|
+
}
|
|
356
|
+
const hops = chain.slice(1);
|
|
357
|
+
if (/^https?:\/\//i.test(landed) && bare(landed) !== bare(hops.at(-1) ?? url)) hops.push(landed);
|
|
358
|
+
return hops;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* The checks a navigation gets once it has happened: the SSRF guard on where
|
|
363
|
+
* the page landed, and the caller's `onRedirect` gate on every hop that led
|
|
364
|
+
* there.
|
|
365
|
+
*
|
|
366
|
+
* A fetch is refused a redirect before the hop is requested (resignedFetch.js).
|
|
367
|
+
* A browser follows redirects itself and Playwright hands no redirect hop to a
|
|
368
|
+
* route handler, so here the hop has already been requested by the time anyone
|
|
369
|
+
* can ask about it. What is held instead is everything after: a refusal empties
|
|
370
|
+
* the page before it is thrown, so nothing of the refused document can be read,
|
|
371
|
+
* acted on or captured, including by a caller that keeps the page. For the same
|
|
372
|
+
* reason the gate is told the hop is `alreadyRequested`: there is nothing left
|
|
373
|
+
* for a Crawl-delay to space.
|
|
374
|
+
*
|
|
375
|
+
* Call it again with the URL a navigation landed on, and no response, before
|
|
376
|
+
* reading a page that had time to move on by itself.
|
|
377
|
+
*
|
|
378
|
+
* @param {import('playwright').Page} page
|
|
379
|
+
* @param {string} url the URL the navigation was asked for
|
|
380
|
+
* @param {import('playwright').Response|null} [response] what `page.goto` returned
|
|
381
|
+
* @param {(to: string, hop?: { alreadyRequested?: boolean }) => Promise<void>} [onRedirect]
|
|
382
|
+
* refuses a hop by throwing
|
|
383
|
+
* @returns {Promise<string>} the URL the page stood on when it was checked
|
|
384
|
+
*/
|
|
385
|
+
export async function assertNavigationAllowed(page, url, response, onRedirect) {
|
|
386
|
+
try {
|
|
387
|
+
const landedUrl = page.url();
|
|
388
|
+
if (/^https?:\/\//i.test(landedUrl)) {
|
|
389
|
+
await assertUrlAllowed(landedUrl, { resolveDns: true });
|
|
390
|
+
}
|
|
391
|
+
if (onRedirect) {
|
|
392
|
+
for (const hop of navigationHops(landedUrl, url, response)) {
|
|
393
|
+
await onRedirect(hop, { alreadyRequested: true });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return landedUrl;
|
|
397
|
+
} catch (error) {
|
|
398
|
+
await page.goto('about:blank').catch(() => {});
|
|
399
|
+
throw error;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
326
403
|
/**
|
|
327
404
|
* SSRF-safe wrapper around Playwright's `page.goto`. The navigate analog of
|
|
328
405
|
* safeFetch: a browser drives its own DNS resolution, so URL/host checks alone
|
|
329
406
|
* miss DNS-rebinding and private-IP targets — resolveDns:true closes that.
|
|
330
|
-
* Checks the URL before navigating, then re-checks the
|
|
331
|
-
*
|
|
407
|
+
* Checks the URL before navigating, then re-checks where the navigation went
|
|
408
|
+
* (see assertNavigationAllowed), because a redirect could carry us into a
|
|
409
|
+
* blocked range, or past the caller's `onRedirect` gate, even when the
|
|
332
410
|
* original URL was safe. Returns the Response so callers can read the status.
|
|
333
|
-
*
|
|
411
|
+
* Every other option passes through unchanged; behaviour-preserving for
|
|
412
|
+
* allowed URLs.
|
|
334
413
|
*
|
|
335
414
|
* @param {import('playwright').Page} page
|
|
336
415
|
* @param {string} url
|
|
337
|
-
* @param {Parameters<import('playwright').Page['goto']>[1]} [options]
|
|
416
|
+
* @param {Parameters<import('playwright').Page['goto']>[1] & { onRedirect?: (to: string) => Promise<void> }} [options]
|
|
338
417
|
* @returns {Promise<import('playwright').Response|null>}
|
|
339
418
|
*/
|
|
340
419
|
export async function safeGoto(page, url, options = {}) {
|
|
420
|
+
const { onRedirect, ...gotoOptions } = options;
|
|
341
421
|
await assertUrlAllowed(url, { resolveDns: true });
|
|
342
|
-
const response = await page.goto(url,
|
|
343
|
-
|
|
344
|
-
if (/^https?:\/\//i.test(landedUrl)) {
|
|
345
|
-
await assertUrlAllowed(landedUrl, { resolveDns: true });
|
|
346
|
-
}
|
|
422
|
+
const response = await page.goto(url, gotoOptions);
|
|
423
|
+
await assertNavigationAllowed(page, url, response, onRedirect);
|
|
347
424
|
return response;
|
|
348
425
|
}
|
|
349
426
|
|