crawlforge-mcp-server 6.14.0 → 6.17.1

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 (39) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -3
  3. package/server.js +74 -22
  4. package/src/cli/commands/stealth.js +2 -2
  5. package/src/core/ActionExecutor.js +219 -39
  6. package/src/core/AuthManager.js +12 -0
  7. package/src/core/LLMsTxtAnalyzer.js +1 -0
  8. package/src/core/ResearchOrchestrator.js +10 -4
  9. package/src/core/StealthBrowserManager.js +117 -13
  10. package/src/core/browser/actionQueue.js +4 -1
  11. package/src/core/browser/windowState.js +88 -0
  12. package/src/core/crawlers/BFSCrawler.js +12 -3
  13. package/src/core/processing/BrowserProcessor.js +27 -8
  14. package/src/core/processing/PDFProcessor.js +15 -6
  15. package/src/server/fallbackHints.js +7 -4
  16. package/src/server/inlineThreshold.js +81 -19
  17. package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -1
  18. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +25 -6
  19. package/src/tools/advanced/BrowserSessionTool.js +38 -13
  20. package/src/tools/advanced/ScrapeWithActionsTool.js +40 -4
  21. package/src/tools/advanced/batchScrape/worker.js +3 -1
  22. package/src/tools/basic/_fetch.js +3 -1
  23. package/src/tools/crawl/_sessionContext.js +2 -1
  24. package/src/tools/crawl/mapSite.js +2 -1
  25. package/src/tools/extract/_fetchAndParse.js +1 -0
  26. package/src/tools/extract/extractContent.js +2 -1
  27. package/src/tools/extract/extractEmbeddedState.js +320 -71
  28. package/src/tools/extract/processDocument.js +5 -3
  29. package/src/tools/llmstxt/generateLLMsTxt.js +2 -1
  30. package/src/tools/templates/ScrapeTemplateTool.js +11 -3
  31. package/src/tools/tracking/trackChanges/differ.js +1 -0
  32. package/src/utils/complianceAudit.js +35 -5
  33. package/src/utils/impitRung.js +16 -5
  34. package/src/utils/resignedFetch.js +165 -0
  35. package/src/utils/robotsChecker.js +6 -1
  36. package/src/utils/robotsGate.js +124 -11
  37. package/src/utils/secretMask.js +1 -1
  38. package/src/utils/sitemapParser.js +2 -1
  39. package/src/utils/ssrfGuard.js +88 -11
@@ -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) {
@@ -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
- constructor(url) {
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
- `robots.txt on ${new URL(url).host} disallows this path for CrawlForge. ` +
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 call-site helper: run the gate, honour Crawl-delay and any recorded
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
- * @param {string} url
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 {Promise<{ headers: Record<string,string>, userAgent: string,
147
- * warnings: string[], overridden: boolean }>}
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
 
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { redactPii } from 'crawlforge-extractors';
13
13
 
14
- const SECRET_KEYS_RE = /api[_-]?key|apikey|x-api-key|password|passwd|secret|token|authorization|auth|credential|login|formautofill|private[_-]?key|access[_-]?key|proxy_url|proxyurl|cookie/i;
14
+ const SECRET_KEYS_RE = /api[_-]?key|apikey|x-api-key|password|passwd|secret|token|authorization|auth|credential|login|formautofill|private[_-]?key|access[_-]?key|proxy_url|proxyurl|^proxies$|cookie/i;
15
15
 
16
16
  const MASK = '[REDACTED]';
17
17
  const PARTIAL_MASK_LEN = 4; // show last N chars of long secrets
@@ -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) {
@@ -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 fetch(url, { ...options, ...guard });
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 landed URL, because a
331
- * redirect during navigation could carry us into a blocked range even when the
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
- * Options pass through unchanged; behaviour-preserving for allowed URLs.
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, options);
343
- const landedUrl = page.url();
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