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
|
@@ -283,8 +283,9 @@ export class ProcessDocumentTool {
|
|
|
283
283
|
*/
|
|
284
284
|
async processPDFDocument(result, source, sourceType, options, identity = {}) {
|
|
285
285
|
// A remote PDF is a fetch of the target like any other; a local file is not.
|
|
286
|
+
let gate;
|
|
286
287
|
if (sourceType === 'pdf_url') {
|
|
287
|
-
|
|
288
|
+
gate = await preflightFetch(source, {
|
|
288
289
|
respectRobots: identity.respect_robots,
|
|
289
290
|
userAgent: identity.user_agent,
|
|
290
291
|
tool: 'process_document'
|
|
@@ -302,7 +303,7 @@ export class ProcessDocumentTool {
|
|
|
302
303
|
maxPages: options.maxPages,
|
|
303
304
|
...(options.pageRange ? { pageRange: options.pageRange } : {})
|
|
304
305
|
}
|
|
305
|
-
});
|
|
306
|
+
}, gate);
|
|
306
307
|
|
|
307
308
|
if (!pdfResult.success) {
|
|
308
309
|
throw new Error(pdfResult.error || 'PDF processing failed');
|
|
@@ -369,7 +370,7 @@ export class ProcessDocumentTool {
|
|
|
369
370
|
enableImages: false,
|
|
370
371
|
captureScreenshot: false
|
|
371
372
|
}
|
|
372
|
-
});
|
|
373
|
+
}, gate);
|
|
373
374
|
|
|
374
375
|
if (!browserResult.success) {
|
|
375
376
|
throw new Error(`Browser processing failed: ${browserResult.error}`);
|
|
@@ -381,6 +382,7 @@ export class ProcessDocumentTool {
|
|
|
381
382
|
// Simple HTTP fetch
|
|
382
383
|
const response = await safeFetch(source, {
|
|
383
384
|
headers: { ...gate.headers },
|
|
385
|
+
onRedirect: gate.onRedirect,
|
|
384
386
|
signal: AbortSignal.timeout(15000)
|
|
385
387
|
});
|
|
386
388
|
|
|
@@ -380,7 +380,8 @@ export class GenerateLLMsTxtTool {
|
|
|
380
380
|
});
|
|
381
381
|
const response = await safeFetch(baseUrl, {
|
|
382
382
|
signal: controller.signal,
|
|
383
|
-
headers: { ...gate.headers }
|
|
383
|
+
headers: { ...gate.headers },
|
|
384
|
+
onRedirect: gate.onRedirect
|
|
384
385
|
});
|
|
385
386
|
if (!response.ok) return null;
|
|
386
387
|
// Charset-aware read; .text() decodes UTF-8 only.
|
|
@@ -123,10 +123,13 @@ export class ScrapeTemplateTool {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
// Robots gate + per-host politeness before any request to the target.
|
|
126
|
+
// A keyed connector's URL carries the key, and an override writes the URL
|
|
127
|
+
// to the audit log — so the gate is told what to strip from that row.
|
|
126
128
|
const gate = await preflightFetch(fetchUrl, {
|
|
127
129
|
respectRobots: respect_robots,
|
|
128
130
|
userAgent: user_agent,
|
|
129
|
-
tool: 'scrape_template'
|
|
131
|
+
tool: 'scrape_template',
|
|
132
|
+
redactCredential: apiKey
|
|
130
133
|
});
|
|
131
134
|
|
|
132
135
|
// Fetch the page
|
|
@@ -136,7 +139,8 @@ export class ScrapeTemplateTool {
|
|
|
136
139
|
try {
|
|
137
140
|
const response = await safeFetch(fetchUrl, {
|
|
138
141
|
signal: controller.signal,
|
|
139
|
-
headers: { ...gate.headers }
|
|
142
|
+
headers: { ...gate.headers },
|
|
143
|
+
onRedirect: gate.onRedirect
|
|
140
144
|
});
|
|
141
145
|
clearTimeout(timeoutId);
|
|
142
146
|
|
|
@@ -200,7 +204,11 @@ export class ScrapeTemplateTool {
|
|
|
200
204
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
201
205
|
let response;
|
|
202
206
|
try {
|
|
203
|
-
response = await safeFetch(url, {
|
|
207
|
+
response = await safeFetch(url, {
|
|
208
|
+
signal: controller.signal,
|
|
209
|
+
headers: { ...pageGate.headers },
|
|
210
|
+
onRedirect: pageGate.onRedirect
|
|
211
|
+
});
|
|
204
212
|
} finally {
|
|
205
213
|
clearTimeout(timeoutId);
|
|
206
214
|
}
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
* best-effort: an audit sink that throws must never fail a customer's fetch.
|
|
14
14
|
*
|
|
15
15
|
* The API key is never stored. `apiKeyId` is a truncated SHA-256 of it — stable
|
|
16
|
-
* enough to group a key's overrides, useless as a credential.
|
|
16
|
+
* enough to group a key's overrides, useless as a credential. A row's `url` is
|
|
17
|
+
* kept, because the row has to say what was fetched, but not a credential that
|
|
18
|
+
* rode along in it (see `auditUrl`).
|
|
17
19
|
*/
|
|
18
20
|
|
|
19
21
|
import { createHash } from 'crypto';
|
|
@@ -35,6 +37,30 @@ export function apiKeyId(apiKey) {
|
|
|
35
37
|
return createHash('sha256').update(apiKey).digest('hex').slice(0, 16);
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Query parameters whose value is a credential, by name: `token`,
|
|
42
|
+
* `access_token`, `api_key`, `key`, `sig`, `X-Amz-Signature`, `password`,
|
|
43
|
+
* `PHPSESSID`, `authorization`. `author` and `keyword` are not.
|
|
44
|
+
*/
|
|
45
|
+
const CREDENTIAL_PARAM_RE = /token|secret|passw|signature|credential|sess(?:ion|id)|api[_-]?key|access[_-]?key|private[_-]?key|jwt|auth(?!or(?!i[sz]))|^(?:key|sig|sid|pwd|pass)$/i;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A URL as a row may keep it: `user:password@` removed, and the value of every
|
|
49
|
+
* query parameter named like a credential replaced. The path and every other
|
|
50
|
+
* parameter stay as they were sent. Matching is by name, so a credential under
|
|
51
|
+
* a name that says nothing (`?k=`, or one carried in the path) is not caught.
|
|
52
|
+
* @param {string} url
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function auditUrl(url) {
|
|
56
|
+
if (typeof url !== 'string') return url;
|
|
57
|
+
return url
|
|
58
|
+
.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/?#@]*@/i, '$1')
|
|
59
|
+
.replace(/([?&])([^=&#]+)=[^&#]*/g, (pair, separator, name) => (
|
|
60
|
+
CREDENTIAL_PARAM_RE.test(name) ? `${separator}${name}=[redacted]` : pair
|
|
61
|
+
));
|
|
62
|
+
}
|
|
63
|
+
|
|
38
64
|
async function defaultSink(row) {
|
|
39
65
|
await mkdir(dirname(DEFAULT_LOG_PATH), { recursive: true });
|
|
40
66
|
await appendFile(DEFAULT_LOG_PATH, `${JSON.stringify(row)}\n`, 'utf8');
|
|
@@ -46,7 +72,11 @@ async function defaultSink(row) {
|
|
|
46
72
|
* @returns {{ event: string, timestamp: string }} the row as written
|
|
47
73
|
*/
|
|
48
74
|
export function recordComplianceEvent(event = {}) {
|
|
49
|
-
const row = {
|
|
75
|
+
const row = {
|
|
76
|
+
timestamp: new Date().toISOString(),
|
|
77
|
+
...event,
|
|
78
|
+
...(event.url === undefined ? {} : { url: auditUrl(event.url) })
|
|
79
|
+
};
|
|
50
80
|
ring.push(row);
|
|
51
81
|
if (ring.length > RING_SIZE) ring.shift();
|
|
52
82
|
|
|
@@ -60,9 +90,9 @@ export function recordComplianceEvent(event = {}) {
|
|
|
60
90
|
/**
|
|
61
91
|
* Record that a request is about to go out under a browser identity (a stealth
|
|
62
92
|
* render). Neither the raw key nor the raw owner token is stored: both are
|
|
63
|
-
* reduced to apiKeyId digests
|
|
64
|
-
*
|
|
65
|
-
* Never throws.
|
|
93
|
+
* reduced to apiKeyId digests, and the URL is kept as `auditUrl` leaves it.
|
|
94
|
+
* `ownerId` names the website customer behind an internal-proxy request, where
|
|
95
|
+
* `apiKeyId` is always the service's own key. Never throws.
|
|
66
96
|
*/
|
|
67
97
|
export function recordStealthEscalation({ url, tool, engine, apiKey, ownerToken } = {}) {
|
|
68
98
|
return recordComplianceEvent({
|
package/src/utils/impitRung.js
CHANGED
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
*
|
|
23
23
|
* impit resolves DNS itself, outside the undici dispatcher ssrfGuard installs,
|
|
24
24
|
* so redirects are followed here, one hop at a time, and every hop is checked
|
|
25
|
-
* with DNS resolution — the same check safeGoto applies to a navigation
|
|
25
|
+
* with DNS resolution — the same check safeGoto applies to a navigation — and
|
|
26
|
+
* put to the caller's `onRedirect` gate before it is requested.
|
|
26
27
|
*/
|
|
27
28
|
|
|
28
29
|
import { load } from 'cheerio';
|
|
@@ -84,16 +85,19 @@ export async function loadImpit() {
|
|
|
84
85
|
* not (a wall, an error page, an empty shell) or the request failed. A null
|
|
85
86
|
* means "run the browser"; this function never throws for a failed fetch.
|
|
86
87
|
*
|
|
87
|
-
* An SSRF refusal on any hop DOES throw
|
|
88
|
-
* URL, so falling through to it
|
|
88
|
+
* An SSRF refusal on any hop DOES throw, and so does a hop the caller's gate
|
|
89
|
+
* refuses: the browser would be refused the same URL, so falling through to it
|
|
90
|
+
* would only fail slower.
|
|
89
91
|
*
|
|
90
92
|
* @param {string} url
|
|
91
93
|
* @param {object} [options]
|
|
92
94
|
* @param {Function} [options.Impit] the client class (tests inject a fake)
|
|
93
95
|
* @param {number} [options.timeoutMs]
|
|
96
|
+
* @param {(to: string) => Promise<void>} [options.onRedirect] awaited with each
|
|
97
|
+
* redirect target before it is requested (robotsGate.js `redirectGate`)
|
|
94
98
|
* @returns {Promise<{ html: string, text: string, title: string, url: string, status: number, engine: string } | null>}
|
|
95
99
|
*/
|
|
96
|
-
export async function impitFetchPage(url, { Impit, timeoutMs = IMPIT_TIMEOUT_MS } = {}) {
|
|
100
|
+
export async function impitFetchPage(url, { Impit, timeoutMs = IMPIT_TIMEOUT_MS, onRedirect } = {}) {
|
|
97
101
|
const ImpitClass = Impit ?? await loadImpit();
|
|
98
102
|
if (!ImpitClass) return null;
|
|
99
103
|
|
|
@@ -108,7 +112,7 @@ export async function impitFetchPage(url, { Impit, timeoutMs = IMPIT_TIMEOUT_MS
|
|
|
108
112
|
...(proxyUrl ? { proxyUrl } : {})
|
|
109
113
|
});
|
|
110
114
|
|
|
111
|
-
|
|
115
|
+
let deadline = Date.now() + timeoutMs;
|
|
112
116
|
let current = url;
|
|
113
117
|
let response;
|
|
114
118
|
for (let hop = 0; ; hop++) {
|
|
@@ -129,6 +133,13 @@ export async function impitFetchPage(url, { Impit, timeoutMs = IMPIT_TIMEOUT_MS
|
|
|
129
133
|
return null;
|
|
130
134
|
}
|
|
131
135
|
if (!/^https?:\/\//i.test(current)) return null;
|
|
136
|
+
if (onRedirect) {
|
|
137
|
+
// The gate may wait out the next host's Crawl-delay. That is our
|
|
138
|
+
// waiting, not the site being slow, so the deadline moves with it.
|
|
139
|
+
const gateStartedAt = Date.now();
|
|
140
|
+
await onRedirect(current);
|
|
141
|
+
deadline += Date.now() - gateStartedAt;
|
|
142
|
+
}
|
|
132
143
|
}
|
|
133
144
|
|
|
134
145
|
let html;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fetch for a crawl request, following redirects one hop at a time so that
|
|
3
|
+
* every hop is gated and signed for itself.
|
|
4
|
+
*
|
|
5
|
+
* fetch follows a redirect on its own, forwarding the request headers
|
|
6
|
+
* unchanged and asking nobody. That went wrong twice:
|
|
7
|
+
*
|
|
8
|
+
* - The gate ran once, for the URL the caller asked for. A redirect could
|
|
9
|
+
* then land on a path robots.txt disallows, or on a host the platform
|
|
10
|
+
* blocklist refuses, and be fetched anyway.
|
|
11
|
+
* - `preflightFetch` signs once. A same-host redirect arrived with a nonce
|
|
12
|
+
* the site had already seen, which a verifier that tracks nonces rejects as
|
|
13
|
+
* a replay, and a cross-host redirect arrived with a signature over the
|
|
14
|
+
* previous host's `@authority`, which no verifier accepts. Both made a
|
|
15
|
+
* genuine CrawlForge request look forged.
|
|
16
|
+
*
|
|
17
|
+
* A request takes the manual path when it carries a signature or an
|
|
18
|
+
* `onRedirect` gate, and follows redirects. Everything else (webhooks, an
|
|
19
|
+
* explicit `redirect: 'manual'`) is handed to fetch exactly as before.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { signRequestHeaders } from './webBotAuth.js';
|
|
23
|
+
|
|
24
|
+
/** fetch's own limit. */
|
|
25
|
+
const MAX_REDIRECTS = 20;
|
|
26
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
27
|
+
const SIGNATURE_HEADERS = ['signature', 'signature-input', 'signature-agent'];
|
|
28
|
+
/** What fetch drops when a redirect leaves the origin. */
|
|
29
|
+
const CROSS_ORIGIN_HEADERS = ['authorization', 'cookie', 'host', 'proxy-authorization'];
|
|
30
|
+
/** What fetch drops when a redirect turns the request into a GET. */
|
|
31
|
+
const BODY_HEADERS = ['content-encoding', 'content-language', 'content-location', 'content-type', 'content-length'];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve a `Location` header the way fetch does. Some sites send it as raw
|
|
35
|
+
* UTF-8 rather than percent-encoded; the header arrives as a binary string, so
|
|
36
|
+
* those bytes are read back as UTF-8 before the URL is parsed.
|
|
37
|
+
*/
|
|
38
|
+
function locationUrl(location, base) {
|
|
39
|
+
const raw = /[^\x20-\x7e]/.test(location) ? Buffer.from(location, 'latin1').toString('utf8') : location;
|
|
40
|
+
return new URL(raw, base);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The caller's abort signal with the time a hop's gate takes given back.
|
|
45
|
+
*
|
|
46
|
+
* The gate for the URL a caller asked for runs before the caller starts its
|
|
47
|
+
* timeout. The gate for a redirect runs inside it, and may wait out the next
|
|
48
|
+
* host's Crawl-delay: 30 s on a host that asks for it, against a fetch timeout
|
|
49
|
+
* of 15. Charged to the timeout, that wait would fail the fetch as if the site
|
|
50
|
+
* had not answered. So the caller's abort reaches the fetch late by however
|
|
51
|
+
* long the gates have held it, and not while one is still holding it.
|
|
52
|
+
*
|
|
53
|
+
* @param {AbortSignal} [callerSignal]
|
|
54
|
+
* @returns {{ signal?: AbortSignal, gated: <T>(gate: () => Promise<T>) => Promise<T> }}
|
|
55
|
+
* `signal` replaces the caller's on every later hop; `gated` runs a gate
|
|
56
|
+
* with its time counted
|
|
57
|
+
*/
|
|
58
|
+
function gateTimeRefund(callerSignal) {
|
|
59
|
+
if (!callerSignal) return { gated: (gate) => gate() };
|
|
60
|
+
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
let gateMs = 0;
|
|
63
|
+
let inGate = false;
|
|
64
|
+
let abortedAt = null;
|
|
65
|
+
let timer;
|
|
66
|
+
|
|
67
|
+
const deliver = () => {
|
|
68
|
+
if (abortedAt === null || inGate) return;
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
timer = setTimeout(
|
|
71
|
+
() => controller.abort(callerSignal.reason),
|
|
72
|
+
Math.max(0, abortedAt + gateMs - Date.now())
|
|
73
|
+
);
|
|
74
|
+
// A fetch that finished long ago must not keep the process up for this.
|
|
75
|
+
timer.unref?.();
|
|
76
|
+
};
|
|
77
|
+
const onAbort = () => {
|
|
78
|
+
abortedAt = Date.now();
|
|
79
|
+
deliver();
|
|
80
|
+
};
|
|
81
|
+
if (callerSignal.aborted) onAbort();
|
|
82
|
+
else callerSignal.addEventListener('abort', onAbort, { once: true });
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
signal: controller.signal,
|
|
86
|
+
async gated(gate) {
|
|
87
|
+
const startedAt = Date.now();
|
|
88
|
+
inGate = true;
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
try {
|
|
91
|
+
return await gate();
|
|
92
|
+
} finally {
|
|
93
|
+
inGate = false;
|
|
94
|
+
gateMs += Date.now() - startedAt;
|
|
95
|
+
deliver();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {string} url
|
|
103
|
+
* @param {RequestInit & { onRedirect?: (to: string) => Promise<void> }} [options]
|
|
104
|
+
* `onRedirect` is awaited with each redirect target before it is requested;
|
|
105
|
+
* it refuses the hop by throwing. The time it takes is not charged to
|
|
106
|
+
* `signal` (see {@link gateTimeRefund}).
|
|
107
|
+
* @returns {Promise<Response>}
|
|
108
|
+
*/
|
|
109
|
+
export async function fetchResigned(url, options = {}) {
|
|
110
|
+
const { onRedirect, ...fetchOptions } = options;
|
|
111
|
+
const signed = new Headers(fetchOptions.headers).has('signature-input');
|
|
112
|
+
if ((!signed && !onRedirect) || (fetchOptions.redirect ?? 'follow') !== 'follow') {
|
|
113
|
+
return fetch(url, fetchOptions);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let current = String(url);
|
|
117
|
+
// The first hop goes out with the caller's headers and signal untouched.
|
|
118
|
+
let init = { ...fetchOptions, redirect: 'manual' };
|
|
119
|
+
let refund = null;
|
|
120
|
+
|
|
121
|
+
for (let hop = 0; ; hop++) {
|
|
122
|
+
const response = await fetch(current, init);
|
|
123
|
+
const location = REDIRECT_STATUSES.has(response.status) ? response.headers.get('location') : null;
|
|
124
|
+
if (!location) {
|
|
125
|
+
if (hop > 0) Object.defineProperty(response, 'redirected', { value: true });
|
|
126
|
+
return response;
|
|
127
|
+
}
|
|
128
|
+
// Released, not awaited: the next hop must not wait on a body nobody reads.
|
|
129
|
+
response.body?.cancel().catch(() => {});
|
|
130
|
+
if (hop >= MAX_REDIRECTS) throw new TypeError('fetch failed: redirect count exceeded');
|
|
131
|
+
|
|
132
|
+
const next = locationUrl(location, current);
|
|
133
|
+
if (next.protocol !== 'http:' && next.protocol !== 'https:') {
|
|
134
|
+
throw new TypeError('fetch failed: redirect to a URL that is not http(s)');
|
|
135
|
+
}
|
|
136
|
+
if (onRedirect) {
|
|
137
|
+
refund ??= gateTimeRefund(fetchOptions.signal);
|
|
138
|
+
await refund.gated(() => onRedirect(next.href));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const headers = new Headers(init.headers);
|
|
142
|
+
if (next.origin !== new URL(current).origin) {
|
|
143
|
+
for (const name of CROSS_ORIGIN_HEADERS) headers.delete(name);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const method = (init.method ?? 'GET').toUpperCase();
|
|
147
|
+
const becomesGet = response.status === 303
|
|
148
|
+
? method !== 'GET' && method !== 'HEAD'
|
|
149
|
+
: method === 'POST' && (response.status === 301 || response.status === 302);
|
|
150
|
+
if (becomesGet) {
|
|
151
|
+
for (const name of BODY_HEADERS) headers.delete(name);
|
|
152
|
+
init = { ...init, method: 'GET', body: undefined };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (signed) {
|
|
156
|
+
for (const name of SIGNATURE_HEADERS) headers.delete(name);
|
|
157
|
+
for (const [name, value] of Object.entries(signRequestHeaders(next.href) ?? {})) {
|
|
158
|
+
headers.set(name, value);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
init = { ...init, headers, ...(refund?.signal ? { signal: refund.signal } : {}) };
|
|
163
|
+
current = next.href;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import robotsParser from 'robots-parser';
|
|
2
2
|
import { safeFetch } from './ssrfGuard.js';
|
|
3
3
|
import { identityHeaders, CRAWLFORGE_USER_AGENT } from './fetchIdentity.js';
|
|
4
|
+
import { signRequestHeaders } from './webBotAuth.js';
|
|
5
|
+
import { assertHostAllowed } from './hostBlocklist.js';
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* The token this crawler used to identify as, honoured as a source of disallow
|
|
@@ -88,7 +90,10 @@ export class RobotsChecker {
|
|
|
88
90
|
try {
|
|
89
91
|
const response = await safeFetch(robotsUrl, {
|
|
90
92
|
signal: controller.signal,
|
|
91
|
-
headers: identityHeaders({ userAgent: this.userAgent })
|
|
93
|
+
headers: { ...identityHeaders({ userAgent: this.userAgent }), ...signRequestHeaders(robotsUrl) },
|
|
94
|
+
// robots.txt is not itself subject to robots.txt, but a host on the
|
|
95
|
+
// platform blocklist is never requested, by redirect or otherwise.
|
|
96
|
+
onRedirect: async (to) => assertHostAllowed(to)
|
|
92
97
|
});
|
|
93
98
|
|
|
94
99
|
if (!response.ok) {
|
package/src/utils/robotsGate.js
CHANGED
|
@@ -28,11 +28,31 @@ import { markPreflightRefusal, internalOwnerToken } from '../server/requestConte
|
|
|
28
28
|
import AuthManager from '../core/AuthManager.js';
|
|
29
29
|
import { config } from '../constants/config.js';
|
|
30
30
|
|
|
31
|
+
/** A URL as it may appear in an error: no query string, which can carry a key. */
|
|
32
|
+
function withoutQuery(url) {
|
|
33
|
+
const parsed = new URL(url);
|
|
34
|
+
return `${parsed.origin}${parsed.pathname}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
31
37
|
export class RobotsDisallowedError extends Error {
|
|
32
|
-
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} url the URL robots.txt disallows
|
|
40
|
+
* @param {{ redirectedFrom?: string, movedFrom?: string }} [options] set when
|
|
41
|
+
* `url` is not what the caller asked for, so the message says how it was
|
|
42
|
+
* reached: `redirectedFrom` when a redirect led there, `movedFrom` when a
|
|
43
|
+
* browser page went there by itself (a click, a form, a script)
|
|
44
|
+
*/
|
|
45
|
+
constructor(url, { redirectedFrom, movedFrom } = {}) {
|
|
46
|
+
const host = new URL(url).host;
|
|
47
|
+
let reached = `robots.txt on ${host} disallows this path for CrawlForge. `;
|
|
48
|
+
if (redirectedFrom) {
|
|
49
|
+
reached = `${withoutQuery(redirectedFrom)} redirects to ${withoutQuery(url)}, and robots.txt on ${host} disallows that path for CrawlForge. `;
|
|
50
|
+
} else if (movedFrom) {
|
|
51
|
+
reached = `The page moved from ${withoutQuery(movedFrom)} to ${withoutQuery(url)}, and robots.txt on ${host} disallows that path for CrawlForge. `;
|
|
52
|
+
}
|
|
33
53
|
super(
|
|
34
|
-
|
|
35
|
-
`Pass respect_robots: false to fetch it anyway — that override is recorded ` +
|
|
54
|
+
reached +
|
|
55
|
+
`Pass respect_robots: false to ${movedFrom ? 'use' : 'fetch'} it anyway — that override is recorded ` +
|
|
36
56
|
`against your API key and is your decision to make.`
|
|
37
57
|
);
|
|
38
58
|
this.name = 'RobotsDisallowedError';
|
|
@@ -41,6 +61,40 @@ export class RobotsDisallowedError extends Error {
|
|
|
41
61
|
}
|
|
42
62
|
}
|
|
43
63
|
|
|
64
|
+
/** What a refusal carries as `code`: this gate's three, and the SSRF guard's. */
|
|
65
|
+
const REFUSAL_CODES = new Set(['ROBOTS_DISALLOWED', 'HOST_BLOCKED', 'USE_REDDIT_SEARCH', 'SSRF_BLOCKED']);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The refusal code an error carries, or null when it is a failure rather than
|
|
69
|
+
* a refusal. A refusal is this gate or the SSRF guard saying no: asking again
|
|
70
|
+
* gets the same answer, so nothing should retry it.
|
|
71
|
+
* @param {unknown} error
|
|
72
|
+
* @returns {string|null}
|
|
73
|
+
*/
|
|
74
|
+
export function gateRefusalCode(error) {
|
|
75
|
+
for (const code of [error?.code, error?.cause?.code]) {
|
|
76
|
+
if (REFUSAL_CODES.has(code)) return code;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Remove a known deployment credential from a URL bound for the audit log: the
|
|
83
|
+
* raw value and both encodings of it. `URLSearchParams` escapes `~ ! ' ( )` and
|
|
84
|
+
* writes a space as `+`; `encodeURIComponent` does neither.
|
|
85
|
+
*/
|
|
86
|
+
function redactSecret(value, secret) {
|
|
87
|
+
if (!secret) return value;
|
|
88
|
+
const forms = new Set([
|
|
89
|
+
secret,
|
|
90
|
+
encodeURIComponent(secret),
|
|
91
|
+
new URLSearchParams({ s: secret }).toString().slice(2)
|
|
92
|
+
]);
|
|
93
|
+
let redacted = value;
|
|
94
|
+
for (const form of forms) redacted = redacted.replaceAll(form, '[redacted]');
|
|
95
|
+
return redacted;
|
|
96
|
+
}
|
|
97
|
+
|
|
44
98
|
/**
|
|
45
99
|
* One checker per identity, so the robots cache is process-wide rather than
|
|
46
100
|
* per-tool — otherwise every tool would re-fetch the same robots.txt.
|
|
@@ -68,6 +122,10 @@ function checkerFor(userAgent) {
|
|
|
68
122
|
* @param {string} [options.userAgent] per-request identity override
|
|
69
123
|
* @param {string} [options.tool] tool name, for the audit row
|
|
70
124
|
* @param {string} [options.apiKey] hashed into the audit row, never stored raw
|
|
125
|
+
* @param {string} [options.redactCredential] a deployment credential that
|
|
126
|
+
* appears in `url` (a keyed connector puts its key in the query string), to
|
|
127
|
+
* remove from the audit row. The row keeps the URL, because it has to say
|
|
128
|
+
* what was fetched, and loses the secret.
|
|
71
129
|
* @returns {Promise<{ allowed: boolean, userAgent: string, crawlDelayMs: number,
|
|
72
130
|
* warnings: string[], overridden: boolean }>}
|
|
73
131
|
* @throws {BlockedHostError} for a permanently blocked host
|
|
@@ -119,7 +177,7 @@ export async function robotsPreflight(url, options = {}) {
|
|
|
119
177
|
const ownerToken = internalOwnerToken();
|
|
120
178
|
recordComplianceEvent({
|
|
121
179
|
event: 'robots_override',
|
|
122
|
-
url,
|
|
180
|
+
url: redactSecret(url, options.redactCredential),
|
|
123
181
|
tool: options.tool || null,
|
|
124
182
|
apiKeyId: apiKeyId(options.apiKey ?? AuthManager.getConfig()?.apiKey),
|
|
125
183
|
...(ownerToken ? { ownerId: apiKeyId(ownerToken) } : {}),
|
|
@@ -138,15 +196,57 @@ export async function robotsPreflight(url, options = {}) {
|
|
|
138
196
|
}
|
|
139
197
|
|
|
140
198
|
/**
|
|
141
|
-
* The
|
|
142
|
-
* `Retry-After`, and hand back the identity headers to send.
|
|
199
|
+
* The gate for a redirect hop, to pass to the fetch as `onRedirect`.
|
|
143
200
|
*
|
|
144
|
-
*
|
|
201
|
+
* The gate above decides about the URL the caller asked for; a redirect can
|
|
202
|
+
* lead anywhere. Without this a 301 into a path robots.txt disallows, or onto
|
|
203
|
+
* a host on the platform blocklist, was fetched because nobody asked again.
|
|
204
|
+
* Each hop gets the decision a first request would: blocklist, then robots,
|
|
205
|
+
* with the caller's own `respect_robots` and an audit row when it overrides.
|
|
206
|
+
*
|
|
207
|
+
* It then waits its turn as a first request would: the hop is one more request
|
|
208
|
+
* to its host, so it is spaced by that host's Crawl-delay, by a Retry-After it
|
|
209
|
+
* has sent and by our own per-host limit. A fetch does not charge that wait to
|
|
210
|
+
* its timeout (resignedFetch.js). A browser has made the request before anyone
|
|
211
|
+
* could be asked, so its caller passes `alreadyRequested` and nothing is
|
|
212
|
+
* slept: there is no request left to space.
|
|
213
|
+
*
|
|
214
|
+
* @param {string} from the URL the fetch started at, for the refusal message
|
|
145
215
|
* @param {object} [options] see {@link robotsPreflight}
|
|
146
|
-
* @returns {
|
|
147
|
-
*
|
|
148
|
-
* @throws {BlockedHostError|RobotsDisallowedError}
|
|
216
|
+
* @returns {(to: string, hop?: { alreadyRequested?: boolean }) => Promise<void>}
|
|
217
|
+
* throws BlockedHostError or RobotsDisallowedError to refuse the hop
|
|
149
218
|
*/
|
|
219
|
+
export function redirectGate(from, options = {}) {
|
|
220
|
+
return hopGate(options, { redirectedFrom: from });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The gate for a URL a browser page reached without being sent there: a click
|
|
225
|
+
* followed a link, a form posted, a script redirected, a client-side router
|
|
226
|
+
* rewrote the address. The decision is {@link redirectGate}'s; only the
|
|
227
|
+
* refusal reads differently, because nothing redirected.
|
|
228
|
+
*
|
|
229
|
+
* @param {string} [from] the last URL the page was checked at
|
|
230
|
+
* @param {object} [options] see {@link robotsPreflight}
|
|
231
|
+
* @returns {(to: string, hop?: { alreadyRequested?: boolean }) => Promise<void>}
|
|
232
|
+
* throws BlockedHostError or RobotsDisallowedError to refuse where the page
|
|
233
|
+
* now stands
|
|
234
|
+
*/
|
|
235
|
+
export function pageMoveGate(from, options = {}) {
|
|
236
|
+
return hopGate(options, { movedFrom: from });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function hopGate(options, reached) {
|
|
240
|
+
return async (to, { alreadyRequested = false } = {}) => {
|
|
241
|
+
const decision = await robotsPreflight(to, options);
|
|
242
|
+
if (!decision.allowed) {
|
|
243
|
+
markPreflightRefusal('ROBOTS_DISALLOWED');
|
|
244
|
+
throw new RobotsDisallowedError(to, reached);
|
|
245
|
+
}
|
|
246
|
+
if (!alreadyRequested) await throttleHost(to, { crawlDelayMs: decision.crawlDelayMs });
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
150
250
|
/**
|
|
151
251
|
* The headers an HTTP page fetch carries: identity, the Web Bot Auth signature
|
|
152
252
|
* when one is configured, and an EMPTY Accept-Language.
|
|
@@ -170,6 +270,18 @@ export function outboundHeaders(userAgent, signature = {}) {
|
|
|
170
270
|
};
|
|
171
271
|
}
|
|
172
272
|
|
|
273
|
+
/**
|
|
274
|
+
* The call-site helper: run the gate, honour Crawl-delay and any recorded
|
|
275
|
+
* `Retry-After`, and hand back the identity headers to send and the gate for
|
|
276
|
+
* any redirect the fetch follows. Pass both to the fetch.
|
|
277
|
+
*
|
|
278
|
+
* @param {string} url
|
|
279
|
+
* @param {object} [options] see {@link robotsPreflight}
|
|
280
|
+
* @returns {Promise<{ headers: Record<string,string>, userAgent: string,
|
|
281
|
+
* warnings: string[], overridden: boolean,
|
|
282
|
+
* onRedirect: (to: string) => Promise<void> }>}
|
|
283
|
+
* @throws {BlockedHostError|RobotsDisallowedError}
|
|
284
|
+
*/
|
|
173
285
|
export async function preflightFetch(url, options = {}) {
|
|
174
286
|
const decision = await robotsPreflight(url, options);
|
|
175
287
|
if (!decision.allowed) {
|
|
@@ -191,7 +303,8 @@ export async function preflightFetch(url, options = {}) {
|
|
|
191
303
|
headers: outboundHeaders(decision.userAgent, signature),
|
|
192
304
|
userAgent: decision.userAgent,
|
|
193
305
|
warnings: [...decision.warnings, ...crawlDelayWarning(url, decision.crawlDelayMs)],
|
|
194
|
-
overridden: decision.overridden
|
|
306
|
+
overridden: decision.overridden,
|
|
307
|
+
onRedirect: redirectGate(url, options)
|
|
195
308
|
};
|
|
196
309
|
}
|
|
197
310
|
|
|
@@ -679,7 +679,8 @@ export class SitemapParser {
|
|
|
679
679
|
...gate.headers,
|
|
680
680
|
'Accept': 'application/xml,text/xml,text/plain,*/*',
|
|
681
681
|
'Accept-Encoding': 'gzip, deflate'
|
|
682
|
-
}
|
|
682
|
+
},
|
|
683
|
+
onRedirect: gate.onRedirect
|
|
683
684
|
});
|
|
684
685
|
clearTimeout(timeoutId);
|
|
685
686
|
if (response.status === 429 || response.status === 503) {
|