@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.
Files changed (44) hide show
  1. package/AUTHORING.md +70 -6
  2. package/CHANGELOG.md +12 -0
  3. package/dist/define.js +9 -0
  4. package/dist/errors.d.ts +13 -0
  5. package/dist/errors.js +25 -0
  6. package/dist/index.d.ts +3 -3
  7. package/dist/index.js +2 -2
  8. package/dist/native-egress-policy.d.ts +27 -0
  9. package/dist/native-egress-policy.js +225 -0
  10. package/dist/provider.d.ts +3 -3
  11. package/dist/provider.js +2 -2
  12. package/dist/runtime/executor.js +17 -2
  13. package/dist/runtime/http.js +189 -9
  14. package/dist/runtime/native-network.d.ts +39 -4
  15. package/dist/runtime/native-network.js +365 -20
  16. package/dist/runtime/redirects.d.ts +29 -0
  17. package/dist/runtime/redirects.js +36 -0
  18. package/dist/runtime/stealth.js +16 -44
  19. package/dist/server/index.d.ts +1 -1
  20. package/dist/server/index.js +1 -1
  21. package/dist/server/serve.d.ts +9 -0
  22. package/dist/server/serve.js +190 -51
  23. package/dist/server/types.d.ts +3 -0
  24. package/dist/server/types.js +1 -0
  25. package/dist/stateful/stateful-provider-owner-forwarder.js +9 -1
  26. package/dist/testing/run.js +32 -13
  27. package/dist/types.d.ts +23 -2
  28. package/package.json +1 -1
  29. package/src/define.ts +11 -0
  30. package/src/errors.ts +37 -0
  31. package/src/index.ts +12 -1
  32. package/src/native-egress-policy.ts +285 -0
  33. package/src/provider.ts +7 -0
  34. package/src/runtime/executor.ts +22 -2
  35. package/src/runtime/http.ts +217 -9
  36. package/src/runtime/native-network.ts +474 -22
  37. package/src/runtime/redirects.ts +66 -0
  38. package/src/runtime/stealth.ts +20 -47
  39. package/src/server/index.ts +2 -0
  40. package/src/server/serve.ts +226 -68
  41. package/src/server/types.ts +1 -0
  42. package/src/stateful/stateful-provider-owner-forwarder.ts +9 -1
  43. package/src/testing/run.ts +39 -14
  44. package/src/types.ts +32 -2
@@ -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 fetch(requestUrl, {
423
- ...requestInit,
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 fetch(requestUrl, {
496
- ...requestInit,
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 headersOptions = withClientHeaders(options, clientOptions, options.body);
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: withClientHeaders(options, clientOptions, options.body),
970
+ headersOptions: {
971
+ ...withClientHeaders(options, clientOptions, options.body),
972
+ redirectPolicy,
973
+ },
766
974
  methodName: normalizeHttpMethod(method),
767
975
  };
768
976
  } catch (error) {