@apifuse/provider-sdk 2.2.0-beta.68 → 2.2.0-beta.69
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 +6 -1
- package/CHANGELOG.md +4 -0
- package/README.md +10 -3
- package/dist/fixture-sanitization.js +159 -15
- package/dist/lint.js +56 -9
- package/dist/runtime/diagnostic-redactor.d.ts +6 -1
- package/dist/runtime/diagnostic-redactor.js +16 -14
- package/dist/runtime/http.js +15 -102
- package/dist/runtime/instrumentation.js +4 -1
- package/dist/runtime/redirect-hop-headers.d.ts +19 -0
- package/dist/runtime/redirect-hop-headers.js +58 -0
- package/dist/runtime/redirects.d.ts +33 -1
- package/dist/runtime/redirects.js +113 -0
- package/dist/runtime/resolver-span-attributes.d.ts +34 -0
- package/dist/runtime/resolver-span-attributes.js +93 -0
- package/dist/runtime/resolver-usage.d.ts +8 -0
- package/dist/runtime/resolver-usage.js +5 -11
- package/dist/runtime/resolver-vendors/browser.js +5 -4
- package/dist/runtime/resolver-vendors/capsolver.js +11 -10
- package/dist/runtime/resolver-vendors/twocaptcha.js +9 -8
- package/dist/runtime/resolver.js +7 -6
- package/dist/runtime/stealth-owned-headers.d.ts +21 -8
- package/dist/runtime/stealth-owned-headers.js +28 -8
- package/dist/runtime/stealth-telemetry.d.ts +1 -1
- package/dist/runtime/stealth.js +158 -46
- package/dist/runtime/trace.js +2 -2
- package/dist/server/serve-implementation.js +2 -1
- package/dist/testing/run.js +14 -7
- package/dist/trace-sanitization.js +1 -1
- package/dist/types.d.ts +30 -6
- package/package.json +2 -2
- package/src/fixture-sanitization.ts +175 -17
- package/src/lint.ts +54 -9
- package/src/runtime/diagnostic-redactor.ts +19 -13
- package/src/runtime/http.ts +22 -111
- package/src/runtime/instrumentation.ts +4 -1
- package/src/runtime/redirect-hop-headers.ts +71 -0
- package/src/runtime/redirects.ts +150 -1
- package/src/runtime/resolver-span-attributes.ts +139 -0
- package/src/runtime/resolver-usage.ts +8 -5
- package/src/runtime/resolver-vendors/browser.ts +5 -4
- package/src/runtime/resolver-vendors/capsolver.ts +11 -10
- package/src/runtime/resolver-vendors/twocaptcha.ts +9 -8
- package/src/runtime/resolver.ts +7 -6
- package/src/runtime/stealth-owned-headers.ts +34 -8
- package/src/runtime/stealth-telemetry.ts +6 -1
- package/src/runtime/stealth.ts +180 -58
- package/src/runtime/trace.ts +6 -2
- package/src/server/serve-implementation.ts +2 -1
- package/src/testing/run.ts +15 -2
- package/src/trace-sanitization.ts +1 -1
- package/src/types.ts +30 -6
package/src/runtime/stealth.ts
CHANGED
|
@@ -38,7 +38,7 @@ import type {
|
|
|
38
38
|
} from "../types.js";
|
|
39
39
|
import { chrome149HeaderOrder } from "./chrome149-header-order.js";
|
|
40
40
|
import { registerDiagnosticValue } from "./diagnostic-env.js";
|
|
41
|
-
import {
|
|
41
|
+
import { assertCallerHeadersSupported } from "./stealth-owned-headers.js";
|
|
42
42
|
import {
|
|
43
43
|
ENGINE_CEREMONY_EGRESS_LEASE,
|
|
44
44
|
type CeremonyEgressBinding,
|
|
@@ -68,10 +68,15 @@ import {
|
|
|
68
68
|
shouldRetryProxyTransportAttempt,
|
|
69
69
|
validateUnsafeProxyTransportRetryMethods,
|
|
70
70
|
} from "./proxy-retry-policy.js";
|
|
71
|
+
import { redirectHopHeaders } from "./redirect-hop-headers.js";
|
|
71
72
|
import {
|
|
72
73
|
evaluateRedirectHop,
|
|
74
|
+
invalidRedirectPolicy,
|
|
73
75
|
isRedirectStatus,
|
|
74
76
|
nextRedirectMethod,
|
|
77
|
+
nextSameOriginRedirectHop,
|
|
78
|
+
normalizeRedirectPolicy,
|
|
79
|
+
type RedirectPolicyTransport,
|
|
75
80
|
resolveRedirectUrl,
|
|
76
81
|
} from "./redirects.js";
|
|
77
82
|
import {
|
|
@@ -139,12 +144,10 @@ function isBoundEgressTransportFailure(error: TransportError): boolean {
|
|
|
139
144
|
);
|
|
140
145
|
}
|
|
141
146
|
const MAX_STEALTH_REDIRECT_HOPS = 10;
|
|
142
|
-
const
|
|
143
|
-
"
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
"content-type",
|
|
147
|
-
]);
|
|
147
|
+
const STEALTH_REDIRECT_POLICY_TRANSPORT: RedirectPolicyTransport = {
|
|
148
|
+
label: "ctx.stealth",
|
|
149
|
+
maxHops: MAX_STEALTH_REDIRECT_HOPS,
|
|
150
|
+
};
|
|
148
151
|
|
|
149
152
|
function isRecord(value: unknown): value is Record<PropertyKey, unknown> {
|
|
150
153
|
return typeof value === "object" && value !== null;
|
|
@@ -399,6 +402,8 @@ function normalizedHeaderEntries(
|
|
|
399
402
|
// installs Accept-Language via DevTools next to User-Agent; real Chrome only
|
|
400
403
|
// receives it from //net (URLRequestHttpJob::AddExtraHeaders), after
|
|
401
404
|
// Accept-Encoding and before Cookie, as al-placement-capture.json B/C show.
|
|
405
|
+
// chrome-form-post-capture.json was taken without that option and needs no
|
|
406
|
+
// adjustment.
|
|
402
407
|
const CHROME_HEADER_ORDERS = {
|
|
403
408
|
navigation: [
|
|
404
409
|
"sec-ch-ua",
|
|
@@ -435,7 +440,28 @@ const CHROME_HEADER_ORDERS = {
|
|
|
435
440
|
"cookie",
|
|
436
441
|
"priority",
|
|
437
442
|
],
|
|
438
|
-
|
|
443
|
+
"form-post": [
|
|
444
|
+
"content-length",
|
|
445
|
+
"cache-control",
|
|
446
|
+
"sec-ch-ua",
|
|
447
|
+
"sec-ch-ua-mobile",
|
|
448
|
+
"sec-ch-ua-platform",
|
|
449
|
+
"upgrade-insecure-requests",
|
|
450
|
+
"content-type",
|
|
451
|
+
"user-agent",
|
|
452
|
+
"origin",
|
|
453
|
+
"accept",
|
|
454
|
+
"sec-fetch-site",
|
|
455
|
+
"sec-fetch-mode",
|
|
456
|
+
"sec-fetch-user",
|
|
457
|
+
"sec-fetch-dest",
|
|
458
|
+
"referer",
|
|
459
|
+
"accept-encoding",
|
|
460
|
+
"accept-language",
|
|
461
|
+
"cookie",
|
|
462
|
+
"priority",
|
|
463
|
+
],
|
|
464
|
+
} as const satisfies Record<Exclude<ChromeRequestClass, "xhr">, readonly string[]>;
|
|
439
465
|
|
|
440
466
|
const CHROME_H1_HEADER_ORDERS = {
|
|
441
467
|
navigation: [
|
|
@@ -492,7 +518,29 @@ const CHROME_H1_HEADER_ORDERS = {
|
|
|
492
518
|
"accept-language",
|
|
493
519
|
"cookie",
|
|
494
520
|
],
|
|
495
|
-
|
|
521
|
+
"form-post": [
|
|
522
|
+
"host",
|
|
523
|
+
"connection",
|
|
524
|
+
"content-length",
|
|
525
|
+
"cache-control",
|
|
526
|
+
"sec-ch-ua",
|
|
527
|
+
"sec-ch-ua-mobile",
|
|
528
|
+
"sec-ch-ua-platform",
|
|
529
|
+
"upgrade-insecure-requests",
|
|
530
|
+
"content-type",
|
|
531
|
+
"user-agent",
|
|
532
|
+
"origin",
|
|
533
|
+
"accept",
|
|
534
|
+
"sec-fetch-site",
|
|
535
|
+
"sec-fetch-mode",
|
|
536
|
+
"sec-fetch-user",
|
|
537
|
+
"sec-fetch-dest",
|
|
538
|
+
"referer",
|
|
539
|
+
"accept-encoding",
|
|
540
|
+
"accept-language",
|
|
541
|
+
"cookie",
|
|
542
|
+
],
|
|
543
|
+
} as const satisfies Record<ChromeRequestClass, readonly string[]>;
|
|
496
544
|
|
|
497
545
|
const CHROME_H1_HEADER_NAMES: Record<string, string> = {
|
|
498
546
|
host: "Host",
|
|
@@ -516,7 +564,30 @@ const CHROME_H1_HEADER_NAMES: Record<string, string> = {
|
|
|
516
564
|
range: "Range",
|
|
517
565
|
};
|
|
518
566
|
|
|
519
|
-
type ChromeRequestClass =
|
|
567
|
+
type ChromeRequestClass = NonNullable<NonNullable<StealthFetchOptions["stealth"]>["requestClass"]>;
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* What each request class sends besides its header order, one row per public
|
|
571
|
+
* `stealth.requestClass`:
|
|
572
|
+
*
|
|
573
|
+
* - `fetchMetadata: "navigate"` is a document navigation: `Sec-Fetch-Mode:
|
|
574
|
+
* navigate`, `Sec-Fetch-Dest: document`, `Sec-Fetch-User: ?1` unless
|
|
575
|
+
* `userActivation` is false, `Upgrade-Insecure-Requests: 1`, and the
|
|
576
|
+
* profile's `Accept` and `Priority`. `"cors"` is a script fetch: `cors` /
|
|
577
|
+
* `empty`, `Accept: *\/*`, `Priority: u=1, i`.
|
|
578
|
+
* - `bodyful` sends `Content-Length`.
|
|
579
|
+
* - `validateCache` sends `Cache-Control: max-age=0`, which Chrome adds to a
|
|
580
|
+
* form submission (chrome-form-post-capture.json).
|
|
581
|
+
*/
|
|
582
|
+
const CHROME_REQUEST_CLASSES = {
|
|
583
|
+
navigation: { fetchMetadata: "navigate", bodyful: false, validateCache: false },
|
|
584
|
+
"form-post": { fetchMetadata: "navigate", bodyful: true, validateCache: true },
|
|
585
|
+
xhr: { fetchMetadata: "cors", bodyful: false, validateCache: false },
|
|
586
|
+
post: { fetchMetadata: "cors", bodyful: true, validateCache: false },
|
|
587
|
+
} as const satisfies Record<
|
|
588
|
+
ChromeRequestClass,
|
|
589
|
+
{ fetchMetadata: "navigate" | "cors"; bodyful: boolean; validateCache: boolean }
|
|
590
|
+
>;
|
|
520
591
|
|
|
521
592
|
function chromeHeaderOrder(
|
|
522
593
|
requestClass: ChromeRequestClass,
|
|
@@ -547,33 +618,11 @@ function chromeHeaderOrder(
|
|
|
547
618
|
return order;
|
|
548
619
|
}
|
|
549
620
|
|
|
550
|
-
function normalizedCallerHeaderEntries(
|
|
551
|
-
headers: Record<string, string | string[] | undefined>,
|
|
552
|
-
): HeaderTuple[] {
|
|
553
|
-
const entries = normalizedHeaderEntries(headers);
|
|
554
|
-
assertCallerHeadersSupported(entries);
|
|
555
|
-
return entries;
|
|
556
|
-
}
|
|
557
|
-
|
|
558
621
|
function normalizedCallerHeaderEntriesFromRecord(headers: Record<string, string>): HeaderTuple[] {
|
|
559
622
|
// The record has been through normalizeHeaders (lowercase, merged names); the
|
|
560
623
|
// lowercase here only guards later insertions such as the cookie jar's header.
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
value,
|
|
564
|
-
]);
|
|
565
|
-
assertCallerHeadersSupported(entries);
|
|
566
|
-
return entries;
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
function assertCallerHeadersSupported(entries: readonly HeaderTuple[]): void {
|
|
570
|
-
for (const [name] of entries) {
|
|
571
|
-
if (isStealthOwnedHeaderName(name)) {
|
|
572
|
-
throw new SDKError(`Stealth transport owns the "${name}" header; remove it from headers.`, {
|
|
573
|
-
code: "STEALTH_HEADER_OVERRIDE_UNSUPPORTED",
|
|
574
|
-
});
|
|
575
|
-
}
|
|
576
|
-
}
|
|
624
|
+
assertCallerHeadersSupported(headers);
|
|
625
|
+
return Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]);
|
|
577
626
|
}
|
|
578
627
|
|
|
579
628
|
function chromeRequestClass(
|
|
@@ -621,9 +670,10 @@ function buildChromeHeaderTuples(options: {
|
|
|
621
670
|
),
|
|
622
671
|
);
|
|
623
672
|
const requestClass = chromeRequestClass(options.method, options.requestClass);
|
|
673
|
+
const shape = CHROME_REQUEST_CLASSES[requestClass];
|
|
624
674
|
const referer = caller.get("referer");
|
|
625
675
|
const fetchSite = secFetchSite(options.requestUrl, referer);
|
|
626
|
-
const isNavigation =
|
|
676
|
+
const isNavigation = shape.fetchMetadata === "navigate";
|
|
627
677
|
const values = new Map<string, string>([
|
|
628
678
|
["sec-ch-ua", requiredEmulationHeader(emulation, "sec-ch-ua")],
|
|
629
679
|
["sec-ch-ua-mobile", requiredEmulationHeader(emulation, "sec-ch-ua-mobile")],
|
|
@@ -649,12 +699,16 @@ function buildChromeHeaderTuples(options: {
|
|
|
649
699
|
values.set("upgrade-insecure-requests", "1");
|
|
650
700
|
if (options.userActivation !== false) values.set("sec-fetch-user", "?1");
|
|
651
701
|
}
|
|
702
|
+
if (shape.validateCache) values.set("cache-control", "max-age=0");
|
|
652
703
|
for (const [name, value] of callerEntries) values.set(name, value);
|
|
653
704
|
for (const name of ["content-type", "origin", "referer"] as const) {
|
|
654
705
|
const value = caller.get(name);
|
|
655
706
|
if (value !== undefined) values.set(name, value);
|
|
656
707
|
}
|
|
657
|
-
|
|
708
|
+
// A bodyful class keeps its class across a 301/302/303 that rewrites the method
|
|
709
|
+
// to GET (a form submission's redirect stays a validated navigation), but the
|
|
710
|
+
// rewritten request has no body and Chrome sends no Content-Length for it.
|
|
711
|
+
if (shape.bodyful && options.method !== "GET" && options.method !== "HEAD") {
|
|
658
712
|
values.set(
|
|
659
713
|
"content-length",
|
|
660
714
|
caller.get("content-length") ?? String(Buffer.byteLength(options.body ?? "")),
|
|
@@ -1194,10 +1248,30 @@ function locationHeader(headers: Record<string, string>): string | undefined {
|
|
|
1194
1248
|
return undefined;
|
|
1195
1249
|
}
|
|
1196
1250
|
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1251
|
+
/**
|
|
1252
|
+
* Snapshot a caller's `redirectPolicy` before the first send. The policy acts
|
|
1253
|
+
* on redirects the transport follows, so it requires the default
|
|
1254
|
+
* `redirect: "follow"`.
|
|
1255
|
+
*/
|
|
1256
|
+
function snapshotStealthRedirectPolicy(options: StealthFetchOptions) {
|
|
1257
|
+
let policy: ReturnType<typeof normalizeRedirectPolicy>;
|
|
1258
|
+
try {
|
|
1259
|
+
policy = normalizeRedirectPolicy(options.redirectPolicy, STEALTH_REDIRECT_POLICY_TRANSPORT);
|
|
1260
|
+
} catch (error) {
|
|
1261
|
+
if (error instanceof TransportError) throw error;
|
|
1262
|
+
throw invalidRedirectPolicy(
|
|
1263
|
+
STEALTH_REDIRECT_POLICY_TRANSPORT,
|
|
1264
|
+
"could not be read safely",
|
|
1265
|
+
error instanceof Error ? error : undefined,
|
|
1266
|
+
);
|
|
1267
|
+
}
|
|
1268
|
+
if (policy && options.redirect !== undefined && options.redirect !== "follow") {
|
|
1269
|
+
throw invalidRedirectPolicy(
|
|
1270
|
+
STEALTH_REDIRECT_POLICY_TRANSPORT,
|
|
1271
|
+
`it applies to followed redirects and cannot be combined with redirect: "${options.redirect}"`,
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1274
|
+
return policy;
|
|
1201
1275
|
}
|
|
1202
1276
|
|
|
1203
1277
|
function assertStealthRedirectUrl(url: string): void {
|
|
@@ -1318,7 +1392,9 @@ function startStealthExchange(
|
|
|
1318
1392
|
requestClass:
|
|
1319
1393
|
requestClass === "navigation" && options.stealth?.userActivation === false
|
|
1320
1394
|
? "script_navigation"
|
|
1321
|
-
: requestClass
|
|
1395
|
+
: requestClass === "form-post"
|
|
1396
|
+
? "form_post"
|
|
1397
|
+
: requestClass,
|
|
1322
1398
|
...(status === undefined ? {} : { status }),
|
|
1323
1399
|
...(errorCode ? { errorCode } : {}),
|
|
1324
1400
|
...(error === undefined
|
|
@@ -1359,6 +1435,10 @@ async function fetchStealthRedirectChain(
|
|
|
1359
1435
|
let followedHops = 0;
|
|
1360
1436
|
let response: StealthTransportResponse;
|
|
1361
1437
|
const deadline = options.timeout ? performance.now() + options.timeout : undefined;
|
|
1438
|
+
// Snapshotted and validated by session.fetch before the first send.
|
|
1439
|
+
const redirectPolicy = options.redirectPolicy;
|
|
1440
|
+
const initialOrigin = new URL(requestUrl).origin;
|
|
1441
|
+
const visitedRequests = new Set([`${method} ${requestUrl}`]);
|
|
1362
1442
|
|
|
1363
1443
|
let finishExchange: ReturnType<typeof startStealthExchange> | undefined;
|
|
1364
1444
|
let status: number | undefined;
|
|
@@ -1415,28 +1495,48 @@ async function fetchStealthRedirectChain(
|
|
|
1415
1495
|
});
|
|
1416
1496
|
}
|
|
1417
1497
|
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
);
|
|
1422
|
-
if (!nextUrl) break;
|
|
1423
|
-
if (followedHops >= MAX_STEALTH_REDIRECT_HOPS) {
|
|
1498
|
+
let nextUrl: string;
|
|
1499
|
+
let nextMethod: StealthMethod;
|
|
1500
|
+
if (redirectPolicy) {
|
|
1424
1501
|
discardStealthRedirectBody(response);
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1502
|
+
({ nextUrl, nextMethod } = nextSameOriginRedirectHop({
|
|
1503
|
+
policy: redirectPolicy,
|
|
1504
|
+
initialOrigin,
|
|
1505
|
+
currentUrl,
|
|
1506
|
+
responseUrl: response.url ?? currentUrl,
|
|
1507
|
+
status: response.status,
|
|
1508
|
+
method: currentMethod,
|
|
1509
|
+
location: response.headers.get("location") ?? undefined,
|
|
1510
|
+
followedHops,
|
|
1511
|
+
visitedRequests,
|
|
1512
|
+
}));
|
|
1513
|
+
} else {
|
|
1514
|
+
const location = resolveRedirectUrl(
|
|
1515
|
+
response.headers.get("location") ?? undefined,
|
|
1516
|
+
response.url ?? currentUrl,
|
|
1428
1517
|
);
|
|
1518
|
+
if (!location) break;
|
|
1519
|
+
if (followedHops >= MAX_STEALTH_REDIRECT_HOPS) {
|
|
1520
|
+
discardStealthRedirectBody(response);
|
|
1521
|
+
throw new TransportError(
|
|
1522
|
+
`Stealth request exceeded the ${MAX_STEALTH_REDIRECT_HOPS}-redirect limit`,
|
|
1523
|
+
{ code: "transport_network_error", status: 0 },
|
|
1524
|
+
);
|
|
1525
|
+
}
|
|
1526
|
+
discardStealthRedirectBody(response);
|
|
1527
|
+
nextUrl = location;
|
|
1528
|
+
nextMethod = nextRedirectMethod(response.status, currentMethod);
|
|
1429
1529
|
}
|
|
1430
1530
|
assertStealthRedirectUrl(nextUrl);
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
}
|
|
1531
|
+
currentHeaders = redirectHopHeaders(
|
|
1532
|
+
{ from: currentUrl, to: nextUrl, methodChanged: nextMethod !== currentMethod },
|
|
1533
|
+
currentHeaders,
|
|
1534
|
+
);
|
|
1535
|
+
if (nextMethod !== currentMethod) currentBody = undefined;
|
|
1437
1536
|
currentMethod = nextMethod;
|
|
1438
1537
|
currentUrl = nextUrl;
|
|
1439
1538
|
followedHops += 1;
|
|
1539
|
+
visitedRequests.add(`${currentMethod} ${currentUrl}`);
|
|
1440
1540
|
emitStealthTelemetry(telemetry?.sink, (sink) => sink.recordRedirectHop());
|
|
1441
1541
|
finishExchange(status);
|
|
1442
1542
|
finishExchange = undefined;
|
|
@@ -1558,10 +1658,15 @@ function createSessionFetcher(
|
|
|
1558
1658
|
let entry = clients.get(cacheKey);
|
|
1559
1659
|
if (!entry) {
|
|
1560
1660
|
entry = {
|
|
1661
|
+
// The header shape only partitions sessions; it is never handed to wreq as
|
|
1662
|
+
// session `defaultHeaders`. wreq merges those into every request that lacks
|
|
1663
|
+
// one of their names, so a header the SDK left off on purpose — a credential
|
|
1664
|
+
// dropped on a cross-origin redirect hop, the body headers after POST -> GET,
|
|
1665
|
+
// a cookie for another host — would come back with the first request's value.
|
|
1666
|
+
// Every request already sends its complete ordered header list.
|
|
1561
1667
|
session: wreq.createSession({
|
|
1562
1668
|
browser,
|
|
1563
1669
|
os,
|
|
1564
|
-
...(defaultHeaders ? { defaultHeaders } : {}),
|
|
1565
1670
|
...(proxyUrl ? { proxy: proxyUrl } : {}),
|
|
1566
1671
|
...(ignoreTlsErrors ? { insecure: true } : {}),
|
|
1567
1672
|
timeout: 30_000,
|
|
@@ -1761,7 +1866,11 @@ function createSessionFetcher(
|
|
|
1761
1866
|
const { hasExplicitRetryPolicy, method, stealthRetryOptions } = (() => {
|
|
1762
1867
|
try {
|
|
1763
1868
|
const method = normalizeMethod(options.method ?? "GET");
|
|
1764
|
-
|
|
1869
|
+
assertCallerHeadersSupported(options.headers ?? {});
|
|
1870
|
+
// Replace the caller's object with the validated copy: the walker, a
|
|
1871
|
+
// retry and an explicit replay all read this snapshot.
|
|
1872
|
+
const redirectPolicy = snapshotStealthRedirectPolicy(options);
|
|
1873
|
+
if (redirectPolicy) options.redirectPolicy = redirectPolicy;
|
|
1765
1874
|
const hasExplicitRetryPolicy = options.retry !== undefined;
|
|
1766
1875
|
const stealthRetryOptions =
|
|
1767
1876
|
normalizeProxyTransportRetryOptions(options.retry, {
|
|
@@ -2527,11 +2636,13 @@ function createSessionFetcher(
|
|
|
2527
2636
|
stopWhen,
|
|
2528
2637
|
params,
|
|
2529
2638
|
sensitiveParams,
|
|
2639
|
+
headers: initialHeaders,
|
|
2530
2640
|
...fetchOptions
|
|
2531
2641
|
} = options;
|
|
2532
2642
|
const hops: StealthRedirectHop[] = [];
|
|
2533
2643
|
let method = normalizeMethod(options.method ?? "GET");
|
|
2534
2644
|
let body = options.body;
|
|
2645
|
+
let headers = initialHeaders;
|
|
2535
2646
|
let response: StealthResponse | undefined;
|
|
2536
2647
|
const visitedRequests = new Set<string>();
|
|
2537
2648
|
const initialParams = params
|
|
@@ -2593,6 +2704,7 @@ function createSessionFetcher(
|
|
|
2593
2704
|
try {
|
|
2594
2705
|
response = await session.fetch(currentUrl, {
|
|
2595
2706
|
...fetchOptions,
|
|
2707
|
+
...(headers ? { headers } : {}),
|
|
2596
2708
|
body,
|
|
2597
2709
|
method,
|
|
2598
2710
|
...(hopIndex === 0 && initialParams ? { params: initialParams } : {}),
|
|
@@ -2689,6 +2801,16 @@ function createSessionFetcher(
|
|
|
2689
2801
|
cookieStore: cookieJar.serialize(),
|
|
2690
2802
|
};
|
|
2691
2803
|
}
|
|
2804
|
+
if (headers) {
|
|
2805
|
+
headers = redirectHopHeaders(
|
|
2806
|
+
{
|
|
2807
|
+
from: currentUrl,
|
|
2808
|
+
to: decision.nextUrl,
|
|
2809
|
+
methodChanged: decision.nextMethod !== method,
|
|
2810
|
+
},
|
|
2811
|
+
headers,
|
|
2812
|
+
);
|
|
2813
|
+
}
|
|
2692
2814
|
if (decision.nextMethod !== method) {
|
|
2693
2815
|
body = undefined;
|
|
2694
2816
|
}
|
package/src/runtime/trace.ts
CHANGED
|
@@ -191,7 +191,11 @@ function normalizeAttributes(
|
|
|
191
191
|
? redactedKey === REDACTION_FAILED
|
|
192
192
|
? REDACTION_FAILED
|
|
193
193
|
: REDACTED_QUERY_VALUE
|
|
194
|
-
: normalizeAttributeValue(
|
|
194
|
+
: normalizeAttributeValue(
|
|
195
|
+
value,
|
|
196
|
+
diagnosticAttributeRedactor(key, redact, value),
|
|
197
|
+
retainBigints,
|
|
198
|
+
);
|
|
195
199
|
return [normalizedKey, normalizedValue] as const;
|
|
196
200
|
})
|
|
197
201
|
.filter(
|
|
@@ -226,7 +230,7 @@ function prepareResourceAttributesForExport(
|
|
|
226
230
|
? redactedKey === REDACTION_FAILED
|
|
227
231
|
? REDACTION_FAILED
|
|
228
232
|
: REDACTED_QUERY_VALUE
|
|
229
|
-
: sanitizeSpanNameForOutput(value, diagnosticAttributeRedactor(key, redact)),
|
|
233
|
+
: sanitizeSpanNameForOutput(value, diagnosticAttributeRedactor(key, redact, value)),
|
|
230
234
|
];
|
|
231
235
|
}),
|
|
232
236
|
);
|
|
@@ -127,6 +127,7 @@ import {
|
|
|
127
127
|
tenantOpaqueKeys,
|
|
128
128
|
} from "../runtime/request-telemetry.js";
|
|
129
129
|
import type * as ResolverRuntimeModule from "../runtime/resolver.js";
|
|
130
|
+
import { resolverSpanAttributes } from "../runtime/resolver-span-attributes.js";
|
|
130
131
|
import {
|
|
131
132
|
bindResolverTelemetry,
|
|
132
133
|
createUnsupportedResolverClient,
|
|
@@ -702,7 +703,7 @@ function createStealthChallengeDetection(
|
|
|
702
703
|
const solve = () => resolver.solve(challenge, solveSignal, recorder ?? undefined);
|
|
703
704
|
return recorder
|
|
704
705
|
? recorder.runSpan("resolver.solve", solve, {
|
|
705
|
-
attributes: { challenge_kind: challenge.kind },
|
|
706
|
+
attributes: resolverSpanAttributes({ challenge_kind: challenge.kind }),
|
|
706
707
|
})
|
|
707
708
|
: solve();
|
|
708
709
|
},
|
package/src/testing/run.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { createTestHandleContext } from "../runtime/handle.js";
|
|
|
10
10
|
*/
|
|
11
11
|
const STANDARD_TEST_CONNECTION_ID = "standard-test-connection";
|
|
12
12
|
import { createMemoryProviderRuntimeState } from "../runtime/state.js";
|
|
13
|
+
import { assertCallerHeadersSupported } from "../runtime/stealth-owned-headers.js";
|
|
13
14
|
import { createUnsupportedOcrClient } from "../runtime/ocr.js";
|
|
14
15
|
import { createUnsupportedSttClient } from "../runtime/stt.js";
|
|
15
16
|
import {
|
|
@@ -356,8 +357,19 @@ function createUpstreamContext(
|
|
|
356
357
|
options?: unknown,
|
|
357
358
|
): Promise<HttpResponse> =>
|
|
358
359
|
toHttpResponse(await dispatch({ transport: "http", method, url, body, options }));
|
|
359
|
-
|
|
360
|
-
|
|
360
|
+
// The production transport rejects owned headers (User-Agent, Sec-Fetch-*,
|
|
361
|
+
// sec-ch-ua*, ...) before any byte is sent; the fake applies the same
|
|
362
|
+
// function, so a provider test fails exactly where production would.
|
|
363
|
+
const stealthCall = async (
|
|
364
|
+
url: string,
|
|
365
|
+
options?: {
|
|
366
|
+
method?: string;
|
|
367
|
+
body?: unknown;
|
|
368
|
+
headers?: Readonly<Record<string, string | readonly string[] | undefined>>;
|
|
369
|
+
},
|
|
370
|
+
) => {
|
|
371
|
+
assertCallerHeadersSupported(options?.headers ?? {});
|
|
372
|
+
return toStealthResponse(
|
|
361
373
|
await dispatch({
|
|
362
374
|
transport: "stealth",
|
|
363
375
|
method: options?.method?.toUpperCase() ?? "GET",
|
|
@@ -367,6 +379,7 @@ function createUpstreamContext(
|
|
|
367
379
|
}),
|
|
368
380
|
url,
|
|
369
381
|
);
|
|
382
|
+
};
|
|
370
383
|
|
|
371
384
|
const createBrowserPage = (): BrowserPage => {
|
|
372
385
|
let currentUrl = "about:blank";
|
|
@@ -52,7 +52,7 @@ export function sanitizeTraceAttributes(
|
|
|
52
52
|
const valueRedactor =
|
|
53
53
|
typeof value === "bigint" || isDiagnosticBigintAttribute(attributes, key)
|
|
54
54
|
? diagnosticStructuredRedactor(redact)
|
|
55
|
-
: diagnosticAttributeRedactor(key, redact);
|
|
55
|
+
: diagnosticAttributeRedactor(key, redact, value);
|
|
56
56
|
const sanitizedValue = keyChanged
|
|
57
57
|
? redactedKey === REDACTION_FAILED
|
|
58
58
|
? REDACTION_FAILED
|
package/src/types.ts
CHANGED
|
@@ -1414,7 +1414,23 @@ export interface StealthFetchOptions extends Omit<RequestOptions, "redirectPolic
|
|
|
1414
1414
|
headers?: Record<string, string | string[]>;
|
|
1415
1415
|
method?: HttpMethod;
|
|
1416
1416
|
body?: string | Buffer;
|
|
1417
|
+
/**
|
|
1418
|
+
* Defaults to `"follow"`. Every followed hop drops the request-body headers
|
|
1419
|
+
* when the method changes, `Authorization` and `Proxy-Authorization` when it
|
|
1420
|
+
* leaves the current origin, and an explicit `Cookie` when it leaves the
|
|
1421
|
+
* current site (scheme plus registrable domain); after that the session
|
|
1422
|
+
* cookie jar supplies cookies for the new site.
|
|
1423
|
+
*/
|
|
1417
1424
|
redirect?: "follow" | "manual" | "error";
|
|
1425
|
+
/**
|
|
1426
|
+
* Opt-in same-origin redirect enforcement, the ctx.http `redirectPolicy`
|
|
1427
|
+
* for the stealth transport. A redirect whose target leaves the initial
|
|
1428
|
+
* origin, exceeds `maxHops` (at most 10, the stealth redirect limit),
|
|
1429
|
+
* loops, or has no usable `Location` throws `HttpRedirectError` before any
|
|
1430
|
+
* request is sent to that target. Requires the default
|
|
1431
|
+
* `redirect: "follow"`.
|
|
1432
|
+
*/
|
|
1433
|
+
redirectPolicy?: HttpRedirectPolicy;
|
|
1418
1434
|
/**
|
|
1419
1435
|
* Maximum decoded response-body bytes to buffer. When set, the stealth
|
|
1420
1436
|
* transport aborts the response and throws `response_too_large` if the
|
|
@@ -1440,13 +1456,21 @@ export interface StealthFetchOptions extends Omit<RequestOptions, "redirectPolic
|
|
|
1440
1456
|
* replacement for hand-set `Sec-Fetch-*` headers, which the transport
|
|
1441
1457
|
* rejects; it drives `Sec-Fetch-Mode`/`Dest`/`User`, `Accept`, `Priority`,
|
|
1442
1458
|
* and `Upgrade-Insecure-Requests`.
|
|
1459
|
+
*
|
|
1460
|
+
* `form-post` is an HTML `<form method="post">` submission: a navigation
|
|
1461
|
+
* (`navigate`/`document`, the document `Accept`, `Upgrade-Insecure-Requests`)
|
|
1462
|
+
* that carries a body, in the header order Chrome uses for it, with
|
|
1463
|
+
* `Cache-Control: max-age=0` and the caller's `Content-Type`, `Origin` and
|
|
1464
|
+
* `Referer` in place. It is never inferred, since a form submission and a
|
|
1465
|
+
* script `fetch()` POST look the same to the transport.
|
|
1443
1466
|
*/
|
|
1444
|
-
requestClass?: "navigation" | "xhr" | "post";
|
|
1467
|
+
requestClass?: "navigation" | "form-post" | "xhr" | "post";
|
|
1445
1468
|
/**
|
|
1446
|
-
* Whether a `navigation` request carries a user gesture and
|
|
1447
|
-
* `Sec-Fetch-User: ?1` (default `true`). Set `false` for
|
|
1448
|
-
* navigations such as `location.replace
|
|
1449
|
-
* Ignored for the `xhr` and
|
|
1469
|
+
* Whether a `navigation` or `form-post` request carries a user gesture and
|
|
1470
|
+
* therefore `Sec-Fetch-User: ?1` (default `true`). Set `false` for
|
|
1471
|
+
* script-driven navigations such as `location.replace` or
|
|
1472
|
+
* `form.submit()`, which Chrome sends without it. Ignored for the `xhr` and
|
|
1473
|
+
* `post` request classes.
|
|
1450
1474
|
*/
|
|
1451
1475
|
userActivation?: boolean;
|
|
1452
1476
|
/**
|
|
@@ -1551,7 +1575,7 @@ export interface StealthRedirectHop {
|
|
|
1551
1575
|
}
|
|
1552
1576
|
|
|
1553
1577
|
export interface StealthRedirectRunOptions
|
|
1554
|
-
extends Omit<StealthFetchOptions, "redirect"> {
|
|
1578
|
+
extends Omit<StealthFetchOptions, "redirect" | "redirectPolicy"> {
|
|
1555
1579
|
url: string;
|
|
1556
1580
|
maxHops?: number;
|
|
1557
1581
|
stopWhen?: (hop: StealthRedirectHop) => boolean | Promise<boolean>;
|