@apifuse/provider-sdk 2.2.0-beta.11 → 2.2.0-beta.13
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/AUTHORING.md +238 -0
- package/CHANGELOG.md +14 -0
- package/README.md +44 -2
- package/bin/apifuse-pack-smoke.ts +14 -0
- package/bin/apifuse-pack-types.ts +40 -1
- package/bin/apifuse-record.ts +622 -57
- package/bin/apifuse-submit-check.ts +43 -10
- package/dist/config/loader.d.ts +9 -1
- package/dist/config/loader.js +9 -0
- package/dist/define.d.ts +2 -1
- package/dist/define.js +61 -3
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +15 -0
- package/dist/fixture-sanitization.d.ts +26 -0
- package/dist/fixture-sanitization.js +216 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -1
- package/dist/provider.d.ts +2 -1
- package/dist/provider.js +1 -0
- package/dist/runtime/http.js +86 -32
- package/dist/runtime/instrumentation.js +295 -9
- package/dist/runtime/native-network.d.ts +53 -0
- package/dist/runtime/native-network.js +477 -0
- package/dist/runtime/proxy-nodemaven.d.ts +14 -0
- package/dist/runtime/proxy-nodemaven.js +20 -2
- package/dist/runtime/request-options.d.ts +68 -1
- package/dist/runtime/request-options.js +548 -0
- package/dist/runtime/stealth.d.ts +3 -1
- package/dist/runtime/stealth.js +352 -86
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/self-test-input-tokens.d.ts +2 -1
- package/dist/server/self-test-input-tokens.js +18 -14
- package/dist/stream-evidence.d.ts +74 -0
- package/dist/stream-evidence.js +785 -0
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +1 -1
- package/dist/testing/run.d.ts +32 -2
- package/dist/testing/run.js +451 -19
- package/dist/types.d.ts +201 -7
- package/package.json +3 -1
- package/src/config/loader.ts +22 -1
- package/src/define.ts +81 -3
- package/src/errors.ts +15 -0
- package/src/fixture-sanitization.ts +247 -0
- package/src/index.ts +45 -1
- package/src/provider.ts +37 -0
- package/src/runtime/http.ts +144 -38
- package/src/runtime/instrumentation.ts +424 -8
- package/src/runtime/native-network.ts +600 -0
- package/src/runtime/proxy-nodemaven.ts +37 -2
- package/src/runtime/request-options.ts +680 -1
- package/src/runtime/stealth.ts +420 -88
- package/src/server/index.ts +4 -1
- package/src/server/self-test-input-tokens.ts +29 -14
- package/src/stream-evidence.ts +988 -0
- package/src/testing/index.ts +9 -1
- package/src/testing/run.ts +608 -12
- package/src/types.ts +235 -7
package/src/runtime/stealth.ts
CHANGED
|
@@ -1,24 +1,27 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import type { Browser, ImpitOptions, ImpitResponse, RequestInit } from "impit";
|
|
3
3
|
import { Impit } from "impit";
|
|
4
|
+
import { Cookie, CookieJar as ToughCookieJar } from "tough-cookie";
|
|
4
5
|
|
|
5
6
|
import type { ProxyResolutionOptions, ProxyVendorName } from "../config/loader.js";
|
|
6
7
|
import {
|
|
7
8
|
DEFAULT_SMARTPROXY_POOL_SIZE,
|
|
8
9
|
invalidateProxyResolutionCacheAsync,
|
|
9
|
-
policyResolvesRegistryVendorChain,
|
|
10
10
|
ProxyResolutionError,
|
|
11
|
+
policyResolvesRegistryVendorChain,
|
|
11
12
|
resolvePolicyProxyPoolSpan,
|
|
12
13
|
resolvePolicyTransportAttemptCap,
|
|
13
14
|
resolveProxyConfigAsync,
|
|
14
15
|
vendorFromResolvedSource,
|
|
15
16
|
} from "../config/loader.js";
|
|
16
|
-
import { SDKError, TransportError } from "../errors.js";
|
|
17
|
+
import { SDKError, StealthCookieStoreVersionError, TransportError } from "../errors.js";
|
|
17
18
|
import { getStealthProfile } from "../stealth/profiles.js";
|
|
18
19
|
import type {
|
|
19
20
|
CookieJar,
|
|
20
21
|
HttpMethod,
|
|
21
22
|
StealthClient,
|
|
23
|
+
StealthCookieStore,
|
|
24
|
+
StealthCookieStoreV1,
|
|
22
25
|
StealthFetchOptions,
|
|
23
26
|
StealthRedirectHop,
|
|
24
27
|
StealthResponse,
|
|
@@ -47,7 +50,15 @@ import {
|
|
|
47
50
|
shouldRetryProxyTransportAttempt,
|
|
48
51
|
validateUnsafeProxyTransportRetryMethods,
|
|
49
52
|
} from "./proxy-retry-policy.js";
|
|
50
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
isSensitiveKey,
|
|
55
|
+
redactSensitiveError,
|
|
56
|
+
redactSensitiveRequestError,
|
|
57
|
+
redactSensitiveText,
|
|
58
|
+
redactUrlQueryParams,
|
|
59
|
+
normalizeSensitiveParams,
|
|
60
|
+
serializeRequestUrl,
|
|
61
|
+
} from "./request-options.js";
|
|
51
62
|
|
|
52
63
|
const DEFAULT_PROFILE = "chrome-146";
|
|
53
64
|
|
|
@@ -62,6 +73,14 @@ const PROXY_AUTH_DIAGNOSTIC_URL = "http://example.com/";
|
|
|
62
73
|
const PROXY_AUTH_DIAGNOSTIC_TIMEOUT_MS = 5_000;
|
|
63
74
|
const STEALTH_PROXY_TRANSPORT_RETRY_ERROR_CODES = [PROXY_CONNECT_FAILURE_CODE] as const;
|
|
64
75
|
|
|
76
|
+
function sensitiveQueryParamNames(url: string): string[] {
|
|
77
|
+
const queryStart = url.indexOf("?");
|
|
78
|
+
if (queryStart === -1) return [];
|
|
79
|
+
const fragmentStart = url.indexOf("#", queryStart);
|
|
80
|
+
const query = url.slice(queryStart + 1, fragmentStart === -1 ? undefined : fragmentStart);
|
|
81
|
+
return [...new URLSearchParams(query).keys()].filter(isSensitiveKey);
|
|
82
|
+
}
|
|
83
|
+
|
|
65
84
|
export type StealthClientOptions = ProxyResolutionOptions & {
|
|
66
85
|
warn?: (message: string) => void;
|
|
67
86
|
/**
|
|
@@ -114,6 +133,8 @@ type StealthTransportResponse = Pick<
|
|
|
114
133
|
ImpitResponse,
|
|
115
134
|
"arrayBuffer" | "headers" | "json" | "ok" | "status" | "text"
|
|
116
135
|
> & {
|
|
136
|
+
body?: ReadableStream<Uint8Array>;
|
|
137
|
+
abort?: () => void;
|
|
117
138
|
url?: string;
|
|
118
139
|
redirected?: boolean;
|
|
119
140
|
};
|
|
@@ -127,81 +148,145 @@ function isRecord(value: unknown): value is Record<PropertyKey, unknown> {
|
|
|
127
148
|
return typeof value === "object" && value !== null;
|
|
128
149
|
}
|
|
129
150
|
|
|
151
|
+
const LEGACY_COOKIE_ORIGIN = "https://legacy-cookie.invalid/";
|
|
152
|
+
|
|
130
153
|
class CookieJarImpl implements CookieJar {
|
|
131
|
-
private
|
|
154
|
+
private cookies: ToughCookieJar;
|
|
155
|
+
private readonly defaultUrl: string;
|
|
132
156
|
|
|
133
|
-
constructor(cookieStrings: string[]) {
|
|
134
|
-
this.cookies = {
|
|
157
|
+
constructor(cookieStrings: readonly string[], defaultUrl = LEGACY_COOKIE_ORIGIN) {
|
|
158
|
+
this.cookies = new ToughCookieJar(undefined, {
|
|
159
|
+
allowSecureOnLocal: false,
|
|
160
|
+
rejectPublicSuffixes: true,
|
|
161
|
+
});
|
|
162
|
+
this.defaultUrl = this.normalizeUrl(defaultUrl) ?? LEGACY_COOKIE_ORIGIN;
|
|
135
163
|
this.setFromCookieStrings(cookieStrings);
|
|
136
164
|
}
|
|
137
165
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
166
|
+
/**
|
|
167
|
+
* URL-less legacy operations are scoped to this jar's default URL. Session
|
|
168
|
+
* jars use the client's base URL and response jars use the response URL. A
|
|
169
|
+
* flat restore has no attributes to recover, so it creates host-only Path=/
|
|
170
|
+
* cookies for that default URL instead of making them visible to every host.
|
|
171
|
+
*/
|
|
172
|
+
setFromCookieStrings(cookieStrings: readonly string[], url = this.defaultUrl): void {
|
|
173
|
+
const cookieUrl = this.normalizeUrl(url);
|
|
174
|
+
if (!cookieUrl) return;
|
|
149
175
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if (name) this.cookies[name] = value;
|
|
176
|
+
for (const cookieString of cookieStrings) {
|
|
177
|
+
this.cookies.setCookieSync(cookieString, cookieUrl, { ignoreError: true });
|
|
153
178
|
}
|
|
154
179
|
}
|
|
155
180
|
|
|
156
|
-
get(name: string): string | undefined {
|
|
157
|
-
return this.
|
|
181
|
+
get(name: string, url?: string): string | undefined {
|
|
182
|
+
return this.getAll(url)[name];
|
|
158
183
|
}
|
|
159
184
|
|
|
160
|
-
getAll(): Record<string, string> {
|
|
161
|
-
return
|
|
185
|
+
getAll(url?: string): Record<string, string> {
|
|
186
|
+
return Object.fromEntries(
|
|
187
|
+
this.getUniqueCookies(url ?? this.defaultUrl).map((cookie) => [cookie.key, cookie.value]),
|
|
188
|
+
);
|
|
162
189
|
}
|
|
163
190
|
|
|
164
|
-
has(name: string): boolean {
|
|
165
|
-
return Object.hasOwn(this.
|
|
191
|
+
has(name: string, url?: string): boolean {
|
|
192
|
+
return Object.hasOwn(this.getAll(url), name);
|
|
166
193
|
}
|
|
167
194
|
|
|
168
|
-
toString(): string {
|
|
169
|
-
return
|
|
170
|
-
.map((
|
|
195
|
+
toString(url?: string): string {
|
|
196
|
+
return this.getUniqueCookies(url ?? this.defaultUrl)
|
|
197
|
+
.map((cookie) => cookie.cookieString())
|
|
171
198
|
.join("; ");
|
|
172
199
|
}
|
|
173
200
|
|
|
174
|
-
toHeader(): string {
|
|
175
|
-
return this.toString();
|
|
201
|
+
toHeader(url?: string): string {
|
|
202
|
+
return this.toString(url);
|
|
176
203
|
}
|
|
177
204
|
|
|
178
205
|
snapshot(): Record<string, string> {
|
|
179
|
-
|
|
206
|
+
// This compatibility view deliberately enumerates the serialized store,
|
|
207
|
+
// not getAll(defaultUrl): persistence must include sibling hosts and paths.
|
|
208
|
+
// Duplicate names still collapse because a flat map cannot represent them.
|
|
209
|
+
const entries: [string, string][] = [];
|
|
210
|
+
for (const cookie of this.serialize().jar.cookies) {
|
|
211
|
+
if (typeof cookie.key === "string" && typeof cookie.value === "string" && cookie.key) {
|
|
212
|
+
entries.push([cookie.key, cookie.value]);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return Object.fromEntries(entries);
|
|
180
216
|
}
|
|
181
217
|
|
|
182
218
|
restore(cookies: Record<string, string>): void {
|
|
183
219
|
this.clear();
|
|
184
220
|
for (const [name, value] of Object.entries(cookies)) {
|
|
185
|
-
if (name)
|
|
221
|
+
if (!name) continue;
|
|
222
|
+
this.cookies.setCookieSync(new Cookie({ key: name, path: "/", value }), this.defaultUrl, {
|
|
223
|
+
ignoreError: true,
|
|
224
|
+
});
|
|
186
225
|
}
|
|
187
226
|
}
|
|
188
227
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
228
|
+
serialize(): StealthCookieStoreV1 {
|
|
229
|
+
const jar = this.cookies.serializeSync();
|
|
230
|
+
if (!jar) {
|
|
231
|
+
throw new SDKError("Stealth cookie store could not be serialized", {
|
|
232
|
+
code: "stealth_cookie_store_serialize_failed",
|
|
233
|
+
});
|
|
192
234
|
}
|
|
235
|
+
return { version: 1, jar };
|
|
193
236
|
}
|
|
194
237
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
238
|
+
deserialize(state: StealthCookieStore): void {
|
|
239
|
+
const version = isRecord(state) ? state.version : undefined;
|
|
240
|
+
if (version !== 1) {
|
|
241
|
+
throw new StealthCookieStoreVersionError(version);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Deserialize into a new jar first so invalid state cannot partially clear
|
|
245
|
+
// or replace a live session. tough-cookie restores the cookie attributes and
|
|
246
|
+
// matching semantics represented in its own serialized format.
|
|
247
|
+
const restored = ToughCookieJar.deserializeSync(state.jar);
|
|
248
|
+
// tough-cookie 6 does not include this option in serializeSync(). Preserve
|
|
249
|
+
// the SDK's stricter setting across restoration.
|
|
250
|
+
Reflect.set(restored, "allowSecureOnLocal", false);
|
|
251
|
+
this.cookies = restored;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
clear(): void {
|
|
255
|
+
this.cookies.removeAllCookiesSync();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
find(predicate: (cookie: string) => boolean, url?: string): string | undefined {
|
|
259
|
+
for (const cookie of this.getUniqueCookies(url ?? this.defaultUrl)) {
|
|
260
|
+
const cookieString = cookie.cookieString();
|
|
261
|
+
if (predicate(cookieString)) {
|
|
262
|
+
return cookieString;
|
|
200
263
|
}
|
|
201
264
|
}
|
|
202
265
|
|
|
203
266
|
return undefined;
|
|
204
267
|
}
|
|
268
|
+
|
|
269
|
+
private normalizeUrl(url: string): string | undefined {
|
|
270
|
+
try {
|
|
271
|
+
return new URL(url).toString();
|
|
272
|
+
} catch {
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private getUniqueCookies(url: string): Cookie[] {
|
|
278
|
+
const cookieUrl = this.normalizeUrl(url);
|
|
279
|
+
if (!cookieUrl) return [];
|
|
280
|
+
|
|
281
|
+
// tough-cookie returns longer (more-specific) paths first. Keeping the
|
|
282
|
+
// first cookie for each name prevents ambiguous duplicate-name headers.
|
|
283
|
+
const names = new Set<string>();
|
|
284
|
+
return this.cookies.getCookiesSync(cookieUrl).filter((cookie) => {
|
|
285
|
+
if (names.has(cookie.key)) return false;
|
|
286
|
+
names.add(cookie.key);
|
|
287
|
+
return true;
|
|
288
|
+
});
|
|
289
|
+
}
|
|
205
290
|
}
|
|
206
291
|
|
|
207
292
|
function closestImpitBrowser(
|
|
@@ -282,12 +367,12 @@ function hasOwn(object: object, key: string): boolean {
|
|
|
282
367
|
}
|
|
283
368
|
function toImpitCookieJar(cookieJar: CookieJarImpl): NonNullable<ImpitOptions["cookieJar"]> {
|
|
284
369
|
return {
|
|
285
|
-
setCookie(cookie: string,
|
|
286
|
-
cookieJar.setFromCookieStrings([cookie]);
|
|
370
|
+
setCookie(cookie: string, url: string, cb?: (error?: unknown) => void) {
|
|
371
|
+
cookieJar.setFromCookieStrings([cookie], url);
|
|
287
372
|
if (typeof cb === "function") cb();
|
|
288
373
|
},
|
|
289
|
-
getCookieString(
|
|
290
|
-
return cookieJar.
|
|
374
|
+
getCookieString(url: string) {
|
|
375
|
+
return cookieJar.toHeader(url);
|
|
291
376
|
},
|
|
292
377
|
};
|
|
293
378
|
}
|
|
@@ -339,10 +424,17 @@ function splitCombinedSetCookieHeader(headerValue: string): string[] {
|
|
|
339
424
|
export async function normalizeResponse(
|
|
340
425
|
response: StealthTransportResponse,
|
|
341
426
|
requestUrl?: string,
|
|
427
|
+
maxBodyBytes?: number,
|
|
342
428
|
): Promise<StealthResponse> {
|
|
343
429
|
const headers = Object.fromEntries(response.headers.entries());
|
|
344
|
-
const cookies = new CookieJarImpl(
|
|
345
|
-
|
|
430
|
+
const cookies = new CookieJarImpl(
|
|
431
|
+
setCookieHeadersFromResponse(response.headers),
|
|
432
|
+
response.url ?? requestUrl,
|
|
433
|
+
);
|
|
434
|
+
const bodyBytes =
|
|
435
|
+
maxBodyBytes === undefined
|
|
436
|
+
? await response.arrayBuffer()
|
|
437
|
+
: await readResponseBodyWithLimit(response, maxBodyBytes);
|
|
346
438
|
const body = new TextDecoder().decode(bodyBytes);
|
|
347
439
|
|
|
348
440
|
return {
|
|
@@ -370,6 +462,83 @@ export async function normalizeResponse(
|
|
|
370
462
|
};
|
|
371
463
|
}
|
|
372
464
|
|
|
465
|
+
function responseTooLargeError(maxBodyBytes: number, observedBytes: number): TransportError {
|
|
466
|
+
return new TransportError(
|
|
467
|
+
`Response body exceeded maxBodyBytes limit of ${maxBodyBytes} bytes (observed ${observedBytes} bytes)`,
|
|
468
|
+
{
|
|
469
|
+
code: "response_too_large",
|
|
470
|
+
category: "upstream_http",
|
|
471
|
+
retryable: false,
|
|
472
|
+
status: 0,
|
|
473
|
+
},
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function declaredContentLength(headers: Headers): number | undefined {
|
|
478
|
+
const contentLength = headers.get("content-length")?.trim();
|
|
479
|
+
if (!contentLength || !/^\d+$/.test(contentLength)) return undefined;
|
|
480
|
+
const parsed = Number(contentLength);
|
|
481
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function abortTransportResponse(response: StealthTransportResponse): boolean {
|
|
485
|
+
if (!response.abort) return false;
|
|
486
|
+
try {
|
|
487
|
+
response.abort();
|
|
488
|
+
} catch {
|
|
489
|
+
// The size error remains the primary failure if impit has already closed the response.
|
|
490
|
+
}
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function readResponseBodyWithLimit(
|
|
495
|
+
response: StealthTransportResponse,
|
|
496
|
+
maxBodyBytes: number,
|
|
497
|
+
): Promise<ArrayBuffer> {
|
|
498
|
+
const contentLength = declaredContentLength(response.headers);
|
|
499
|
+
if (contentLength !== undefined && contentLength > maxBodyBytes) {
|
|
500
|
+
if (!abortTransportResponse(response)) {
|
|
501
|
+
await response.body?.cancel().catch(() => undefined);
|
|
502
|
+
}
|
|
503
|
+
throw responseTooLargeError(maxBodyBytes, contentLength);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (!response.body) {
|
|
507
|
+
throw new TransportError("Response body stream is unavailable", {
|
|
508
|
+
code: "transport_stream_unavailable",
|
|
509
|
+
category: "upstream_http",
|
|
510
|
+
status: 0,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const reader = response.body.getReader();
|
|
515
|
+
const chunks: Uint8Array[] = [];
|
|
516
|
+
let receivedBytes = 0;
|
|
517
|
+
try {
|
|
518
|
+
while (true) {
|
|
519
|
+
const { done, value } = await reader.read();
|
|
520
|
+
if (done) break;
|
|
521
|
+
receivedBytes += value.byteLength;
|
|
522
|
+
if (receivedBytes > maxBodyBytes) {
|
|
523
|
+
await reader.cancel().catch(() => undefined);
|
|
524
|
+
abortTransportResponse(response);
|
|
525
|
+
throw responseTooLargeError(maxBodyBytes, receivedBytes);
|
|
526
|
+
}
|
|
527
|
+
chunks.push(value);
|
|
528
|
+
}
|
|
529
|
+
} finally {
|
|
530
|
+
reader.releaseLock();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const bodyBytes = new Uint8Array(receivedBytes);
|
|
534
|
+
let offset = 0;
|
|
535
|
+
for (const chunk of chunks) {
|
|
536
|
+
bodyBytes.set(chunk, offset);
|
|
537
|
+
offset += chunk.byteLength;
|
|
538
|
+
}
|
|
539
|
+
return bodyBytes.buffer;
|
|
540
|
+
}
|
|
541
|
+
|
|
373
542
|
function normalizeBody(body: StealthFetchOptions["body"]): string {
|
|
374
543
|
if (body === undefined) {
|
|
375
544
|
return "";
|
|
@@ -588,7 +757,7 @@ function createSessionFetcher(
|
|
|
588
757
|
let closed = false;
|
|
589
758
|
let hasWarnedMissingProxy = false;
|
|
590
759
|
const warn = clientOptions.warn ?? console.warn;
|
|
591
|
-
const cookieJar = new CookieJarImpl([]);
|
|
760
|
+
const cookieJar = new CookieJarImpl([], baseUrl);
|
|
592
761
|
const impitCookieJar = toImpitCookieJar(cookieJar);
|
|
593
762
|
|
|
594
763
|
function getClient(
|
|
@@ -652,22 +821,29 @@ function createSessionFetcher(
|
|
|
652
821
|
|
|
653
822
|
const session: StealthSession = {
|
|
654
823
|
async fetch(url, options: StealthFetchOptions = {}) {
|
|
655
|
-
const method =
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
}) ??
|
|
662
|
-
(hasExplicitRetryPolicy
|
|
663
|
-
? undefined
|
|
664
|
-
: createDefaultProxyTransportRetryOptions({
|
|
824
|
+
const { hasExplicitRetryPolicy, method, stealthRetryOptions } = (() => {
|
|
825
|
+
try {
|
|
826
|
+
const method = normalizeMethod(options.method ?? "GET");
|
|
827
|
+
const hasExplicitRetryPolicy = options.retry !== undefined;
|
|
828
|
+
const stealthRetryOptions =
|
|
829
|
+
normalizeProxyTransportRetryOptions(options.retry, {
|
|
665
830
|
extraErrorCodes: STEALTH_PROXY_TRANSPORT_RETRY_ERROR_CODES,
|
|
666
831
|
label: "Stealth",
|
|
667
|
-
})
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
832
|
+
}) ??
|
|
833
|
+
(hasExplicitRetryPolicy
|
|
834
|
+
? undefined
|
|
835
|
+
: createDefaultProxyTransportRetryOptions({
|
|
836
|
+
extraErrorCodes: STEALTH_PROXY_TRANSPORT_RETRY_ERROR_CODES,
|
|
837
|
+
label: "Stealth",
|
|
838
|
+
}));
|
|
839
|
+
if (stealthRetryOptions) {
|
|
840
|
+
validateUnsafeProxyTransportRetryMethods(stealthRetryOptions, "Stealth");
|
|
841
|
+
}
|
|
842
|
+
return { hasExplicitRetryPolicy, method, stealthRetryOptions };
|
|
843
|
+
} catch (error) {
|
|
844
|
+
throw redactSensitiveRequestError(error, url, options.sensitiveParams);
|
|
845
|
+
}
|
|
846
|
+
})();
|
|
671
847
|
const hasPolicyProxy = isPolicyManagedProxy(clientOptions);
|
|
672
848
|
const usesPolicyAllocator = hasPolicyProxy && !options.proxy && !clientOptions.proxy;
|
|
673
849
|
const retryAttemptCap = Math.max(1, stealthRetryOptions?.attempts ?? 1);
|
|
@@ -712,6 +888,11 @@ function createSessionFetcher(
|
|
|
712
888
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
713
889
|
let proxy: string | undefined;
|
|
714
890
|
let attemptProxy: ResolvedAttemptProxy | undefined;
|
|
891
|
+
// Reuse the exact serialization used by this outbound attempt in its catch path.
|
|
892
|
+
let serializedUrl: ReturnType<typeof serializeRequestUrl> | undefined;
|
|
893
|
+
let fallbackSensitiveValues: readonly string[] = [];
|
|
894
|
+
let fallbackRequestUrl: string | undefined;
|
|
895
|
+
let fallbackRedactedUrl: string | undefined;
|
|
715
896
|
const attemptStartedAt = Date.now();
|
|
716
897
|
let attemptRecorded = false;
|
|
717
898
|
const recordProxyAttempt = (
|
|
@@ -735,6 +916,16 @@ function createSessionFetcher(
|
|
|
735
916
|
});
|
|
736
917
|
};
|
|
737
918
|
try {
|
|
919
|
+
const sensitiveParams = normalizeSensitiveParams(options.sensitiveParams);
|
|
920
|
+
const structural = redactUrlQueryParams(url, Object.keys(sensitiveParams ?? {}));
|
|
921
|
+
fallbackSensitiveValues = [
|
|
922
|
+
...new Set([
|
|
923
|
+
...Object.values(sensitiveParams ?? {}).map(String),
|
|
924
|
+
...structural.sensitiveValues,
|
|
925
|
+
]),
|
|
926
|
+
].filter((value) => value !== "");
|
|
927
|
+
fallbackRequestUrl = url;
|
|
928
|
+
fallbackRedactedUrl = structural.redactedUrl;
|
|
738
929
|
assertNoUnsupportedFingerprintOverrides(options);
|
|
739
930
|
attemptProxy = await resolveRequestProxy(options, attempt, refreshAttempt);
|
|
740
931
|
proxy = attemptProxy.url;
|
|
@@ -754,10 +945,15 @@ function createSessionFetcher(
|
|
|
754
945
|
(!hasPolicyProxy && proxy && clientOptions.proxyStealth?.insecureSkipVerify),
|
|
755
946
|
);
|
|
756
947
|
const profileName = options.profile ?? defaultProfile;
|
|
757
|
-
|
|
948
|
+
serializedUrl = serializeRequestUrl(
|
|
949
|
+
resolveUrl(baseUrl, url),
|
|
950
|
+
options.params,
|
|
951
|
+
sensitiveParams,
|
|
952
|
+
);
|
|
953
|
+
const { requestUrl } = serializedUrl;
|
|
758
954
|
const headers = { ...(options.headers ?? {}) };
|
|
759
955
|
if (!hasHeader(headers, "Cookie")) {
|
|
760
|
-
const cookieHeader = cookieJar.
|
|
956
|
+
const cookieHeader = cookieJar.toHeader(requestUrl);
|
|
761
957
|
if (cookieHeader) headers.Cookie = cookieHeader;
|
|
762
958
|
}
|
|
763
959
|
const requestInit: StealthRequestInit = {
|
|
@@ -773,8 +969,11 @@ function createSessionFetcher(
|
|
|
773
969
|
requestUrl,
|
|
774
970
|
requestInit,
|
|
775
971
|
);
|
|
776
|
-
const normalized = await normalizeResponse(response, requestUrl);
|
|
777
|
-
cookieJar.setFromCookieStrings(
|
|
972
|
+
const normalized = await normalizeResponse(response, requestUrl, options.maxBodyBytes);
|
|
973
|
+
cookieJar.setFromCookieStrings(
|
|
974
|
+
setCookieHeadersFromResponse(response.headers),
|
|
975
|
+
response.url ?? requestUrl,
|
|
976
|
+
);
|
|
778
977
|
|
|
779
978
|
if (proxy && isProxyConnectFailureResponse(response, normalized.body)) {
|
|
780
979
|
throw createProxyConnectFailureError(normalized.body);
|
|
@@ -818,16 +1017,36 @@ function createSessionFetcher(
|
|
|
818
1017
|
recordProxyAttempt("ok", undefined, response.status);
|
|
819
1018
|
return normalized;
|
|
820
1019
|
} catch (error) {
|
|
821
|
-
const
|
|
1020
|
+
const sensitiveValues = serializedUrl?.sensitiveValues ?? fallbackSensitiveValues;
|
|
1021
|
+
let normalizedError: TransportError;
|
|
1022
|
+
try {
|
|
1023
|
+
normalizedError = normalizeStealthTransportError(error);
|
|
1024
|
+
} catch (normalizationError) {
|
|
1025
|
+
throw redactSensitiveError(
|
|
1026
|
+
normalizationError,
|
|
1027
|
+
sensitiveValues,
|
|
1028
|
+
serializedUrl?.requestUrl ?? fallbackRequestUrl,
|
|
1029
|
+
serializedUrl?.redactedUrl ?? fallbackRedactedUrl,
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
const retryErrorCode = proxyAttemptErrorCode(normalizedError);
|
|
1033
|
+
const refreshableProxyError = isProxyPoolRefreshableError(normalizedError);
|
|
1034
|
+
const runProxyAuthDiagnostic = shouldRunProxyAuthDiagnostic(normalizedError);
|
|
1035
|
+
normalizedError = redactSensitiveError(
|
|
1036
|
+
normalizedError,
|
|
1037
|
+
sensitiveValues,
|
|
1038
|
+
serializedUrl?.requestUrl ?? fallbackRequestUrl,
|
|
1039
|
+
serializedUrl?.redactedUrl ?? fallbackRedactedUrl,
|
|
1040
|
+
);
|
|
822
1041
|
recordProxyAttempt(
|
|
823
1042
|
"error",
|
|
824
1043
|
proxyAttemptErrorCode(normalizedError),
|
|
825
1044
|
proxyAttemptStatus(normalizedError),
|
|
826
1045
|
);
|
|
827
1046
|
lastError = normalizedError;
|
|
828
|
-
if (proxy && rotatesRegistryChain &&
|
|
1047
|
+
if (proxy && rotatesRegistryChain && refreshableProxyError) {
|
|
829
1048
|
stalePoolError = normalizedError;
|
|
830
|
-
if (
|
|
1049
|
+
if (runProxyAuthDiagnostic) {
|
|
831
1050
|
stalePoolDiagnosticProxy = proxy;
|
|
832
1051
|
}
|
|
833
1052
|
if (attempt + 1 < maxAttempts) {
|
|
@@ -858,7 +1077,7 @@ function createSessionFetcher(
|
|
|
858
1077
|
if (
|
|
859
1078
|
attempt + 1 < transportRetryCap &&
|
|
860
1079
|
shouldRetryProxyTransportAttempt({
|
|
861
|
-
error:
|
|
1080
|
+
error: { code: retryErrorCode },
|
|
862
1081
|
explicitRetry: hasExplicitRetryPolicy,
|
|
863
1082
|
method,
|
|
864
1083
|
options: stealthRetryOptions,
|
|
@@ -928,25 +1147,99 @@ function createSessionFetcher(
|
|
|
928
1147
|
options.maxHops === undefined || !Number.isFinite(options.maxHops)
|
|
929
1148
|
? 10
|
|
930
1149
|
: Math.max(0, Math.floor(options.maxHops));
|
|
1150
|
+
const {
|
|
1151
|
+
url: _url,
|
|
1152
|
+
maxHops: _maxHops,
|
|
1153
|
+
stopWhen,
|
|
1154
|
+
params,
|
|
1155
|
+
sensitiveParams,
|
|
1156
|
+
...fetchOptions
|
|
1157
|
+
} = options;
|
|
931
1158
|
const hops: StealthRedirectHop[] = [];
|
|
932
|
-
let currentUrl = resolveUrl(baseUrl, options.url);
|
|
933
1159
|
let method = normalizeMethod(options.method ?? "GET");
|
|
934
1160
|
let body = options.body;
|
|
935
1161
|
let response: StealthResponse | undefined;
|
|
936
1162
|
const visitedRequests = new Set<string>();
|
|
937
|
-
|
|
938
|
-
|
|
1163
|
+
const initialParams = params
|
|
1164
|
+
? Object.fromEntries(
|
|
1165
|
+
Object.entries(params).map(([key, value]) => [
|
|
1166
|
+
key,
|
|
1167
|
+
Array.isArray(value) ? [...value] : value,
|
|
1168
|
+
]),
|
|
1169
|
+
)
|
|
1170
|
+
: undefined;
|
|
1171
|
+
const normalizedSensitiveParams = normalizeSensitiveParams(sensitiveParams);
|
|
1172
|
+
const initialSensitiveParams = normalizedSensitiveParams
|
|
1173
|
+
? { ...normalizedSensitiveParams }
|
|
1174
|
+
: undefined;
|
|
1175
|
+
const sensitiveParamNames = initialSensitiveParams
|
|
1176
|
+
? Object.keys(initialSensitiveParams)
|
|
1177
|
+
: [];
|
|
1178
|
+
const callerStructural = redactUrlQueryParams(options.url, sensitiveParamNames);
|
|
1179
|
+
const sensitiveValues = new Set(
|
|
1180
|
+
[
|
|
1181
|
+
...Object.values(initialSensitiveParams ?? {}),
|
|
1182
|
+
...callerStructural.sensitiveValues,
|
|
1183
|
+
].filter((value) => value !== ""),
|
|
1184
|
+
);
|
|
1185
|
+
const redactRedirectUrl = (value: string): string => {
|
|
1186
|
+
const structural = redactUrlQueryParams(value, [
|
|
1187
|
+
...new Set([...sensitiveParamNames, ...sensitiveQueryParamNames(value)]),
|
|
1188
|
+
]);
|
|
1189
|
+
for (const sensitiveValue of structural.sensitiveValues) {
|
|
1190
|
+
sensitiveValues.add(sensitiveValue);
|
|
1191
|
+
}
|
|
1192
|
+
return redactSensitiveText(structural.redactedUrl, [...sensitiveValues]);
|
|
1193
|
+
};
|
|
1194
|
+
let currentUrl: string;
|
|
1195
|
+
let initialUrl: ReturnType<typeof serializeRequestUrl>;
|
|
1196
|
+
try {
|
|
1197
|
+
currentUrl = resolveUrl(baseUrl, options.url);
|
|
1198
|
+
redactRedirectUrl(currentUrl);
|
|
1199
|
+
initialUrl = serializeRequestUrl(currentUrl, initialParams, initialSensitiveParams);
|
|
1200
|
+
for (const value of initialUrl.sensitiveValues) {
|
|
1201
|
+
if (value !== "") sensitiveValues.add(value);
|
|
1202
|
+
}
|
|
1203
|
+
} catch (error) {
|
|
1204
|
+
throw redactSensitiveError(
|
|
1205
|
+
error,
|
|
1206
|
+
[...sensitiveValues],
|
|
1207
|
+
options.url,
|
|
1208
|
+
redactRedirectUrl(options.url),
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
939
1211
|
|
|
940
1212
|
for (let hopIndex = 0; hopIndex <= maxHops; hopIndex += 1) {
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
1213
|
+
const outboundUrl =
|
|
1214
|
+
hopIndex === 0 ? initialUrl.requestUrl : serializeRequestUrl(currentUrl).requestUrl;
|
|
1215
|
+
// Preserve params-only loop bookkeeping from before sensitiveParams:
|
|
1216
|
+
// the first visited key is the caller's resolved URL, not its expanded query.
|
|
1217
|
+
const visitedUrl = hopIndex === 0 && !initialSensitiveParams ? currentUrl : outboundUrl;
|
|
1218
|
+
visitedRequests.add(`${method} ${visitedUrl}`);
|
|
1219
|
+
try {
|
|
1220
|
+
response = await session.fetch(currentUrl, {
|
|
1221
|
+
...fetchOptions,
|
|
1222
|
+
body,
|
|
1223
|
+
method,
|
|
1224
|
+
...(hopIndex === 0 && initialParams ? { params: initialParams } : {}),
|
|
1225
|
+
...(hopIndex === 0 && initialSensitiveParams
|
|
1226
|
+
? { sensitiveParams: initialSensitiveParams }
|
|
1227
|
+
: {}),
|
|
1228
|
+
redirect: "manual",
|
|
1229
|
+
throwOnHttpError: false,
|
|
1230
|
+
});
|
|
1231
|
+
} catch (error) {
|
|
1232
|
+
throw redactSensitiveError(
|
|
1233
|
+
error,
|
|
1234
|
+
[...sensitiveValues],
|
|
1235
|
+
outboundUrl,
|
|
1236
|
+
redactRedirectUrl(outboundUrl),
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
1239
|
+
// StealthResponse.url is programmatic metadata and remains raw. Only the
|
|
1240
|
+
// redirect hop emitted below is a diagnostic surface.
|
|
1241
|
+
const responseUrl =
|
|
1242
|
+
response.url ?? (hopIndex === 0 && initialSensitiveParams ? outboundUrl : currentUrl);
|
|
950
1243
|
|
|
951
1244
|
if (!isRedirectStatus(response.status)) {
|
|
952
1245
|
return {
|
|
@@ -954,28 +1247,63 @@ function createSessionFetcher(
|
|
|
954
1247
|
hops,
|
|
955
1248
|
reason: "completed",
|
|
956
1249
|
cookies: cookieJar.snapshot(),
|
|
1250
|
+
cookieStore: cookieJar.serialize(),
|
|
957
1251
|
};
|
|
958
1252
|
}
|
|
959
1253
|
|
|
960
1254
|
const location = locationHeader(response.headers);
|
|
961
|
-
const
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1255
|
+
const redactedResponseUrl = redactRedirectUrl(responseUrl);
|
|
1256
|
+
const redactedLocation = location ? redactRedirectUrl(location) : undefined;
|
|
1257
|
+
let nextUrl: string | undefined;
|
|
1258
|
+
try {
|
|
1259
|
+
nextUrl = location ? new URL(location, responseUrl).toString() : undefined;
|
|
1260
|
+
} catch (error) {
|
|
1261
|
+
throw redactSensitiveError(error, [...sensitiveValues], location, redactedLocation);
|
|
1262
|
+
}
|
|
1263
|
+
const realHop: StealthRedirectHop = {
|
|
1264
|
+
url: responseUrl,
|
|
966
1265
|
status: response.status,
|
|
967
1266
|
method,
|
|
968
1267
|
...(location ? { location } : {}),
|
|
969
1268
|
...(nextUrl ? { nextUrl } : {}),
|
|
970
1269
|
};
|
|
1270
|
+
const hop: StealthRedirectHop = {
|
|
1271
|
+
...realHop,
|
|
1272
|
+
url: redactedResponseUrl,
|
|
1273
|
+
...(redactedLocation ? { location: redactedLocation } : {}),
|
|
1274
|
+
...(nextUrl ? { nextUrl: redactRedirectUrl(nextUrl) } : {}),
|
|
1275
|
+
};
|
|
971
1276
|
hops.push(hop);
|
|
972
1277
|
|
|
973
|
-
|
|
1278
|
+
let shouldStop = false;
|
|
1279
|
+
if (stopWhen) {
|
|
1280
|
+
try {
|
|
1281
|
+
shouldStop = await stopWhen(realHop);
|
|
1282
|
+
} catch (error) {
|
|
1283
|
+
let sanitizedError: unknown = error;
|
|
1284
|
+
for (const [rawUrl, safeUrl] of [
|
|
1285
|
+
[responseUrl, redactedResponseUrl],
|
|
1286
|
+
[location, redactedLocation],
|
|
1287
|
+
[nextUrl, nextUrl ? redactRedirectUrl(nextUrl) : undefined],
|
|
1288
|
+
] as const) {
|
|
1289
|
+
if (!rawUrl || !safeUrl) continue;
|
|
1290
|
+
sanitizedError = redactSensitiveError(
|
|
1291
|
+
sanitizedError,
|
|
1292
|
+
[...sensitiveValues],
|
|
1293
|
+
rawUrl,
|
|
1294
|
+
safeUrl,
|
|
1295
|
+
);
|
|
1296
|
+
}
|
|
1297
|
+
throw sanitizedError;
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
if (shouldStop) {
|
|
974
1301
|
return {
|
|
975
1302
|
final: response,
|
|
976
1303
|
hops,
|
|
977
1304
|
reason: "stopped",
|
|
978
1305
|
cookies: cookieJar.snapshot(),
|
|
1306
|
+
cookieStore: cookieJar.serialize(),
|
|
979
1307
|
};
|
|
980
1308
|
}
|
|
981
1309
|
|
|
@@ -985,6 +1313,7 @@ function createSessionFetcher(
|
|
|
985
1313
|
hops,
|
|
986
1314
|
reason: "missing_location",
|
|
987
1315
|
cookies: cookieJar.snapshot(),
|
|
1316
|
+
cookieStore: cookieJar.serialize(),
|
|
988
1317
|
};
|
|
989
1318
|
}
|
|
990
1319
|
|
|
@@ -994,6 +1323,7 @@ function createSessionFetcher(
|
|
|
994
1323
|
hops,
|
|
995
1324
|
reason: "max_hops",
|
|
996
1325
|
cookies: cookieJar.snapshot(),
|
|
1326
|
+
cookieStore: cookieJar.serialize(),
|
|
997
1327
|
};
|
|
998
1328
|
}
|
|
999
1329
|
|
|
@@ -1007,6 +1337,7 @@ function createSessionFetcher(
|
|
|
1007
1337
|
hops,
|
|
1008
1338
|
reason: "loop",
|
|
1009
1339
|
cookies: cookieJar.snapshot(),
|
|
1340
|
+
cookieStore: cookieJar.serialize(),
|
|
1010
1341
|
};
|
|
1011
1342
|
}
|
|
1012
1343
|
method = nextMethod;
|
|
@@ -1028,6 +1359,7 @@ function createSessionFetcher(
|
|
|
1028
1359
|
hops,
|
|
1029
1360
|
reason: "max_hops",
|
|
1030
1361
|
cookies: cookieJar.snapshot(),
|
|
1362
|
+
cookieStore: cookieJar.serialize(),
|
|
1031
1363
|
};
|
|
1032
1364
|
},
|
|
1033
1365
|
},
|