@lincoln504/pi-research 1.3.8 → 1.3.10
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 +7 -7
- package/dist/cli.mjs +105 -58
- package/dist/thread-worker.mjs +69 -31
- package/docs/ARCHITECTURE.md +9 -7
- package/docs/CHANGELOG.md +69 -0
- package/docs/CONFIGURATION.md +3 -1
- package/package.json +1 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +5 -1
- package/src/config.ts +12 -0
- package/src/constants.ts +8 -17
- package/src/core/pi-version.ts +2 -2
- package/src/index.ts +5 -3
- package/src/infrastructure/browser/thread-worker.mjs +69 -31
- package/src/knowledge/embedder-utils.ts +19 -9
- package/src/orchestration/quick-research-orchestrator.ts +6 -6
- package/src/orchestration/researcher-executor.ts +8 -6
- package/src/orchestration/researcher.ts +13 -2
- package/src/utils/log-utils.ts +45 -13
- package/src/utils/user-agent.ts +26 -15
- package/src/web-research/scraper-utils.ts +70 -12
- package/src/web-research/web-scraper.ts +19 -9
package/src/utils/log-utils.ts
CHANGED
|
@@ -84,41 +84,73 @@ const MAX_LOG_MESSAGE_LENGTH = 10_000;
|
|
|
84
84
|
// separately for the console). Matching the ESC control byte is the point here.
|
|
85
85
|
// eslint-disable-next-line no-control-regex
|
|
86
86
|
const ANSI_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g;
|
|
87
|
-
// Credentials embedded in a URL userinfo component: scheme://user:pass@host
|
|
88
|
-
|
|
87
|
+
// Credentials embedded in a URL userinfo component: scheme://user:pass@host.
|
|
88
|
+
//
|
|
89
|
+
// The scheme is length-bounded, and that bound is load-bearing rather than
|
|
90
|
+
// cosmetic. With an unbounded `[a-z0-9+.-]*`, every position inside a long
|
|
91
|
+
// alphanumeric run greedily consumes the rest of the input and then backtracks
|
|
92
|
+
// one character at a time looking for `://` — quadratic in the input length. On
|
|
93
|
+
// the 40_000-character window redactSecrets scans that measured ~1.4 SECONDS of
|
|
94
|
+
// blocked event loop per call, on a function that runs for every single log
|
|
95
|
+
// message, over content that is largely untrusted (scraped pages, provider error
|
|
96
|
+
// bodies): a page carrying one long unbroken token was enough to trigger it. A
|
|
97
|
+
// bounded scheme makes each start position O(32) instead of O(n) — same matches
|
|
98
|
+
// (a longer run simply starts matching nearer the `://`), ~215x faster measured.
|
|
99
|
+
// RFC 3986 schemes are short; no registered scheme approaches 32 characters.
|
|
100
|
+
const URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]{0,31}:\/\/)[^/\s:@]+:[^/\s:@]+@/gi;
|
|
101
|
+
// Left/right boundaries for secret key names and opaque token formats. `\b`
|
|
102
|
+
// is WRONG here: `_` is a word character, so `\b` finds no boundary between the
|
|
103
|
+
// prefix and the secret in the single most common credential shape there is —
|
|
104
|
+
// an env var like PI_RESEARCH_API_KEY=…, OPENAI_API_KEY=…, DB_PASSWORD=…. Every
|
|
105
|
+
// such line was written to the log in the clear while the unprefixed `api_key=`
|
|
106
|
+
// was masked. Treat `_` (and any other non-alphanumeric) as a boundary, so a
|
|
107
|
+
// prefixed name matches while a name merely CONTAINING the word does not
|
|
108
|
+
// ("monkey=1" must not read as "key=1").
|
|
109
|
+
const B_LEFT = '(?<![A-Za-z0-9])';
|
|
110
|
+
const B_RIGHT = '(?![A-Za-z0-9])';
|
|
89
111
|
// Cookie / Set-Cookie headers. The KV value class below stops at whitespace, so
|
|
90
112
|
// "Cookie: lang=en; SID=secret123" would mask only the first pair and leave every
|
|
91
113
|
// later one exposed. A cookie string is a ;-and-space separated list of secrets,
|
|
92
114
|
// not a single token — mask the ENTIRE header value to end-of-line.
|
|
93
|
-
const COOKIE_HEADER_PATTERN =
|
|
115
|
+
const COOKIE_HEADER_PATTERN = new RegExp(
|
|
116
|
+
`${B_LEFT}(set[_-]?cookie|cookie)${B_RIGHT}(["']?\\s*[:=]\\s*["']?)([^\\r\\n]+)`,
|
|
117
|
+
'gi',
|
|
118
|
+
);
|
|
94
119
|
// key=value / key: "value" pairs whose key names a secret. Includes bare
|
|
95
120
|
// `key` (not just `api_key`/`private_key`): several provider query-param
|
|
96
121
|
// APIs (e.g. StackExchange's `?key=<apikey>`) name the credential parameter
|
|
97
122
|
// with no qualifying prefix. Over-redacting an unrelated "key" field (a
|
|
98
123
|
// sort key, a map key) is the accepted trade-off — same one already made for
|
|
99
124
|
// the equally generic bare `session`/`cookie` entries below.
|
|
100
|
-
const SENSITIVE_KV_PATTERN =
|
|
101
|
-
|
|
125
|
+
const SENSITIVE_KV_PATTERN = new RegExp(
|
|
126
|
+
`${B_LEFT}(api[_-]?key|apikey|access[_-]?token|auth[_-]?token|refresh[_-]?token|secret|client[_-]?secret|password|passwd|pwd|authorization|bearer|set[_-]?cookie|cookie|session[_-]?id|session|csrf[_-]?token|xsrf[_-]?token|private[_-]?key|key)${B_RIGHT}` +
|
|
127
|
+
`(["']?\\s*[:=]\\s*["']?)([^\\s"',&)]+)`,
|
|
128
|
+
'gi',
|
|
129
|
+
);
|
|
102
130
|
// Well-known opaque credential formats (OpenAI, GitHub, AWS, Slack, Anthropic).
|
|
103
|
-
const KNOWN_TOKEN_PATTERN =
|
|
104
|
-
|
|
131
|
+
const KNOWN_TOKEN_PATTERN = new RegExp(
|
|
132
|
+
`${B_LEFT}(sk-[A-Za-z0-9_-]{16,}|gh[posru]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,})${B_RIGHT}`,
|
|
133
|
+
'g',
|
|
134
|
+
);
|
|
105
135
|
// JSON Web Tokens — header.payload.signature, each a base64url segment.
|
|
106
|
-
const JWT_PATTERN =
|
|
136
|
+
const JWT_PATTERN = new RegExp(`${B_LEFT}eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+`, 'g');
|
|
107
137
|
// HTTP Basic credentials: "Basic <base64>".
|
|
108
|
-
const BASIC_AUTH_PATTERN =
|
|
138
|
+
const BASIC_AUTH_PATTERN = new RegExp(`${B_LEFT}Basic\\s+[A-Za-z0-9+/]{16,}={0,2}`, 'gi');
|
|
109
139
|
// HTTP Bearer credentials: "Bearer <token>". The token is often opaque — many
|
|
110
140
|
// providers (e.g. GLM/zhipuai) use dotted keys that are NOT JWTs and carry no
|
|
111
141
|
// known prefix, so KNOWN_TOKEN_PATTERN/JWT_PATTERN miss them and the KV pass
|
|
112
142
|
// only masks the word "Bearer" (its value class stops at the space). Match the
|
|
113
143
|
// scheme and redact the whole RFC 6750 b64token that follows, whatever its shape.
|
|
114
|
-
const BEARER_AUTH_PATTERN =
|
|
144
|
+
const BEARER_AUTH_PATTERN = new RegExp(`${B_LEFT}Bearer\\s+[A-Za-z0-9._~+/-]+=*`, 'gi');
|
|
115
145
|
// Provider keys not covered by KNOWN_TOKEN_PATTERN: Google API/OAuth, Stripe.
|
|
116
|
-
const PROVIDER_TOKEN_PATTERN =
|
|
117
|
-
|
|
146
|
+
const PROVIDER_TOKEN_PATTERN = new RegExp(
|
|
147
|
+
`${B_LEFT}(AIza[0-9A-Za-z_-]{35}|ya29\\.[0-9A-Za-z_-]{20,}|[sp]k_(?:live|test)_[0-9A-Za-z]{16,})${B_RIGHT}`,
|
|
148
|
+
'g',
|
|
149
|
+
);
|
|
118
150
|
// Long opaque hex secrets (>=32 hex chars), e.g. the 64-hex browser auth secret
|
|
119
151
|
// or MD5/SHA-style tokens. Diagnostic logs may over-redact content hashes of
|
|
120
152
|
// this length; that trade-off favors not persisting credentials clear-text.
|
|
121
|
-
const LONG_HEX_SECRET_PATTERN =
|
|
153
|
+
const LONG_HEX_SECRET_PATTERN = new RegExp(`${B_LEFT}[0-9a-fA-F]{32,}${B_RIGHT}`, 'g');
|
|
122
154
|
// All C0 control characters plus DEL — newlines/carriage returns are the log/
|
|
123
155
|
// terminal-injection vector when written to a raw console line. Matching these
|
|
124
156
|
// control bytes is exactly the intent.
|
package/src/utils/user-agent.ts
CHANGED
|
@@ -1,28 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Realistic User-Agent Strings for Browser Rotation
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Sent by the FETCH scrape path (web-scraper.ts); the stealth browser path
|
|
5
|
+
* supplies its own fingerprint-consistent UA via camoufox.
|
|
6
|
+
*
|
|
7
|
+
* These have a shelf life. Both browsers ship a major version roughly every
|
|
8
|
+
* four weeks, and a UA more than a few majors behind stable is itself a
|
|
9
|
+
* bot signal — the exact thing rotating them is meant to avoid. Refresh the
|
|
10
|
+
* two Chrome and two Firefox majors below when they drift; keep the platform
|
|
11
|
+
* tokens as-is (Chrome freezes the macOS token at 10_15_7 and zeroes the
|
|
12
|
+
* minor version triplet, Firefox mirrors its major in both `rv:` and the
|
|
13
|
+
* trailing `Firefox/` token).
|
|
14
|
+
*
|
|
15
|
+
* Last refreshed 2026-08-14 against Chrome 151 stable (Chrome Releases,
|
|
16
|
+
* 2026-07-28) and Firefox 153 stable (firefox.com release notes, 2026-08-11).
|
|
6
17
|
*/
|
|
7
18
|
|
|
8
19
|
export const USER_AGENTS = [
|
|
9
|
-
// Chrome on Windows
|
|
10
|
-
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
11
|
-
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
20
|
+
// Chrome on Windows
|
|
21
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36',
|
|
22
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
|
|
12
23
|
|
|
13
24
|
// Chrome on macOS
|
|
14
|
-
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
15
|
-
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
25
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36',
|
|
26
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
|
|
16
27
|
|
|
17
28
|
// Chrome on Linux
|
|
18
|
-
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
19
|
-
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
29
|
+
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36',
|
|
30
|
+
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
|
|
20
31
|
|
|
21
|
-
// Firefox on Windows
|
|
22
|
-
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:
|
|
23
|
-
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:
|
|
32
|
+
// Firefox on Windows
|
|
33
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:153.0) Gecko/20100101 Firefox/153.0',
|
|
34
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0',
|
|
24
35
|
|
|
25
36
|
// Firefox on macOS
|
|
26
|
-
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:
|
|
27
|
-
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:
|
|
28
|
-
];
|
|
37
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:153.0) Gecko/20100101 Firefox/153.0',
|
|
38
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0',
|
|
39
|
+
];
|
|
@@ -250,27 +250,73 @@ export function ssrfSafeLookup(
|
|
|
250
250
|
});
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
253
|
+
/**
|
|
254
|
+
* True when the error is one of our SSRF blocks — request-time validation
|
|
255
|
+
* (message contains "not allowed") or the connect-time DNS pin (`ESSRFBLOCKED`).
|
|
256
|
+
*
|
|
257
|
+
* The connect-time error never surfaces directly: undici wraps it in
|
|
258
|
+
* `TypeError: fetch failed` with the real error down the `cause` chain, so a
|
|
259
|
+
* message check on the outer error alone files a connect-time block as a generic
|
|
260
|
+
* fetch error. Walk the chain (bounded — causes can be cyclic) instead.
|
|
261
|
+
*/
|
|
262
|
+
export function isSsrfBlockError(error: unknown): boolean {
|
|
263
|
+
let e: unknown = error;
|
|
264
|
+
for (let depth = 0; depth < 5 && e instanceof Error; depth++) {
|
|
265
|
+
if ((e as NodeJS.ErrnoException).code === 'ESSRFBLOCKED' || e.message.includes('not allowed')) {
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
e = (e as { cause?: unknown }).cause;
|
|
269
|
+
}
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** A fetch-shaped callable plus the dispatcher it must be invoked with. */
|
|
274
|
+
export interface SsrfSafeFetcher {
|
|
275
|
+
/** undici's own fetch — NOT the global one. See getSsrfSafeFetcher. */
|
|
276
|
+
fetch: (url: string, init: Record<string, unknown>) => Promise<Response>;
|
|
277
|
+
dispatcher: unknown;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Cached after first construction. `undefined` = not yet attempted, `null` =
|
|
281
|
+
// undici unavailable (fall back to the global fetch with request-time
|
|
282
|
+
// validation only).
|
|
283
|
+
let cachedSsrfFetcher: SsrfSafeFetcher | null | undefined = undefined;
|
|
257
284
|
|
|
258
285
|
/**
|
|
259
286
|
* Build (once) an undici Agent whose connector resolves via ssrfSafeLookup, so
|
|
260
287
|
* every scrape fetch — including each manually-followed redirect hop — connects
|
|
261
|
-
* only to a validated public address
|
|
262
|
-
*
|
|
288
|
+
* only to a validated public address, PAIRED with the fetch implementation that
|
|
289
|
+
* can actually accept it.
|
|
290
|
+
*
|
|
291
|
+
* The pairing is the whole point. A dispatcher is only honoured by the undici
|
|
292
|
+
* that created it: Node's global `fetch` is backed by the undici BUILT INTO the
|
|
293
|
+
* runtime (6.21.0 on Node 22, 7.24.4 on Node 25), while this package depends on
|
|
294
|
+
* undici 8.x. Handing a v8 `Agent` to the global fetch throws
|
|
295
|
+
* `UND_ERR_INVALID_ARG: invalid onRequestStart method` on every request, on
|
|
296
|
+
* every supported Node — which is exactly what it did: the entire fetch scrape
|
|
297
|
+
* path failed instantly, every URL was retried once and then fell back to the
|
|
298
|
+
* stealth browser (~4.5s per page against ~250ms), and the connect-time SSRF pin
|
|
299
|
+
* this dispatcher exists for never engaged even once. The browser fallback made
|
|
300
|
+
* it invisible: scrapes still succeeded, just 15-20x slower.
|
|
301
|
+
*
|
|
302
|
+
* Returning the two together makes the invalid combination unrepresentable —
|
|
303
|
+
* a caller cannot end up holding a dispatcher without the fetch that honours it.
|
|
304
|
+
* Returns null if undici can't be loaded; callers then use the global fetch with
|
|
305
|
+
* no dispatcher (request-time validation still applies).
|
|
263
306
|
*/
|
|
264
|
-
export async function
|
|
265
|
-
if (
|
|
307
|
+
export async function getSsrfSafeFetcher(): Promise<SsrfSafeFetcher | null> {
|
|
308
|
+
if (cachedSsrfFetcher !== undefined) return cachedSsrfFetcher;
|
|
266
309
|
try {
|
|
267
|
-
const { Agent } = await import('undici');
|
|
268
|
-
|
|
310
|
+
const { Agent, fetch: undiciFetch } = await import('undici');
|
|
311
|
+
cachedSsrfFetcher = {
|
|
312
|
+
fetch: undiciFetch as unknown as SsrfSafeFetcher['fetch'],
|
|
313
|
+
dispatcher: new Agent({ connect: { lookup: ssrfSafeLookup } }),
|
|
314
|
+
};
|
|
269
315
|
} catch (e) {
|
|
270
316
|
logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
|
|
271
|
-
|
|
317
|
+
cachedSsrfFetcher = null;
|
|
272
318
|
}
|
|
273
|
-
return
|
|
319
|
+
return cachedSsrfFetcher;
|
|
274
320
|
}
|
|
275
321
|
|
|
276
322
|
/**
|
|
@@ -379,6 +425,18 @@ function isPrivateIpv6(ip: string): boolean {
|
|
|
379
425
|
// metadata service) — block the whole prefix regardless of what it wraps,
|
|
380
426
|
// same rationale as the 6to4/Teredo ranges just above.
|
|
381
427
|
if (normalized.startsWith('64:ff9b:')) return true;
|
|
428
|
+
// IPv4-COMPATIBLE ::/96 (RFC 4291 §2.5.5.1, deprecated) — the sibling of the
|
|
429
|
+
// ::ffff:0:0/96 mapped range decoded above, and the last IPv4-in-IPv6 form
|
|
430
|
+
// left open. `http://[::127.0.0.1]/` normalizes to `[::7f00:1]`, which matches
|
|
431
|
+
// none of the checks above: not `::1`, no `fc`/`fd`/`fe8x` prefix, no `::ffff:`
|
|
432
|
+
// prefix for extractMappedIpv4 to decode, and not one of the tunnel prefixes.
|
|
433
|
+
// Whether it reaches the embedded IPv4 depends on the OS, an intervening
|
|
434
|
+
// proxy, and NAT64 translation — the same "depends on the path" property that
|
|
435
|
+
// made 6to4/Teredo/NAT64 whole-range blocks the right call, so block the whole
|
|
436
|
+
// /96 rather than decode it. Deprecated with no legitimate scrape target.
|
|
437
|
+
// Matched structurally (top 96 bits zero => at most two groups after `::`),
|
|
438
|
+
// which is why it cannot swallow `::ffff:7f00:1` (three groups, ffff set).
|
|
439
|
+
if (/^::(?:\d{1,3}(?:\.\d{1,3}){3}|[0-9a-f]{1,4}(?::[0-9a-f]{1,4})?)$/.test(normalized)) return true;
|
|
382
440
|
// All-zeros (unspecified address)
|
|
383
441
|
if (normalized === '::' || normalized === '0:0:0:0:0:0:0:0') return true;
|
|
384
442
|
return false;
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
import {
|
|
24
24
|
FETCH_LAYER_TIMEOUT,
|
|
25
25
|
} from './types.ts';
|
|
26
|
-
import { getRandomUserAgent, extractDomain, validateUrlForSSRF, validateContent, createNativeMarkdownConverter, createJsMarkdownConverter,
|
|
26
|
+
import { getRandomUserAgent, extractDomain, validateUrlForSSRF, validateContent, createNativeMarkdownConverter, createJsMarkdownConverter, getSsrfSafeFetcher, formatErrorWithCause, isBenignScrapeFailure, isSsrfBlockError, } from './scraper-utils.ts';
|
|
27
27
|
import { isTransientError, abortableDelay } from './retry-utils.ts';
|
|
28
28
|
import { safeUnref } from '../utils/safe-unref.ts';
|
|
29
29
|
import { readBodyCapped, BodyTooLargeError } from '../utils/http-body.ts';
|
|
@@ -208,7 +208,7 @@ function decodeHtmlBody(bytes: Uint8Array, contentTypeHeader: string): string {
|
|
|
208
208
|
* counts are precisely what production incidents are diagnosed from.
|
|
209
209
|
*/
|
|
210
210
|
function trackFetchLayerFailure(url: string, error: unknown): void {
|
|
211
|
-
const isSsrf = error
|
|
211
|
+
const isSsrf = isSsrfBlockError(error);
|
|
212
212
|
errorTracker.trackError(error instanceof Error ? error : String(error), {
|
|
213
213
|
component: 'scrapers',
|
|
214
214
|
operation: 'fetch',
|
|
@@ -226,7 +226,12 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
|
|
|
226
226
|
// closing the DNS-rebinding TOCTOU gap between validateUrlForSSRF (request
|
|
227
227
|
// time) and fetch's own re-resolution (connect time). Null when undici is
|
|
228
228
|
// unavailable — request-time validation above still applies.
|
|
229
|
-
|
|
229
|
+
//
|
|
230
|
+
// The fetch implementation comes WITH the dispatcher and must be used with it:
|
|
231
|
+
// a dispatcher is only honoured by the undici that built it, and the global
|
|
232
|
+
// fetch is backed by Node's bundled undici, not ours. See getSsrfSafeFetcher.
|
|
233
|
+
const fetcher = await getSsrfSafeFetcher();
|
|
234
|
+
const doFetch = fetcher?.fetch ?? ((u: string, init: Record<string, unknown>) => fetch(u, init as RequestInit));
|
|
230
235
|
|
|
231
236
|
const controller = new AbortController();
|
|
232
237
|
const timeoutId = setTimeout(() => controller.abort(), FETCH_LAYER_TIMEOUT);
|
|
@@ -250,18 +255,23 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
|
|
|
250
255
|
const MAX_REDIRECTS = 10;
|
|
251
256
|
let currentUrl = url;
|
|
252
257
|
let response!: Response;
|
|
258
|
+
// One UA for the whole redirect chain. Picked per hop, a request could
|
|
259
|
+
// present as Chrome on Windows and then as Firefox on macOS while following
|
|
260
|
+
// a single Location chain — a browser never does that, so the rotation meant
|
|
261
|
+
// to blend in was instead handing out a distinctive signal.
|
|
262
|
+
const userAgent = getRandomUserAgent();
|
|
253
263
|
|
|
254
264
|
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
255
|
-
response = await
|
|
265
|
+
response = await doFetch(currentUrl, {
|
|
256
266
|
signal: controller.signal,
|
|
257
267
|
redirect: 'manual',
|
|
258
268
|
headers: {
|
|
259
|
-
'User-Agent':
|
|
269
|
+
'User-Agent': userAgent,
|
|
260
270
|
'Accept': 'text/html,application/xhtml+xml,application/pdf,*/*;q=0.8',
|
|
261
271
|
},
|
|
262
|
-
//
|
|
263
|
-
...(
|
|
264
|
-
}
|
|
272
|
+
// Only ever attached alongside the fetch that honours it (see above).
|
|
273
|
+
...(fetcher ? { dispatcher: fetcher.dispatcher } : {}),
|
|
274
|
+
});
|
|
265
275
|
|
|
266
276
|
if (response.status >= 300 && response.status < 400) {
|
|
267
277
|
const location = response.headers.get('location');
|
|
@@ -375,7 +385,7 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
|
|
|
375
385
|
// call in a retry, and tracking per attempt would record one failure twice —
|
|
376
386
|
// inflating exactly the error counts these numbers are read to diagnose.
|
|
377
387
|
// scrapeSingle calls trackFetchLayerFailure once with the final error.
|
|
378
|
-
if (error
|
|
388
|
+
if (isSsrfBlockError(error)) {
|
|
379
389
|
metrics.increment('scrape_operations_total', 1, { layer: 'fetch', status: 'ssrf_blocked' });
|
|
380
390
|
metrics.observe('scrape_latency_ms', Date.now() - fetchStart, { layer: 'fetch', status: 'ssrf_blocked' });
|
|
381
391
|
throw error;
|