@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/redirects.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { HttpRedirectError, TransportError } from "../errors.js";
|
|
2
|
+
import type { HttpRedirectFailureReason, HttpRedirectPolicy } from "../types.js";
|
|
2
3
|
|
|
3
4
|
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
4
5
|
|
|
@@ -64,3 +65,151 @@ export function evaluateRedirectHop<TMethod extends string>(input: {
|
|
|
64
65
|
}
|
|
65
66
|
return { kind: "follow", nextMethod, nextUrl: input.nextUrl };
|
|
66
67
|
}
|
|
68
|
+
|
|
69
|
+
const REDIRECT_POLICY_FIELDS = new Set(["mode", "maxHops"]);
|
|
70
|
+
export const MALFORMED_REDIRECT_TARGET = "[malformed redirect target]";
|
|
71
|
+
|
|
72
|
+
/** The transport a redirectPolicy belongs to: its name in diagnostics and its own hop limit. */
|
|
73
|
+
export interface RedirectPolicyTransport {
|
|
74
|
+
readonly label: string;
|
|
75
|
+
readonly maxHops: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function invalidRedirectPolicy(
|
|
79
|
+
transport: RedirectPolicyTransport,
|
|
80
|
+
message: string,
|
|
81
|
+
cause?: Error,
|
|
82
|
+
): TransportError {
|
|
83
|
+
return new TransportError(`Invalid ${transport.label} redirectPolicy: ${message}`, {
|
|
84
|
+
code: "http_redirect_policy_invalid",
|
|
85
|
+
...(cause ? { cause } : {}),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Snapshot untrusted caller input synchronously, before proxy resolution or fetch. */
|
|
90
|
+
export function normalizeRedirectPolicy(
|
|
91
|
+
value: unknown,
|
|
92
|
+
transport: RedirectPolicyTransport,
|
|
93
|
+
): HttpRedirectPolicy | undefined {
|
|
94
|
+
if (value === undefined) return undefined;
|
|
95
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
96
|
+
throw invalidRedirectPolicy(transport, "expected an object");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const keys = Reflect.ownKeys(value);
|
|
101
|
+
for (const key of keys) {
|
|
102
|
+
if (typeof key !== "string" || !REDIRECT_POLICY_FIELDS.has(key)) {
|
|
103
|
+
throw invalidRedirectPolicy(transport, `unknown field ${String(key)}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const field of REDIRECT_POLICY_FIELDS) {
|
|
107
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, field);
|
|
108
|
+
if (!descriptor || !("value" in descriptor)) {
|
|
109
|
+
throw invalidRedirectPolicy(transport, `${field} must be an own data property`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const record = value as Record<string, unknown>;
|
|
114
|
+
if (record.mode !== "same-origin") {
|
|
115
|
+
throw invalidRedirectPolicy(transport, 'mode must be "same-origin"');
|
|
116
|
+
}
|
|
117
|
+
if (
|
|
118
|
+
typeof record.maxHops !== "number" ||
|
|
119
|
+
!Number.isInteger(record.maxHops) ||
|
|
120
|
+
record.maxHops < 0 ||
|
|
121
|
+
record.maxHops > transport.maxHops
|
|
122
|
+
) {
|
|
123
|
+
throw invalidRedirectPolicy(
|
|
124
|
+
transport,
|
|
125
|
+
`maxHops must be an integer from 0 to ${transport.maxHops}`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { mode: "same-origin", maxHops: record.maxHops };
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (error instanceof TransportError) throw error;
|
|
132
|
+
throw invalidRedirectPolicy(
|
|
133
|
+
transport,
|
|
134
|
+
"could not be inspected safely",
|
|
135
|
+
error instanceof Error ? error : undefined,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function redirectDiagnosticTarget(value: string): string {
|
|
141
|
+
try {
|
|
142
|
+
const parsed = new URL(value);
|
|
143
|
+
// Origin omits URL userinfo. Keeping only origin + path makes diagnostics
|
|
144
|
+
// useful while structurally excluding every query value and fragment,
|
|
145
|
+
// including attacker-chosen keys the provider did not declare sensitive.
|
|
146
|
+
const redactedQuery = parsed.search ? "?[REDACTED]" : "";
|
|
147
|
+
return parsed.origin === "null"
|
|
148
|
+
? `${parsed.protocol}<opaque-target>`
|
|
149
|
+
: `${parsed.origin}${parsed.pathname}${redactedQuery}`;
|
|
150
|
+
} catch {
|
|
151
|
+
return MALFORMED_REDIRECT_TARGET;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* One hop of a same-origin `redirectPolicy` walk, shared by ctx.http and
|
|
157
|
+
* ctx.stealth so both follow and refuse identically: returns the next request
|
|
158
|
+
* inside the initial origin, or throws the `HttpRedirectError` naming why the
|
|
159
|
+
* walk stopped (cross-origin target, maxHops, loop, missing or malformed
|
|
160
|
+
* Location) before any request leaves for that target.
|
|
161
|
+
*/
|
|
162
|
+
export function nextSameOriginRedirectHop<TMethod extends string>(input: {
|
|
163
|
+
policy: HttpRedirectPolicy;
|
|
164
|
+
initialOrigin: string;
|
|
165
|
+
currentUrl: string;
|
|
166
|
+
responseUrl: string;
|
|
167
|
+
status: number;
|
|
168
|
+
method: TMethod;
|
|
169
|
+
location: string | undefined;
|
|
170
|
+
followedHops: number;
|
|
171
|
+
visitedRequests: ReadonlySet<string>;
|
|
172
|
+
}): { nextMethod: TMethod | "GET"; nextUrl: string } {
|
|
173
|
+
let nextUrl: string | undefined;
|
|
174
|
+
try {
|
|
175
|
+
nextUrl = resolveRedirectUrl(input.location || undefined, input.responseUrl);
|
|
176
|
+
} catch {
|
|
177
|
+
const target = MALFORMED_REDIRECT_TARGET;
|
|
178
|
+
throw new HttpRedirectError(`Redirect response has malformed Location target ${target}`, {
|
|
179
|
+
reason: "missing_location",
|
|
180
|
+
target,
|
|
181
|
+
status: input.status,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const decision = evaluateRedirectHop({
|
|
186
|
+
status: input.status,
|
|
187
|
+
method: input.method,
|
|
188
|
+
nextUrl,
|
|
189
|
+
shouldStop: nextUrl ? new URL(nextUrl).origin !== input.initialOrigin : false,
|
|
190
|
+
redirectCount: input.followedHops + 1,
|
|
191
|
+
maxHops: input.policy.maxHops,
|
|
192
|
+
visitedRequests: input.visitedRequests,
|
|
193
|
+
});
|
|
194
|
+
if (decision.kind === "follow") {
|
|
195
|
+
return { nextMethod: decision.nextMethod, nextUrl: decision.nextUrl };
|
|
196
|
+
}
|
|
197
|
+
const target = decision.nextUrl ? redirectDiagnosticTarget(decision.nextUrl) : undefined;
|
|
198
|
+
const message = (() => {
|
|
199
|
+
switch (decision.reason) {
|
|
200
|
+
case "stopped":
|
|
201
|
+
return `Redirect policy refused cross-origin target ${target}`;
|
|
202
|
+
case "max_hops":
|
|
203
|
+
return `Redirect policy reached maxHops before target ${target}`;
|
|
204
|
+
case "loop":
|
|
205
|
+
return `Redirect loop refused target ${target}`;
|
|
206
|
+
case "missing_location":
|
|
207
|
+
return `Redirect response from ${redirectDiagnosticTarget(input.currentUrl)} is missing Location`;
|
|
208
|
+
}
|
|
209
|
+
})();
|
|
210
|
+
throw new HttpRedirectError(message, {
|
|
211
|
+
reason: decision.reason,
|
|
212
|
+
...(target ? { target } : {}),
|
|
213
|
+
status: input.status,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { ProxyUserAgentSource } from "../config/loader.js";
|
|
2
|
+
import type { ProviderChallengeKind, ProviderResolverVendor } from "../types.js";
|
|
3
|
+
import type { ResolverTelemetryPhase } from "./resolver-telemetry.js";
|
|
4
|
+
import type {
|
|
5
|
+
ResolverPaidUsageBilling,
|
|
6
|
+
ResolverPaidUsageEndpoint,
|
|
7
|
+
ResolverPaidUsageOutcome,
|
|
8
|
+
} from "./resolver-usage.js";
|
|
9
|
+
import type {
|
|
10
|
+
ResolverChallengeVerdictReason,
|
|
11
|
+
ResolverVendorUnavailableReason,
|
|
12
|
+
} from "./resolver-vendors/types.js";
|
|
13
|
+
|
|
14
|
+
// Type-only imports: the diagnostic redactor reads this table, so it must not pull the resolver
|
|
15
|
+
// runtime (or anything that imports the redactor) into module evaluation.
|
|
16
|
+
|
|
17
|
+
/** Every member of a closed string union, as runtime values; a missing or extra member fails tsc. */
|
|
18
|
+
function closedEnumValues<TUnion extends string>() {
|
|
19
|
+
return <const TValues extends readonly TUnion[]>(
|
|
20
|
+
values: TValues,
|
|
21
|
+
..._missing: Exclude<TUnion, TValues[number]> extends never
|
|
22
|
+
? []
|
|
23
|
+
: ["Missing closed-enum values", Exclude<TUnion, TValues[number]>]
|
|
24
|
+
): TValues => values;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const RESOLVER_PAID_USAGE_OUTCOMES = closedEnumValues<ResolverPaidUsageOutcome>()([
|
|
28
|
+
"success",
|
|
29
|
+
"vendor_error",
|
|
30
|
+
"timeout",
|
|
31
|
+
"abandoned",
|
|
32
|
+
]);
|
|
33
|
+
// `resolver.cache.invalidate` outcomes. The union stays private to resolver.ts; its producer's
|
|
34
|
+
// `resolverSpanAttributes` call fails to compile if it can emit an outcome missing here.
|
|
35
|
+
const RESOLVER_CACHE_INVALIDATION_OUTCOMES = [
|
|
36
|
+
"cache_disabled",
|
|
37
|
+
"entry_deleted",
|
|
38
|
+
"index_entry_deleted",
|
|
39
|
+
"not_cookie_solution",
|
|
40
|
+
"solution_not_cached",
|
|
41
|
+
] as const;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Closed-enum span attributes emitted by the resolver, by attribute key. A value listed here is an
|
|
45
|
+
* SDK-declared literal, so fail-closed diagnostic redaction keeps it the way it keeps a typed
|
|
46
|
+
* number; any other value under the same key is still free text. Registered credentials are
|
|
47
|
+
* matched either way.
|
|
48
|
+
*/
|
|
49
|
+
export const RESOLVER_CLOSED_ENUM_SPAN_ATTRIBUTES = {
|
|
50
|
+
vendor: closedEnumValues<ProviderResolverVendor>()([
|
|
51
|
+
"browser",
|
|
52
|
+
"capsolver",
|
|
53
|
+
"capmonster",
|
|
54
|
+
"2captcha",
|
|
55
|
+
"hypersolutions",
|
|
56
|
+
"custom",
|
|
57
|
+
]),
|
|
58
|
+
challenge_kind: closedEnumValues<ProviderChallengeKind>()([
|
|
59
|
+
"turnstile",
|
|
60
|
+
"recaptcha_v2",
|
|
61
|
+
"recaptcha_v3",
|
|
62
|
+
"hcaptcha",
|
|
63
|
+
"cloudflare_interstitial",
|
|
64
|
+
"aws_waf",
|
|
65
|
+
"akamai_sec_cpt",
|
|
66
|
+
"akamai_sensor",
|
|
67
|
+
"akamai_sbsd",
|
|
68
|
+
]),
|
|
69
|
+
endpoint: closedEnumValues<ResolverPaidUsageEndpoint>()([
|
|
70
|
+
"capsolver:create_task",
|
|
71
|
+
"twocaptcha:create_task",
|
|
72
|
+
"hyper:ip",
|
|
73
|
+
"hyper:sbsd_create",
|
|
74
|
+
]),
|
|
75
|
+
billing: closedEnumValues<ResolverPaidUsageBilling>()(["metered", "unconfirmed"]),
|
|
76
|
+
outcome: [...RESOLVER_PAID_USAGE_OUTCOMES, ...RESOLVER_CACHE_INVALIDATION_OUTCOMES],
|
|
77
|
+
unavailability_reason: closedEnumValues<ResolverVendorUnavailableReason>()([
|
|
78
|
+
"missing_credentials",
|
|
79
|
+
"missing_proxy_identity",
|
|
80
|
+
"missing_client_profile",
|
|
81
|
+
"missing_challenge_input",
|
|
82
|
+
"missing_transport",
|
|
83
|
+
"allocation_exhausted",
|
|
84
|
+
"transport_failure",
|
|
85
|
+
"timeout",
|
|
86
|
+
"not_implemented",
|
|
87
|
+
]),
|
|
88
|
+
verdict_reason: closedEnumValues<ResolverChallengeVerdictReason>()([
|
|
89
|
+
"human_puzzle",
|
|
90
|
+
"solve_failed",
|
|
91
|
+
]),
|
|
92
|
+
transport_phase: closedEnumValues<ResolverTelemetryPhase>()([
|
|
93
|
+
"create_task",
|
|
94
|
+
"poll_result",
|
|
95
|
+
"cleanup",
|
|
96
|
+
"measure_ip",
|
|
97
|
+
"fetch_script",
|
|
98
|
+
"generate_payload",
|
|
99
|
+
"post_payload",
|
|
100
|
+
]),
|
|
101
|
+
resolver_identity_source: closedEnumValues<ProxyUserAgentSource>()(["declared", "defaulted"]),
|
|
102
|
+
operation: closedEnumValues<"client.close" | "context.close">()([
|
|
103
|
+
"client.close",
|
|
104
|
+
"context.close",
|
|
105
|
+
]),
|
|
106
|
+
} as const;
|
|
107
|
+
|
|
108
|
+
type ResolverClosedEnumSpanAttributes = typeof RESOLVER_CLOSED_ENUM_SPAN_ATTRIBUTES;
|
|
109
|
+
type Present<T> = Exclude<T, undefined>;
|
|
110
|
+
/** Properties typed as a finite union of string literals, never plain `string`. */
|
|
111
|
+
type FiniteStringKeys<T> = {
|
|
112
|
+
[K in keyof T]-?: [Present<T[K]>] extends [never]
|
|
113
|
+
? never
|
|
114
|
+
: [Present<T[K]>] extends [string]
|
|
115
|
+
? string extends Present<T[K]>
|
|
116
|
+
? never
|
|
117
|
+
: K
|
|
118
|
+
: never;
|
|
119
|
+
}[keyof T];
|
|
120
|
+
type DeclaredClosedEnumAttributes<T> = {
|
|
121
|
+
[K in keyof T]: K extends FiniteStringKeys<T>
|
|
122
|
+
? K extends keyof ResolverClosedEnumSpanAttributes
|
|
123
|
+
? [Present<T[K]>] extends [ResolverClosedEnumSpanAttributes[K][number]]
|
|
124
|
+
? T[K]
|
|
125
|
+
: never
|
|
126
|
+
: never
|
|
127
|
+
: T[K];
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Resolver span attributes. Every property typed as a finite string union must be declared in
|
|
132
|
+
* `RESOLVER_CLOSED_ENUM_SPAN_ATTRIBUTES` with all of its members, so a new closed-enum attribute
|
|
133
|
+
* cannot silently become free text that fail-closed redaction suppresses.
|
|
134
|
+
*/
|
|
135
|
+
export function resolverSpanAttributes<const T extends Record<string, unknown>>(
|
|
136
|
+
attributes: T & DeclaredClosedEnumAttributes<T>,
|
|
137
|
+
): T {
|
|
138
|
+
return attributes;
|
|
139
|
+
}
|
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
ResolverVendorUnavailableError,
|
|
5
5
|
} from "./resolver-vendors/types.js";
|
|
6
6
|
import type { TraceRecorder } from "./trace.js";
|
|
7
|
+
import { resolverSpanAttributes } from "./resolver-span-attributes.js";
|
|
7
8
|
|
|
8
9
|
export const RESOLVER_PAID_USAGE_SPAN = "resolver.usage";
|
|
9
10
|
|
|
@@ -22,9 +23,11 @@ export type ResolverPaidUsageEndpoint =
|
|
|
22
23
|
* but has no published price, so it is recorded with 0 units and `billing: "unconfirmed"`
|
|
23
24
|
* rather than invented as a paid unit; the span still proves the call was made.
|
|
24
25
|
*/
|
|
26
|
+
export type ResolverPaidUsageBilling = "metered" | "unconfirmed";
|
|
27
|
+
|
|
25
28
|
const ENDPOINT_BILLING: Record<
|
|
26
29
|
ResolverPaidUsageEndpoint,
|
|
27
|
-
{ readonly billable_units: 0 | 1; readonly billing:
|
|
30
|
+
{ readonly billable_units: 0 | 1; readonly billing: ResolverPaidUsageBilling }
|
|
28
31
|
> = {
|
|
29
32
|
"capsolver:create_task": { billable_units: 1, billing: "metered" },
|
|
30
33
|
"twocaptcha:create_task": { billable_units: 1, billing: "metered" },
|
|
@@ -78,7 +81,7 @@ export async function recordPaidResolverCreate<T>(options: {
|
|
|
78
81
|
}
|
|
79
82
|
return options.create();
|
|
80
83
|
}
|
|
81
|
-
const baseAttributes = {
|
|
84
|
+
const baseAttributes = resolverSpanAttributes({
|
|
82
85
|
vendor: options.vendor,
|
|
83
86
|
challenge_kind: options.kind,
|
|
84
87
|
endpoint: options.endpoint,
|
|
@@ -87,10 +90,10 @@ export async function recordPaidResolverCreate<T>(options: {
|
|
|
87
90
|
...(options.usage ? { vendor_index: options.usage.vendorIndex } : {}),
|
|
88
91
|
...(options.round === undefined ? {} : { round: options.round }),
|
|
89
92
|
resolver_identity_scope: options.usage?.resolverIdentityScope,
|
|
90
|
-
};
|
|
93
|
+
});
|
|
91
94
|
return options.traceRecorder.runSpan(RESOLVER_PAID_USAGE_SPAN, options.create, {
|
|
92
95
|
attributes: baseAttributes,
|
|
93
|
-
onSuccess: () => ({ outcome: "success" }),
|
|
94
|
-
onError: (error) => ({ outcome: errorOutcome(error, options.signal) }),
|
|
96
|
+
onSuccess: () => resolverSpanAttributes({ outcome: "success" }),
|
|
97
|
+
onError: (error) => resolverSpanAttributes({ outcome: errorOutcome(error, options.signal) }),
|
|
95
98
|
});
|
|
96
99
|
}
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
} from "../../types.js";
|
|
9
9
|
import { type BrowserClientOptions, createBrowserClient } from "../browser.js";
|
|
10
10
|
import type { TraceRecorder } from "../trace.js";
|
|
11
|
+
import { resolverSpanAttributes } from "../resolver-span-attributes.js";
|
|
11
12
|
import type { BrowserTelemetrySink } from "../browser-telemetry.js";
|
|
12
13
|
import { resolverChallengeIssuingIdentity } from "./bindings.js";
|
|
13
14
|
import { assertResolverHostAllowed, normalizedResolverHostname } from "./hosts.js";
|
|
@@ -218,15 +219,15 @@ async function runBoundedCleanup(
|
|
|
218
219
|
|
|
219
220
|
await traceRecorder
|
|
220
221
|
.runSpan("resolver.vendor.cleanup", boundedCleanup, {
|
|
221
|
-
attributes: {
|
|
222
|
+
attributes: resolverSpanAttributes({
|
|
222
223
|
vendor: BROWSER_VENDOR_ID,
|
|
223
224
|
challenge_kind: challengeKind,
|
|
224
225
|
operation,
|
|
225
|
-
},
|
|
226
|
+
}),
|
|
226
227
|
onError(error) {
|
|
227
|
-
return {
|
|
228
|
+
return resolverSpanAttributes({
|
|
228
229
|
error_message: error instanceof Error ? error.message : String(error),
|
|
229
|
-
};
|
|
230
|
+
});
|
|
230
231
|
},
|
|
231
232
|
})
|
|
232
233
|
.catch(() => undefined);
|
|
@@ -3,6 +3,7 @@ import type { ChallengeSolution, ProviderChallenge, ProviderChallengeKind } from
|
|
|
3
3
|
import { redactSensitiveText } from "../request-options.js";
|
|
4
4
|
import { DEFAULT_STEALTH_PROFILE } from "../stealth.js";
|
|
5
5
|
import { recordPaidResolverCreate } from "../resolver-usage.js";
|
|
6
|
+
import { resolverSpanAttributes } from "../resolver-span-attributes.js";
|
|
6
7
|
import type { TraceRecorder } from "../trace.js";
|
|
7
8
|
import { assertResolverHostAllowed } from "./hosts.js";
|
|
8
9
|
import {
|
|
@@ -413,7 +414,7 @@ function spanErrorAttributes(
|
|
|
413
414
|
phase: CapsolverOperationPhase,
|
|
414
415
|
): Record<string, unknown> | undefined {
|
|
415
416
|
if (error instanceof ResolverVendorUnavailableError) {
|
|
416
|
-
return {
|
|
417
|
+
return resolverSpanAttributes({
|
|
417
418
|
unavailability_reason: error.reason,
|
|
418
419
|
transport_phase: error.phase,
|
|
419
420
|
...(error instanceof CapsolverUnavailableError
|
|
@@ -425,21 +426,21 @@ function spanErrorAttributes(
|
|
|
425
426
|
response_length: error.responseLength,
|
|
426
427
|
}
|
|
427
428
|
: {}),
|
|
428
|
-
};
|
|
429
|
+
});
|
|
429
430
|
}
|
|
430
431
|
if (error instanceof CapsolverVerdictError) {
|
|
431
|
-
return {
|
|
432
|
+
return resolverSpanAttributes({
|
|
432
433
|
verdict_reason: error.reason,
|
|
433
434
|
transport_phase: error.phase,
|
|
434
435
|
vendor_error_code: error.errorCode,
|
|
435
436
|
vendor_error_description: error.errorDescription,
|
|
436
|
-
};
|
|
437
|
+
});
|
|
437
438
|
}
|
|
438
439
|
if (error instanceof CapsolverSolveTimeoutError) {
|
|
439
|
-
return {
|
|
440
|
+
return resolverSpanAttributes({
|
|
440
441
|
unavailability_reason: "timeout",
|
|
441
442
|
transport_phase: phase,
|
|
442
|
-
};
|
|
443
|
+
});
|
|
443
444
|
}
|
|
444
445
|
return undefined;
|
|
445
446
|
}
|
|
@@ -606,10 +607,10 @@ export function createCapsolverResolverVendorAdapter(
|
|
|
606
607
|
};
|
|
607
608
|
const taskId = traceRecorder
|
|
608
609
|
? await traceRecorder.runSpan("resolver.vendor.create_task", createTask, {
|
|
609
|
-
attributes: {
|
|
610
|
+
attributes: resolverSpanAttributes({
|
|
610
611
|
vendor: CAPSOLVER_VENDOR_ID,
|
|
611
612
|
challenge_kind: challenge.kind,
|
|
612
|
-
},
|
|
613
|
+
}),
|
|
613
614
|
onError: (error) => spanErrorAttributes(error, "create_task"),
|
|
614
615
|
})
|
|
615
616
|
: await createTask();
|
|
@@ -689,10 +690,10 @@ export function createCapsolverResolverVendorAdapter(
|
|
|
689
690
|
};
|
|
690
691
|
return traceRecorder
|
|
691
692
|
? await traceRecorder.runSpan("resolver.vendor.poll_result", pollResult, {
|
|
692
|
-
attributes: {
|
|
693
|
+
attributes: resolverSpanAttributes({
|
|
693
694
|
vendor: CAPSOLVER_VENDOR_ID,
|
|
694
695
|
challenge_kind: challenge.kind,
|
|
695
|
-
},
|
|
696
|
+
}),
|
|
696
697
|
onError: (error) => spanErrorAttributes(error, "poll_result"),
|
|
697
698
|
})
|
|
698
699
|
: await pollResult();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ChallengeSolution, ProviderChallenge } from "../../types.js";
|
|
2
2
|
import type { TraceRecorder } from "../trace.js";
|
|
3
3
|
import { recordPaidResolverCreate } from "../resolver-usage.js";
|
|
4
|
+
import { resolverSpanAttributes } from "../resolver-span-attributes.js";
|
|
4
5
|
import { assertResolverHostAllowed } from "./hosts.js";
|
|
5
6
|
import {
|
|
6
7
|
type ResolverIdentity,
|
|
@@ -300,19 +301,19 @@ function spanErrorAttributes(
|
|
|
300
301
|
phase: TwoCaptchaOperationPhase,
|
|
301
302
|
): Record<string, unknown> | undefined {
|
|
302
303
|
if (error instanceof ResolverVendorUnavailableError) {
|
|
303
|
-
return {
|
|
304
|
+
return resolverSpanAttributes({
|
|
304
305
|
unavailability_reason: error.reason,
|
|
305
306
|
transport_phase: error.phase,
|
|
306
|
-
};
|
|
307
|
+
});
|
|
307
308
|
}
|
|
308
309
|
// The solve budget raises TwoCaptchaSolveTimeoutError inside the spanned closure and
|
|
309
310
|
// the outer catch converts it only after the span has been finalized, so the span has
|
|
310
311
|
// to recognize it here or slow solves lose exactly the attribution these spans add.
|
|
311
312
|
if (error instanceof TwoCaptchaSolveTimeoutError) {
|
|
312
|
-
return {
|
|
313
|
+
return resolverSpanAttributes({
|
|
313
314
|
unavailability_reason: "timeout",
|
|
314
315
|
transport_phase: phase,
|
|
315
|
-
};
|
|
316
|
+
});
|
|
316
317
|
}
|
|
317
318
|
return undefined;
|
|
318
319
|
}
|
|
@@ -468,10 +469,10 @@ export function createTwoCaptchaResolverVendorAdapter(
|
|
|
468
469
|
};
|
|
469
470
|
const taskId = traceRecorder
|
|
470
471
|
? await traceRecorder.runSpan("resolver.vendor.create_task", createTask, {
|
|
471
|
-
attributes: {
|
|
472
|
+
attributes: resolverSpanAttributes({
|
|
472
473
|
vendor: TWOCAPTCHA_VENDOR_ID,
|
|
473
474
|
challenge_kind: challenge.kind,
|
|
474
|
-
},
|
|
475
|
+
}),
|
|
475
476
|
onError: (error) => spanErrorAttributes(error, "create_task"),
|
|
476
477
|
})
|
|
477
478
|
: await createTask();
|
|
@@ -517,10 +518,10 @@ export function createTwoCaptchaResolverVendorAdapter(
|
|
|
517
518
|
};
|
|
518
519
|
return traceRecorder
|
|
519
520
|
? await traceRecorder.runSpan("resolver.vendor.poll_result", pollResult, {
|
|
520
|
-
attributes: {
|
|
521
|
+
attributes: resolverSpanAttributes({
|
|
521
522
|
vendor: TWOCAPTCHA_VENDOR_ID,
|
|
522
523
|
challenge_kind: challenge.kind,
|
|
523
|
-
},
|
|
524
|
+
}),
|
|
524
525
|
onError: (error) => spanErrorAttributes(error, "poll_result"),
|
|
525
526
|
})
|
|
526
527
|
: await pollResult();
|
package/src/runtime/resolver.ts
CHANGED
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
DEFAULT_RESOLVER_TIMEOUT_MS,
|
|
65
65
|
kindRequiresClientProfile,
|
|
66
66
|
} from "./resolver-config.js";
|
|
67
|
+
import { resolverSpanAttributes } from "./resolver-span-attributes.js";
|
|
67
68
|
import { DEFAULT_STEALTH_PROFILE } from "./stealth.js";
|
|
68
69
|
import type { TraceRecorder } from "./trace.js";
|
|
69
70
|
import type { BrowserTelemetrySink } from "./browser-telemetry.js";
|
|
@@ -595,7 +596,7 @@ function unavailableAttempt(
|
|
|
595
596
|
|
|
596
597
|
function unavailableSpanAttributes(error: ResolverVendorUnavailableError): Record<string, unknown> {
|
|
597
598
|
const attempt = unavailableAttempt(error);
|
|
598
|
-
return {
|
|
599
|
+
return resolverSpanAttributes({
|
|
599
600
|
unavailability_reason: error.reason,
|
|
600
601
|
missing_fields: error.missingFields,
|
|
601
602
|
cause_name: attempt.cause?.name,
|
|
@@ -603,7 +604,7 @@ function unavailableSpanAttributes(error: ResolverVendorUnavailableError): Recor
|
|
|
603
604
|
upstream_host: attempt.upstreamHost,
|
|
604
605
|
transport_phase: attempt.phase,
|
|
605
606
|
transport_round: attempt.round,
|
|
606
|
-
};
|
|
607
|
+
});
|
|
607
608
|
}
|
|
608
609
|
|
|
609
610
|
type ResolverAttemptDiagnostics = {
|
|
@@ -994,8 +995,8 @@ async function invalidateResolverSolutionWithOutcome(
|
|
|
994
995
|
return await invalidate();
|
|
995
996
|
}
|
|
996
997
|
return await metadata.traceRecorder.runSpan("resolver.cache.invalidate", invalidate, {
|
|
997
|
-
attributes: { challenge_kind: challenge.kind },
|
|
998
|
-
onSuccess: (outcome) => ({ outcome }),
|
|
998
|
+
attributes: resolverSpanAttributes({ challenge_kind: challenge.kind }),
|
|
999
|
+
onSuccess: (outcome) => resolverSpanAttributes({ outcome }),
|
|
999
1000
|
});
|
|
1000
1001
|
}
|
|
1001
1002
|
|
|
@@ -1257,12 +1258,12 @@ function createResolverChainClient(options: {
|
|
|
1257
1258
|
};
|
|
1258
1259
|
solution = traceRecorder
|
|
1259
1260
|
? await traceRecorder.runSpan("resolver.vendor.attempt", solveAttempt, {
|
|
1260
|
-
attributes: {
|
|
1261
|
+
attributes: resolverSpanAttributes({
|
|
1261
1262
|
vendor: adapter.id,
|
|
1262
1263
|
challenge_kind: challenge.kind,
|
|
1263
1264
|
client_profile: options.clientProfile,
|
|
1264
1265
|
resolver_identity_source: identityResolution.userAgentSource,
|
|
1265
|
-
},
|
|
1266
|
+
}),
|
|
1266
1267
|
onError(error) {
|
|
1267
1268
|
return error instanceof ResolverVendorUnavailableError
|
|
1268
1269
|
? unavailableSpanAttributes(error)
|
|
@@ -1,20 +1,28 @@
|
|
|
1
|
+
import { SDKError } from "../errors.js";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Header names the stealth transport owns.
|
|
3
5
|
*
|
|
4
6
|
* `ctx.stealth` composes these from the Chrome emulation and the request class,
|
|
5
7
|
* so a caller value is rejected at request time with `SDKError` code
|
|
6
|
-
* `STEALTH_HEADER_OVERRIDE_UNSUPPORTED` (
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* `STEALTH_HEADER_OVERRIDE_UNSUPPORTED` ({@link assertCallerHeadersSupported}).
|
|
9
|
+
* The offline test harness (`runStandardTests`) calls the same function on
|
|
10
|
+
* every `ctx.stealth` request it serves, so a provider test fails exactly where
|
|
11
|
+
* production would, whichever file or variable the header came from. That is
|
|
12
|
+
* the cross-file enforcement.
|
|
13
|
+
*
|
|
14
|
+
* The `browser-version-literal` lint reports a subset of these names — the
|
|
15
|
+
* `sec-fetch-*` and `sec-ch-ua*` families — in files that use `ctx.stealth`
|
|
16
|
+
* (`isReportedOwnedHeaderName` in lint.ts). Both read this module, so the lint
|
|
17
|
+
* cannot claim a rejection the runtime does not perform; it reports
|
|
18
|
+
* deliberately less than the runtime rejects (host, connection,
|
|
12
19
|
* accept-encoding and a non-literal user-agent stay silent — see that
|
|
13
20
|
* predicate for why), so a green `apifuse check` is not proof that no owned
|
|
14
21
|
* header is set.
|
|
15
22
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
23
|
+
* Kept off the stealth runtime: the only import is `errors.js`, whose own
|
|
24
|
+
* imports are type-only, so the lazily loaded CLI lint path does not pull the
|
|
25
|
+
* stealth runtime in.
|
|
18
26
|
*/
|
|
19
27
|
export const SDK_OWNED_EXACT_CHROME_HEADERS: ReadonlySet<string> = new Set([
|
|
20
28
|
"host",
|
|
@@ -39,3 +47,21 @@ export function isStealthOwnedHeaderName(name: string): boolean {
|
|
|
39
47
|
SDK_OWNED_EXACT_CHROME_HEADERS.has(lower) || lower.startsWith(SDK_OWNED_CHROME_HEADER_PREFIX)
|
|
40
48
|
);
|
|
41
49
|
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Throws `SDKError` `STEALTH_HEADER_OVERRIDE_UNSUPPORTED` for the first caller
|
|
53
|
+
* header `ctx.stealth` owns. An `undefined` value is skipped, as the transport
|
|
54
|
+
* drops it before sending. The stealth runtime and the offline test harness
|
|
55
|
+
* both call this, so the two cannot disagree about what is rejected.
|
|
56
|
+
*/
|
|
57
|
+
export function assertCallerHeadersSupported(
|
|
58
|
+
headers: Readonly<Record<string, string | readonly string[] | undefined>>,
|
|
59
|
+
): void {
|
|
60
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
61
|
+
if (value === undefined || !isStealthOwnedHeaderName(name)) continue;
|
|
62
|
+
throw new SDKError(
|
|
63
|
+
`Stealth transport owns the "${name.toLowerCase()}" header; remove it from headers.`,
|
|
64
|
+
{ code: "STEALTH_HEADER_OVERRIDE_UNSUPPORTED" },
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -22,7 +22,12 @@ export type StealthTelemetryErrorCode =
|
|
|
22
22
|
| "PROXY_REQUIRED"
|
|
23
23
|
| "other";
|
|
24
24
|
|
|
25
|
-
export type StealthTelemetryRequestClass =
|
|
25
|
+
export type StealthTelemetryRequestClass =
|
|
26
|
+
| "navigation"
|
|
27
|
+
| "script_navigation"
|
|
28
|
+
| "form_post"
|
|
29
|
+
| "xhr"
|
|
30
|
+
| "post";
|
|
26
31
|
|
|
27
32
|
export type StealthTelemetryAttemptKind = "request" | "resolver" | "proxy_diagnostic";
|
|
28
33
|
|