@apifuse/provider-sdk 2.2.0-beta.67 → 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 +34 -2
- package/CHANGELOG.md +8 -0
- package/README.md +18 -7
- package/bin/apifuse-record.ts +81 -21
- package/bin/apifuse-submit-check.ts +170 -28
- package/dist/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
- package/dist/cli/templates/provider/README.md.tpl +17 -9
- package/dist/config/loader.d.ts +16 -0
- package/dist/config/loader.js +25 -8
- package/dist/error-resolution.d.ts +13 -0
- package/dist/error-resolution.js +72 -35
- package/dist/fixture-sanitization.js +159 -15
- package/dist/lint.d.ts +2 -0
- package/dist/lint.js +549 -11
- package/dist/runtime/diagnostic-redactor.d.ts +6 -1
- package/dist/runtime/diagnostic-redactor.js +16 -14
- package/dist/runtime/executor.js +3 -0
- package/dist/runtime/http.d.ts +2 -0
- package/dist/runtime/http.js +17 -103
- package/dist/runtime/instrumentation.js +4 -1
- package/dist/runtime/provider.d.ts +7 -0
- package/dist/runtime/provider.js +14 -0
- 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/index.d.ts +1 -0
- package/dist/server/serve-implementation.d.ts +2 -1
- package/dist/server/serve-implementation.js +45 -30
- package/dist/server/state-backend-report.d.ts +70 -0
- package/dist/server/state-backend-report.js +113 -0
- 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/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
- package/src/cli/templates/provider/README.md.tpl +17 -9
- package/src/config/loader.ts +49 -12
- package/src/error-resolution.ts +89 -35
- package/src/fixture-sanitization.ts +175 -17
- package/src/lint.ts +595 -10
- package/src/runtime/diagnostic-redactor.ts +19 -13
- package/src/runtime/executor.ts +3 -0
- package/src/runtime/http.ts +24 -112
- package/src/runtime/instrumentation.ts +4 -1
- package/src/runtime/provider.ts +15 -0
- 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/index.ts +4 -0
- package/src/server/serve-implementation.ts +58 -31
- package/src/server/state-backend-report.ts +185 -0
- package/src/testing/run.ts +15 -2
- package/src/trace-sanitization.ts +1 -1
- package/src/types.ts +30 -6
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
REDACTED_QUERY_VALUE,
|
|
7
7
|
type SensitiveValueVariant,
|
|
8
8
|
} from "./request-options.js";
|
|
9
|
+
import { RESOLVER_CLOSED_ENUM_SPAN_ATTRIBUTES } from "./resolver-span-attributes.js";
|
|
9
10
|
|
|
10
11
|
export type DiagnosticRedactor = (text: string) => string;
|
|
11
12
|
export const REDACTION_FAILED = "[REDACTION_FAILED]";
|
|
@@ -577,11 +578,19 @@ export function redactDiagnosticAttributeKey(key: string, redact?: DiagnosticRed
|
|
|
577
578
|
export function redactCriticalDiagnosticText(text: string, redact?: DiagnosticRedactor): string {
|
|
578
579
|
return redactDiagnosticText(text, redact ? (criticalRedactors.get(redact) ?? redact) : undefined);
|
|
579
580
|
}
|
|
581
|
+
/**
|
|
582
|
+
* The redactor for one attribute value. A value its producer declares as a closed-enum literal for
|
|
583
|
+
* this key is a contract, like a typed number: exhaustion suppression skips it, while registered
|
|
584
|
+
* credentials still match. Any other value under the same key stays free text.
|
|
585
|
+
*/
|
|
580
586
|
export function diagnosticAttributeRedactor(
|
|
581
587
|
key: string,
|
|
582
588
|
redact?: DiagnosticRedactor,
|
|
589
|
+
value?: unknown,
|
|
583
590
|
): DiagnosticRedactor | undefined {
|
|
584
|
-
if (
|
|
591
|
+
if (typeof value === "string" && CLOSED_ENUM_ATTRIBUTE_VALUES.get(key)?.has(value)) {
|
|
592
|
+
return diagnosticStructuredRedactor(redact);
|
|
593
|
+
}
|
|
585
594
|
return CRITICAL_ATTRIBUTE_KEYS.has(key) && redact
|
|
586
595
|
? (criticalRedactors.get(redact) ?? redact)
|
|
587
596
|
: redact;
|
|
@@ -600,18 +609,15 @@ const CRITICAL_ATTRIBUTE_KEYS = new Set([
|
|
|
600
609
|
"http.status_code",
|
|
601
610
|
]);
|
|
602
611
|
|
|
603
|
-
// SDK-owned closed enums are contracts, like typed numbers and booleans.
|
|
604
|
-
//
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
"taxonomy",
|
|
613
|
-
"taxonomyVersion",
|
|
614
|
-
]);
|
|
612
|
+
// SDK-owned closed enums are contracts, like typed numbers and booleans. The table is the
|
|
613
|
+
// producers' own declaration of which span attributes are closed enums and of every literal each
|
|
614
|
+
// one can carry; it exempts only exhaustion suppression, and registered credentials still match.
|
|
615
|
+
const CLOSED_ENUM_ATTRIBUTE_VALUES: ReadonlyMap<string, ReadonlySet<string>> = new Map(
|
|
616
|
+
Object.entries(RESOLVER_CLOSED_ENUM_SPAN_ATTRIBUTES).map(([key, values]) => [
|
|
617
|
+
key,
|
|
618
|
+
new Set<string>(values),
|
|
619
|
+
]),
|
|
620
|
+
);
|
|
615
621
|
|
|
616
622
|
// Bigints normalize to strings for OTLP. Carry their type policy through internal
|
|
617
623
|
// detached copies without treating arbitrary numeric-looking free text as typed.
|
package/src/runtime/executor.ts
CHANGED
|
@@ -39,6 +39,9 @@ export async function executeOperation<
|
|
|
39
39
|
if (!operation) {
|
|
40
40
|
throw new ProviderError(`Unknown operation: ${provider.id}/${operationId}`, {
|
|
41
41
|
code: "NOT_FOUND",
|
|
42
|
+
// No upstream was asked: keep the registered NOT_FOUND code from
|
|
43
|
+
// attributing this to one.
|
|
44
|
+
category: "provider_error",
|
|
42
45
|
fix: `Valid operations: ${Object.keys(provider.operations).join(", ")}`,
|
|
43
46
|
});
|
|
44
47
|
}
|
package/src/runtime/http.ts
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
resolvePolicyTransportAttemptCap,
|
|
5
5
|
resolveProxyConfigAsync,
|
|
6
6
|
} from "../config/loader.js";
|
|
7
|
-
import {
|
|
7
|
+
import { ProviderError, TransportError } from "../errors.js";
|
|
8
8
|
import { parseSseStream, readableBytes, readableLines, readableTextChunks } from "../stream.js";
|
|
9
9
|
import type {
|
|
10
10
|
HttpAttemptContext,
|
|
@@ -36,7 +36,13 @@ import {
|
|
|
36
36
|
type HttpTelemetrySink,
|
|
37
37
|
type HttpAttemptTelemetryEvent,
|
|
38
38
|
} from "./http-telemetry.js";
|
|
39
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
invalidRedirectPolicy,
|
|
41
|
+
isRedirectStatus,
|
|
42
|
+
nextSameOriginRedirectHop,
|
|
43
|
+
normalizeRedirectPolicy,
|
|
44
|
+
type RedirectPolicyTransport,
|
|
45
|
+
} from "./redirects.js";
|
|
40
46
|
import {
|
|
41
47
|
normalizeHttpRequestBody,
|
|
42
48
|
redactSensitiveError,
|
|
@@ -578,7 +584,8 @@ function normalizeHttpMethod(method: string): Uppercase<HttpMethod> {
|
|
|
578
584
|
}
|
|
579
585
|
}
|
|
580
586
|
|
|
581
|
-
|
|
587
|
+
/** Whether `url` carries its own scheme, so it needs no `upstream.baseUrl` to resolve. */
|
|
588
|
+
export function isAbsoluteUrl(url: string): boolean {
|
|
582
589
|
return /^[a-z][a-z\d+\-.]*:/i.test(url);
|
|
583
590
|
}
|
|
584
591
|
|
|
@@ -589,74 +596,24 @@ function resolveHttpUrl(baseUrl: string | undefined, url: string): string {
|
|
|
589
596
|
type NativeFetchInit = RequestInit & { proxy?: string };
|
|
590
597
|
|
|
591
598
|
const MAX_HTTP_REDIRECT_HOPS = 20;
|
|
592
|
-
const
|
|
599
|
+
const HTTP_REDIRECT_POLICY_TRANSPORT: RedirectPolicyTransport = {
|
|
600
|
+
label: "ctx.http",
|
|
601
|
+
maxHops: MAX_HTTP_REDIRECT_HOPS,
|
|
602
|
+
};
|
|
593
603
|
const REDIRECT_BODY_HEADERS = new Set([
|
|
594
604
|
"content-encoding",
|
|
595
605
|
"content-language",
|
|
596
606
|
"content-location",
|
|
597
607
|
"content-type",
|
|
598
608
|
]);
|
|
599
|
-
const MALFORMED_REDIRECT_TARGET = "[malformed redirect target]";
|
|
600
|
-
|
|
601
|
-
function invalidHttpRedirectPolicy(message: string, cause?: Error): TransportError {
|
|
602
|
-
return new TransportError(`Invalid ctx.http redirectPolicy: ${message}`, {
|
|
603
|
-
code: "http_redirect_policy_invalid",
|
|
604
|
-
...(cause ? { cause } : {}),
|
|
605
|
-
});
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
/** Snapshot untrusted caller input synchronously, before proxy resolution or fetch. */
|
|
609
|
-
function normalizeHttpRedirectPolicy(value: unknown): HttpRedirectPolicy | undefined {
|
|
610
|
-
if (value === undefined) return undefined;
|
|
611
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
612
|
-
throw invalidHttpRedirectPolicy("expected an object");
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
try {
|
|
616
|
-
const keys = Reflect.ownKeys(value);
|
|
617
|
-
for (const key of keys) {
|
|
618
|
-
if (typeof key !== "string" || !HTTP_REDIRECT_POLICY_FIELDS.has(key)) {
|
|
619
|
-
throw invalidHttpRedirectPolicy(`unknown field ${String(key)}`);
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
for (const field of HTTP_REDIRECT_POLICY_FIELDS) {
|
|
623
|
-
const descriptor = Object.getOwnPropertyDescriptor(value, field);
|
|
624
|
-
if (!descriptor || !("value" in descriptor)) {
|
|
625
|
-
throw invalidHttpRedirectPolicy(`${field} must be an own data property`);
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
const record = value as Record<string, unknown>;
|
|
630
|
-
if (record.mode !== "same-origin") {
|
|
631
|
-
throw invalidHttpRedirectPolicy('mode must be "same-origin"');
|
|
632
|
-
}
|
|
633
|
-
if (
|
|
634
|
-
typeof record.maxHops !== "number" ||
|
|
635
|
-
!Number.isInteger(record.maxHops) ||
|
|
636
|
-
record.maxHops < 0 ||
|
|
637
|
-
record.maxHops > MAX_HTTP_REDIRECT_HOPS
|
|
638
|
-
) {
|
|
639
|
-
throw invalidHttpRedirectPolicy(
|
|
640
|
-
`maxHops must be an integer from 0 to ${MAX_HTTP_REDIRECT_HOPS}`,
|
|
641
|
-
);
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
return { mode: "same-origin", maxHops: record.maxHops };
|
|
645
|
-
} catch (error) {
|
|
646
|
-
if (error instanceof TransportError) throw error;
|
|
647
|
-
throw invalidHttpRedirectPolicy(
|
|
648
|
-
"could not be inspected safely",
|
|
649
|
-
error instanceof Error ? error : undefined,
|
|
650
|
-
);
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
609
|
|
|
654
610
|
function snapshotHttpRedirectPolicy(options: RequestOptions): HttpRedirectPolicy | undefined {
|
|
655
611
|
try {
|
|
656
|
-
return
|
|
612
|
+
return normalizeRedirectPolicy(options.redirectPolicy, HTTP_REDIRECT_POLICY_TRANSPORT);
|
|
657
613
|
} catch (error) {
|
|
658
614
|
if (error instanceof TransportError) throw error;
|
|
659
|
-
throw
|
|
615
|
+
throw invalidRedirectPolicy(
|
|
616
|
+
HTTP_REDIRECT_POLICY_TRANSPORT,
|
|
660
617
|
"could not be read safely",
|
|
661
618
|
error instanceof Error ? error : undefined,
|
|
662
619
|
);
|
|
@@ -669,21 +626,6 @@ function withoutRedirectBodyHeaders(headers: HeadersInit | undefined): Headers {
|
|
|
669
626
|
return nextHeaders;
|
|
670
627
|
}
|
|
671
628
|
|
|
672
|
-
function redirectDiagnosticTarget(value: string): string {
|
|
673
|
-
try {
|
|
674
|
-
const parsed = new URL(value);
|
|
675
|
-
// Origin omits URL userinfo. Keeping only origin + path makes diagnostics
|
|
676
|
-
// useful while structurally excluding every query value and fragment,
|
|
677
|
-
// including attacker-chosen keys the provider did not declare sensitive.
|
|
678
|
-
const redactedQuery = parsed.search ? "?[REDACTED]" : "";
|
|
679
|
-
return parsed.origin === "null"
|
|
680
|
-
? `${parsed.protocol}<opaque-target>`
|
|
681
|
-
: `${parsed.origin}${parsed.pathname}${redactedQuery}`;
|
|
682
|
-
} catch {
|
|
683
|
-
return MALFORMED_REDIRECT_TARGET;
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
|
|
687
629
|
function discardRedirectResponseBody(response: Response): void {
|
|
688
630
|
try {
|
|
689
631
|
const cancellation = response.body?.cancel();
|
|
@@ -729,47 +671,17 @@ async function fetchWithHttpRedirectPolicy(
|
|
|
729
671
|
|
|
730
672
|
const location = response.headers.get("location");
|
|
731
673
|
discardRedirectResponseBody(response);
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
throw new HttpRedirectError(`Redirect response has malformed Location target ${target}`, {
|
|
738
|
-
reason: "missing_location",
|
|
739
|
-
target,
|
|
740
|
-
status: response.status,
|
|
741
|
-
});
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
const decision = evaluateRedirectHop({
|
|
674
|
+
const decision = nextSameOriginRedirectHop({
|
|
675
|
+
policy,
|
|
676
|
+
initialOrigin,
|
|
677
|
+
currentUrl,
|
|
678
|
+
responseUrl: currentUrl,
|
|
745
679
|
status: response.status,
|
|
746
680
|
method,
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
redirectCount: followedHops + 1,
|
|
750
|
-
maxHops: policy.maxHops,
|
|
681
|
+
location: location ?? undefined,
|
|
682
|
+
followedHops,
|
|
751
683
|
visitedRequests,
|
|
752
684
|
});
|
|
753
|
-
if (decision.kind === "stop") {
|
|
754
|
-
const target = decision.nextUrl ? redirectDiagnosticTarget(decision.nextUrl) : undefined;
|
|
755
|
-
const message = (() => {
|
|
756
|
-
switch (decision.reason) {
|
|
757
|
-
case "stopped":
|
|
758
|
-
return `Redirect policy refused cross-origin target ${target}`;
|
|
759
|
-
case "max_hops":
|
|
760
|
-
return `Redirect policy reached maxHops before target ${target}`;
|
|
761
|
-
case "loop":
|
|
762
|
-
return `Redirect loop refused target ${target}`;
|
|
763
|
-
case "missing_location":
|
|
764
|
-
return `Redirect response from ${redirectDiagnosticTarget(currentUrl)} is missing Location`;
|
|
765
|
-
}
|
|
766
|
-
})();
|
|
767
|
-
throw new HttpRedirectError(message, {
|
|
768
|
-
reason: decision.reason,
|
|
769
|
-
...(target ? { target } : {}),
|
|
770
|
-
status: response.status,
|
|
771
|
-
});
|
|
772
|
-
}
|
|
773
685
|
if (decision.nextMethod !== method) {
|
|
774
686
|
body = undefined;
|
|
775
687
|
headers = withoutRedirectBodyHeaders(headers);
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
type TraceContext,
|
|
27
27
|
} from "./trace.js";
|
|
28
28
|
import { RESOLVER_INSTRUMENTATION_METADATA } from "./resolver-shared.js";
|
|
29
|
+
import { resolverSpanAttributes } from "./resolver-span-attributes.js";
|
|
29
30
|
|
|
30
31
|
export interface InstrumentationOptions extends CreateTraceContextOptions {}
|
|
31
32
|
|
|
@@ -707,7 +708,9 @@ function wrapNamespace<T extends object>(
|
|
|
707
708
|
"resolver.solve",
|
|
708
709
|
() => Reflect.apply(value, namespaceTarget, [args[0], args[1], recorder]),
|
|
709
710
|
{
|
|
710
|
-
attributes: challengeKind
|
|
711
|
+
attributes: challengeKind
|
|
712
|
+
? resolverSpanAttributes({ challenge_kind: challengeKind })
|
|
713
|
+
: undefined,
|
|
711
714
|
},
|
|
712
715
|
);
|
|
713
716
|
};
|
package/src/runtime/provider.ts
CHANGED
|
@@ -13,3 +13,18 @@ export function getProviderBaseUrl(provider: ProviderDefinition): string | undef
|
|
|
13
13
|
|
|
14
14
|
return undefined;
|
|
15
15
|
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The base `ctx.stealth` resolves relative request URLs (and seeds its cookie
|
|
19
|
+
* jar's default origin) against: the provider's first declared
|
|
20
|
+
* `upstream.baseUrl`, else `https://<allowedHosts[0]>`. `undefined` means the
|
|
21
|
+
* provider declares neither, and `serve` binds no stealth transport at all.
|
|
22
|
+
*/
|
|
23
|
+
export function getProviderStealthBaseUrl(provider: ProviderDefinition): string | undefined {
|
|
24
|
+
const baseUrl = getProviderBaseUrl(provider);
|
|
25
|
+
if (baseUrl) {
|
|
26
|
+
return baseUrl;
|
|
27
|
+
}
|
|
28
|
+
const firstHost = provider.allowedHosts?.[0];
|
|
29
|
+
return firstHost ? `https://${firstHost}` : undefined;
|
|
30
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { getPublicSuffix } from "tough-cookie";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Request-body header names a redirect drops when it changes the method
|
|
5
|
+
* (Fetch "request-body-header name").
|
|
6
|
+
*/
|
|
7
|
+
const REDIRECT_BODY_HEADERS: ReadonlySet<string> = new Set([
|
|
8
|
+
"content-encoding",
|
|
9
|
+
"content-language",
|
|
10
|
+
"content-location",
|
|
11
|
+
"content-type",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Credentials a redirect never carries to another origin. Fetch removes
|
|
16
|
+
* `Authorization` on a cross-origin redirect; `Proxy-Authorization` is a
|
|
17
|
+
* credential of the same kind.
|
|
18
|
+
*/
|
|
19
|
+
const CROSS_ORIGIN_CREDENTIAL_HEADERS: ReadonlySet<string> = new Set([
|
|
20
|
+
"authorization",
|
|
21
|
+
"proxy-authorization",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
/** One redirect hop: the URL the headers were sent to, and the next request. */
|
|
25
|
+
export interface RedirectHop {
|
|
26
|
+
readonly from: string;
|
|
27
|
+
readonly to: string;
|
|
28
|
+
readonly methodChanged: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Schemeful site (HTML "same site"): scheme plus registrable domain from the
|
|
33
|
+
* public suffix list, or the bare host when there is none (an IP address or
|
|
34
|
+
* a single-label host).
|
|
35
|
+
*/
|
|
36
|
+
function schemefulSite(url: URL): string {
|
|
37
|
+
const host = url.hostname;
|
|
38
|
+
const registrable = getPublicSuffix(host, { allowSpecialUseDomain: true, ignoreError: true });
|
|
39
|
+
return `${url.protocol}//${registrable ?? host}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The caller headers the next redirect hop carries, derived from the headers
|
|
44
|
+
* the previous hop sent. Both stealth redirect walkers compute every hop's
|
|
45
|
+
* headers here, so the rules hold on every path:
|
|
46
|
+
*
|
|
47
|
+
* - a method change drops the request-body headers;
|
|
48
|
+
* - a cross-origin hop drops `Authorization` and `Proxy-Authorization`;
|
|
49
|
+
* - a cross-site hop drops an explicit `Cookie`, after which the session
|
|
50
|
+
* cookie jar supplies cookies by its own domain rules, as a browser does.
|
|
51
|
+
*
|
|
52
|
+
* A header dropped on one hop stays dropped for the rest of the chain.
|
|
53
|
+
*/
|
|
54
|
+
export function redirectHopHeaders<T>(
|
|
55
|
+
hop: RedirectHop,
|
|
56
|
+
headers: Readonly<Record<string, T>>,
|
|
57
|
+
): Record<string, T> {
|
|
58
|
+
const from = new URL(hop.from);
|
|
59
|
+
const to = new URL(hop.to);
|
|
60
|
+
const crossOrigin = from.origin !== to.origin;
|
|
61
|
+
const crossSite = schemefulSite(from) !== schemefulSite(to);
|
|
62
|
+
return Object.fromEntries(
|
|
63
|
+
Object.entries(headers).filter(([name]) => {
|
|
64
|
+
const lower = name.toLowerCase();
|
|
65
|
+
if (hop.methodChanged && REDIRECT_BODY_HEADERS.has(lower)) return false;
|
|
66
|
+
if (crossOrigin && CROSS_ORIGIN_CREDENTIAL_HEADERS.has(lower)) return false;
|
|
67
|
+
if (crossSite && lower === "cookie") return false;
|
|
68
|
+
return true;
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
}
|
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
|
+
}
|