@apifuse/provider-sdk 2.2.0-beta.13 → 2.2.0-beta.15
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 +70 -6
- package/CHANGELOG.md +12 -0
- package/dist/define.js +9 -0
- package/dist/errors.d.ts +13 -0
- package/dist/errors.js +25 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/native-egress-policy.d.ts +27 -0
- package/dist/native-egress-policy.js +225 -0
- package/dist/provider.d.ts +3 -3
- package/dist/provider.js +2 -2
- package/dist/runtime/executor.js +17 -2
- package/dist/runtime/http.js +189 -9
- package/dist/runtime/native-network.d.ts +39 -4
- package/dist/runtime/native-network.js +365 -20
- package/dist/runtime/redirects.d.ts +29 -0
- package/dist/runtime/redirects.js +36 -0
- package/dist/runtime/stealth.js +16 -44
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/serve.d.ts +9 -0
- package/dist/server/serve.js +190 -51
- package/dist/server/types.d.ts +3 -0
- package/dist/server/types.js +1 -0
- package/dist/stateful/stateful-provider-owner-forwarder.js +9 -1
- package/dist/testing/run.js +32 -13
- package/dist/types.d.ts +23 -2
- package/package.json +1 -1
- package/src/define.ts +11 -0
- package/src/errors.ts +37 -0
- package/src/index.ts +12 -1
- package/src/native-egress-policy.ts +285 -0
- package/src/provider.ts +7 -0
- package/src/runtime/executor.ts +22 -2
- package/src/runtime/http.ts +217 -9
- package/src/runtime/native-network.ts +474 -22
- package/src/runtime/redirects.ts +66 -0
- package/src/runtime/stealth.ts +20 -47
- package/src/server/index.ts +2 -0
- package/src/server/serve.ts +226 -68
- package/src/server/types.ts +1 -0
- package/src/stateful/stateful-provider-owner-forwarder.ts +9 -1
- package/src/testing/run.ts +39 -14
- package/src/types.ts +32 -2
package/src/runtime/http.ts
CHANGED
|
@@ -4,11 +4,12 @@ import {
|
|
|
4
4
|
resolvePolicyTransportAttemptCap,
|
|
5
5
|
resolveProxyConfigAsync,
|
|
6
6
|
} from "../config/loader.js";
|
|
7
|
-
import { ProviderError, TransportError } from "../errors.js";
|
|
7
|
+
import { HttpRedirectError, ProviderError, TransportError } from "../errors.js";
|
|
8
8
|
import { parseSseStream, readableBytes, readableLines, readableTextChunks } from "../stream.js";
|
|
9
9
|
import type {
|
|
10
10
|
HttpClient,
|
|
11
11
|
HttpMethod,
|
|
12
|
+
HttpRedirectPolicy,
|
|
12
13
|
HttpResponse,
|
|
13
14
|
HttpRetrySummary,
|
|
14
15
|
HttpStreamResponse,
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
shouldRetryProxyTransportAttempt,
|
|
29
30
|
validateUnsafeProxyTransportRetryMethods,
|
|
30
31
|
} from "./proxy-retry-policy.js";
|
|
32
|
+
import { evaluateRedirectHop, isRedirectStatus, resolveRedirectUrl } from "./redirects.js";
|
|
31
33
|
import {
|
|
32
34
|
normalizeHttpRequestBody,
|
|
33
35
|
redactSensitiveError,
|
|
@@ -312,6 +314,200 @@ function resolveHttpUrl(baseUrl: string | undefined, url: string): string {
|
|
|
312
314
|
|
|
313
315
|
type NativeFetchInit = RequestInit & { proxy?: string };
|
|
314
316
|
|
|
317
|
+
const MAX_HTTP_REDIRECT_HOPS = 20;
|
|
318
|
+
const HTTP_REDIRECT_POLICY_FIELDS = new Set(["mode", "maxHops"]);
|
|
319
|
+
const REDIRECT_BODY_HEADERS = new Set([
|
|
320
|
+
"content-encoding",
|
|
321
|
+
"content-language",
|
|
322
|
+
"content-location",
|
|
323
|
+
"content-type",
|
|
324
|
+
]);
|
|
325
|
+
const MALFORMED_REDIRECT_TARGET = "[malformed redirect target]";
|
|
326
|
+
|
|
327
|
+
function invalidHttpRedirectPolicy(message: string, cause?: Error): TransportError {
|
|
328
|
+
return new TransportError(`Invalid ctx.http redirectPolicy: ${message}`, {
|
|
329
|
+
code: "http_redirect_policy_invalid",
|
|
330
|
+
...(cause ? { cause } : {}),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Snapshot untrusted caller input synchronously, before proxy resolution or fetch. */
|
|
335
|
+
function normalizeHttpRedirectPolicy(value: unknown): HttpRedirectPolicy | undefined {
|
|
336
|
+
if (value === undefined) return undefined;
|
|
337
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
338
|
+
throw invalidHttpRedirectPolicy("expected an object");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
const keys = Reflect.ownKeys(value);
|
|
343
|
+
for (const key of keys) {
|
|
344
|
+
if (typeof key !== "string" || !HTTP_REDIRECT_POLICY_FIELDS.has(key)) {
|
|
345
|
+
throw invalidHttpRedirectPolicy(`unknown field ${String(key)}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
for (const field of HTTP_REDIRECT_POLICY_FIELDS) {
|
|
349
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, field);
|
|
350
|
+
if (!descriptor || !("value" in descriptor)) {
|
|
351
|
+
throw invalidHttpRedirectPolicy(`${field} must be an own data property`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const record = value as Record<string, unknown>;
|
|
356
|
+
if (record.mode !== "same-origin") {
|
|
357
|
+
throw invalidHttpRedirectPolicy('mode must be "same-origin"');
|
|
358
|
+
}
|
|
359
|
+
if (
|
|
360
|
+
typeof record.maxHops !== "number" ||
|
|
361
|
+
!Number.isInteger(record.maxHops) ||
|
|
362
|
+
record.maxHops < 0 ||
|
|
363
|
+
record.maxHops > MAX_HTTP_REDIRECT_HOPS
|
|
364
|
+
) {
|
|
365
|
+
throw invalidHttpRedirectPolicy(
|
|
366
|
+
`maxHops must be an integer from 0 to ${MAX_HTTP_REDIRECT_HOPS}`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return { mode: "same-origin", maxHops: record.maxHops };
|
|
371
|
+
} catch (error) {
|
|
372
|
+
if (error instanceof TransportError) throw error;
|
|
373
|
+
throw invalidHttpRedirectPolicy(
|
|
374
|
+
"could not be inspected safely",
|
|
375
|
+
error instanceof Error ? error : undefined,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function snapshotHttpRedirectPolicy(options: RequestOptions): HttpRedirectPolicy | undefined {
|
|
381
|
+
try {
|
|
382
|
+
return normalizeHttpRedirectPolicy(options.redirectPolicy);
|
|
383
|
+
} catch (error) {
|
|
384
|
+
if (error instanceof TransportError) throw error;
|
|
385
|
+
throw invalidHttpRedirectPolicy(
|
|
386
|
+
"could not be read safely",
|
|
387
|
+
error instanceof Error ? error : undefined,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function withoutRedirectBodyHeaders(headers: HeadersInit | undefined): Headers {
|
|
393
|
+
const nextHeaders = new Headers(headers);
|
|
394
|
+
for (const name of REDIRECT_BODY_HEADERS) nextHeaders.delete(name);
|
|
395
|
+
return nextHeaders;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function redirectDiagnosticTarget(value: string): string {
|
|
399
|
+
try {
|
|
400
|
+
const parsed = new URL(value);
|
|
401
|
+
// Origin omits URL userinfo. Keeping only origin + path makes diagnostics
|
|
402
|
+
// useful while structurally excluding every query value and fragment,
|
|
403
|
+
// including attacker-chosen keys the provider did not declare sensitive.
|
|
404
|
+
const redactedQuery = parsed.search ? "?[REDACTED]" : "";
|
|
405
|
+
return parsed.origin === "null"
|
|
406
|
+
? `${parsed.protocol}<opaque-target>`
|
|
407
|
+
: `${parsed.origin}${parsed.pathname}${redactedQuery}`;
|
|
408
|
+
} catch {
|
|
409
|
+
return MALFORMED_REDIRECT_TARGET;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function discardRedirectResponseBody(response: Response): void {
|
|
414
|
+
try {
|
|
415
|
+
const cancellation = response.body?.cancel();
|
|
416
|
+
if (cancellation) void cancellation.catch(() => undefined);
|
|
417
|
+
} catch {
|
|
418
|
+
// The redirect decision is security-significant and must not be replaced
|
|
419
|
+
// or delayed by an upstream body's cancellation failure. Cancellation was
|
|
420
|
+
// attempted; redirect evaluation continues without awaiting its completion.
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function fetchWithHttpRedirectPolicy(
|
|
425
|
+
requestUrl: string,
|
|
426
|
+
requestInit: NativeFetchInit,
|
|
427
|
+
policy: HttpRedirectPolicy | undefined,
|
|
428
|
+
): Promise<Response> {
|
|
429
|
+
if (!policy) return fetch(requestUrl, requestInit);
|
|
430
|
+
|
|
431
|
+
const initialUrl = new URL(requestUrl);
|
|
432
|
+
if (initialUrl.protocol !== "http:" && initialUrl.protocol !== "https:") {
|
|
433
|
+
throw new TransportError("ctx.http redirectPolicy requires an HTTP(S) origin", {
|
|
434
|
+
code: "transport_invalid_url",
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const initialOrigin = initialUrl.origin;
|
|
439
|
+
let currentUrl = requestUrl;
|
|
440
|
+
let method = normalizeHttpMethod(requestInit.method ?? "GET");
|
|
441
|
+
let body = requestInit.body;
|
|
442
|
+
let headers = requestInit.headers;
|
|
443
|
+
let followedHops = 0;
|
|
444
|
+
const visitedRequests = new Set([`${method} ${currentUrl}`]);
|
|
445
|
+
|
|
446
|
+
while (true) {
|
|
447
|
+
const response = await fetch(currentUrl, {
|
|
448
|
+
...requestInit,
|
|
449
|
+
body,
|
|
450
|
+
headers,
|
|
451
|
+
method,
|
|
452
|
+
redirect: "manual",
|
|
453
|
+
});
|
|
454
|
+
if (!isRedirectStatus(response.status)) return response;
|
|
455
|
+
|
|
456
|
+
const location = response.headers.get("location");
|
|
457
|
+
discardRedirectResponseBody(response);
|
|
458
|
+
let nextUrlString: string | undefined;
|
|
459
|
+
try {
|
|
460
|
+
nextUrlString = resolveRedirectUrl(location || undefined, currentUrl);
|
|
461
|
+
} catch {
|
|
462
|
+
const target = MALFORMED_REDIRECT_TARGET;
|
|
463
|
+
throw new HttpRedirectError(`Redirect response has malformed Location target ${target}`, {
|
|
464
|
+
reason: "missing_location",
|
|
465
|
+
target,
|
|
466
|
+
status: response.status,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const decision = evaluateRedirectHop({
|
|
471
|
+
status: response.status,
|
|
472
|
+
method,
|
|
473
|
+
nextUrl: nextUrlString,
|
|
474
|
+
shouldStop: nextUrlString ? new URL(nextUrlString).origin !== initialOrigin : false,
|
|
475
|
+
redirectCount: followedHops + 1,
|
|
476
|
+
maxHops: policy.maxHops,
|
|
477
|
+
visitedRequests,
|
|
478
|
+
});
|
|
479
|
+
if (decision.kind === "stop") {
|
|
480
|
+
const target = decision.nextUrl ? redirectDiagnosticTarget(decision.nextUrl) : undefined;
|
|
481
|
+
const message = (() => {
|
|
482
|
+
switch (decision.reason) {
|
|
483
|
+
case "stopped":
|
|
484
|
+
return `Redirect policy refused cross-origin target ${target}`;
|
|
485
|
+
case "max_hops":
|
|
486
|
+
return `Redirect policy reached maxHops before target ${target}`;
|
|
487
|
+
case "loop":
|
|
488
|
+
return `Redirect loop refused target ${target}`;
|
|
489
|
+
case "missing_location":
|
|
490
|
+
return `Redirect response from ${redirectDiagnosticTarget(currentUrl)} is missing Location`;
|
|
491
|
+
}
|
|
492
|
+
})();
|
|
493
|
+
throw new HttpRedirectError(message, {
|
|
494
|
+
reason: decision.reason,
|
|
495
|
+
...(target ? { target } : {}),
|
|
496
|
+
status: response.status,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
if (decision.nextMethod !== method) {
|
|
500
|
+
body = undefined;
|
|
501
|
+
headers = withoutRedirectBodyHeaders(headers);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
method = decision.nextMethod;
|
|
505
|
+
currentUrl = decision.nextUrl;
|
|
506
|
+
followedHops += 1;
|
|
507
|
+
visitedRequests.add(`${method} ${currentUrl}`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
315
511
|
async function resolveNativeProxy(
|
|
316
512
|
options: RequestOptions,
|
|
317
513
|
clientOptions: HttpClientOptions,
|
|
@@ -419,9 +615,11 @@ async function fetchNativeHttp(
|
|
|
419
615
|
if (options.body !== undefined) {
|
|
420
616
|
requestInit.body = normalizeNativeFetchBody(options.body);
|
|
421
617
|
}
|
|
422
|
-
const response = await
|
|
423
|
-
|
|
424
|
-
|
|
618
|
+
const response = await fetchWithHttpRedirectPolicy(
|
|
619
|
+
requestUrl,
|
|
620
|
+
requestInit,
|
|
621
|
+
options.redirectPolicy,
|
|
622
|
+
);
|
|
425
623
|
const headers = Object.fromEntries(response.headers.entries());
|
|
426
624
|
|
|
427
625
|
if (statusRetryCodes && response.status >= 400) {
|
|
@@ -492,9 +690,11 @@ async function fetchNativeHttpStream(
|
|
|
492
690
|
if (options.body !== undefined) {
|
|
493
691
|
requestInit.body = normalizeNativeFetchBody(options.body);
|
|
494
692
|
}
|
|
495
|
-
const response = await
|
|
496
|
-
|
|
497
|
-
|
|
693
|
+
const response = await fetchWithHttpRedirectPolicy(
|
|
694
|
+
requestUrl,
|
|
695
|
+
requestInit,
|
|
696
|
+
options.redirectPolicy,
|
|
697
|
+
);
|
|
498
698
|
|
|
499
699
|
if (response.status >= 400 && options.throwOnHttpError !== false) {
|
|
500
700
|
await drainNativeResponseBody(response);
|
|
@@ -553,7 +753,11 @@ export function createHttpClient(
|
|
|
553
753
|
);
|
|
554
754
|
}
|
|
555
755
|
assertNoHttpTransportOverrides(options);
|
|
556
|
-
const
|
|
756
|
+
const redirectPolicy = snapshotHttpRedirectPolicy(options);
|
|
757
|
+
const headersOptions = {
|
|
758
|
+
...withClientHeaders(options, clientOptions, options.body),
|
|
759
|
+
redirectPolicy,
|
|
760
|
+
};
|
|
557
761
|
const methodName = normalizeHttpMethod(method);
|
|
558
762
|
const explicitRetry = headersOptions.retry !== undefined;
|
|
559
763
|
const retryOptions =
|
|
@@ -761,8 +965,12 @@ export function createHttpClient(
|
|
|
761
965
|
);
|
|
762
966
|
}
|
|
763
967
|
assertNoHttpTransportOverrides(options);
|
|
968
|
+
const redirectPolicy = snapshotHttpRedirectPolicy(options);
|
|
764
969
|
return {
|
|
765
|
-
headersOptions:
|
|
970
|
+
headersOptions: {
|
|
971
|
+
...withClientHeaders(options, clientOptions, options.body),
|
|
972
|
+
redirectPolicy,
|
|
973
|
+
},
|
|
766
974
|
methodName: normalizeHttpMethod(method),
|
|
767
975
|
};
|
|
768
976
|
} catch (error) {
|