@ai-sdk/provider-utils 4.0.59 → 4.0.61

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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # @ai-sdk/provider-utils
2
2
 
3
+ ## 4.0.61
4
+
5
+ ### Patch Changes
6
+
7
+ - 5aa3086: feat(openai-compatible): support URL-based image responses
8
+
9
+ Add opt-in URL validation and credential origin handling to `getFromApi` for URL-based image responses. Validate redirect targets, strip unsafe request headers, and drop credentials on cross-origin redirects while preserving existing unvalidated requests.
10
+
11
+ ## 4.0.60
12
+
13
+ ### Patch Changes
14
+
15
+ - Updated dependencies [739e9f6]
16
+ - @ai-sdk/provider@3.0.19
17
+
3
18
  ## 4.0.59
4
19
 
5
20
  ### Patch Changes
package/dist/index.d.mts CHANGED
@@ -250,24 +250,34 @@ declare function fetchWithValidatedEndpoint({ url, init, fetch: customFetch, tru
250
250
  redirect?: 'error' | 'manual';
251
251
  }): Promise<Response>;
252
252
  /**
253
- * Fetches a URL while enforcing the SSRF download guard on every hop.
253
+ * Fetches a URL while enforcing the download guard on every hop.
254
254
  *
255
255
  * Redirects are followed manually (`redirect: 'manual'`) so each hop is
256
256
  * validated with {@link validateDownloadUrl} *before* it is requested. Relying
257
257
  * on the default `redirect: 'follow'` would issue the request to a redirect
258
258
  * target (e.g. an internal address) before we ever see its URL, defeating the
259
- * SSRF guard.
259
+ * guard.
260
+ *
261
+ * Request headers are also protected: {@link sanitizeRequestHeaders} strips
262
+ * proxy/metadata/cookie/hop-by-hop headers before the first request, and all
263
+ * caller headers except `User-Agent` are dropped on a cross-origin redirect.
264
+ * Credentials and custom headers are preserved on the first hop for backwards
265
+ * compatibility. The caller must ensure that the initial URL may receive them.
266
+ * The fetch spec only strips `Authorization` on cross-origin redirects because
267
+ * in a browser, CORS preflighting protects custom headers; there is no CORS on
268
+ * the server, so provider API keys carried in custom headers (e.g. `x-key`)
269
+ * must be dropped here as well.
260
270
  *
261
271
  * A `redirect: 'manual'` request yields an unreadable opaque response in the
262
272
  * browser (and in other spec-compliant fetch implementations), so the redirect
263
273
  * target cannot be validated here. In a real browser this is safe to follow
264
- * natively because SSRF is not reachable (fetch is constrained by CORS and
265
- * cannot reach a server's internal network or cloud-metadata). On any other
266
- * runtime we cannot validate the hop, so we fail closed rather than follow it
267
- * blindly and bypass the SSRF guard.
274
+ * natively because reaching an internal network is not possible (fetch is
275
+ * constrained by CORS and cannot reach a server's internal network or
276
+ * cloud-metadata). On any other runtime we cannot validate the hop, so we fail
277
+ * closed rather than follow it blindly and bypass the guard.
268
278
  *
269
279
  * A hop that is same-origin with `trustedOrigin` (the developer-configured
270
- * endpoint) skips target validation: that origin is exactly what an
280
+ * provider endpoint) skips target validation: that origin is exactly what an
271
281
  * unvalidated, config-derived request would fetch anyway, and validating it
272
282
  * would break legitimate self-hosted / localhost deployments whose response
273
283
  * URLs point back at the configured host. Hops on any other origin are always
@@ -278,7 +288,8 @@ declare function fetchWithValidatedEndpoint({ url, init, fetch: customFetch, tru
278
288
  *
279
289
  * On Node.js, the default fetch resolves every hostname through a validating
280
290
  * lookup hook and passes those exact addresses to the connector, preventing
281
- * hostname-to-private-IP and DNS-rebinding bypasses. Other runtimes should
291
+ * hostname-to-private-IP and DNS-rebinding bypasses. An injected fetch is
292
+ * responsible for equivalent connect-time validation. Other runtimes should
282
293
  * constrain egress at the network layer when handling untrusted URLs.
283
294
  *
284
295
  * @throws DownloadError if a hop is unsafe, the redirect limit is exceeded, or
@@ -291,8 +302,8 @@ declare function fetchWithValidatedRedirects({ url, headers, abortSignal, maxRed
291
302
  maxRedirects?: number;
292
303
  fetch?: FetchFunction;
293
304
  /**
294
- * A developer-configured origin whose hops skip target validation. Must
295
- * never be derived from response data.
305
+ * A developer-configured origin (e.g. the provider's `baseURL`) whose hops
306
+ * skip target validation. Must never be derived from response data.
296
307
  */
297
308
  trustedOrigin?: string;
298
309
  }): Promise<Response>;
@@ -467,13 +478,44 @@ declare const createJsonResponseHandler: <T>(responseSchema: FlexibleSchema<T>)
467
478
  declare const createBinaryResponseHandler: () => ResponseHandler<Uint8Array>;
468
479
  declare const createStatusCodeErrorResponseHandler: () => ResponseHandler<APICallError>;
469
480
 
470
- declare const getFromApi: <T>({ url, headers, successfulResponseHandler, failedResponseHandler, abortSignal, fetch, }: {
481
+ declare const getFromApi: <T>({ url, headers, successfulResponseHandler, failedResponseHandler, abortSignal, fetch, validateUrl, credentialedOrigin, trustedOrigin, }: {
471
482
  url: string;
472
483
  headers?: Record<string, string | undefined>;
473
484
  failedResponseHandler: ResponseHandler<Error>;
474
485
  successfulResponseHandler: ResponseHandler<T>;
475
486
  abortSignal?: AbortSignal;
476
487
  fetch?: FetchFunction;
488
+ /**
489
+ * Set `true` when `url` is untrusted (e.g. taken from a provider response
490
+ * body): it is routed through {@link fetchWithValidatedRedirects}, which
491
+ * rejects private/loopback/link-local targets and re-validates redirect
492
+ * hops; blocked URLs throw `DownloadError`. Set `false` only for URLs built
493
+ * from a developer-configured endpoint.
494
+ *
495
+ * Optional for backwards compatibility with existing callers; omitting it
496
+ * behaves like `false` (no validation).
497
+ */
498
+ validateUrl?: boolean;
499
+ /**
500
+ * When set, `headers` are sent only if `url` is same-origin with this origin
501
+ * (the user-agent suffix is always kept). Pass the provider's configured
502
+ * base URL alongside `validateUrl: true` so credentials never ride a request
503
+ * to a response-supplied host on a different origin (e.g. a CDN). Redirects
504
+ * that later cross origin drop all caller headers regardless (see
505
+ * {@link fetchWithValidatedRedirects}).
506
+ */
507
+ credentialedOrigin?: string;
508
+ /**
509
+ * A developer-configured origin (e.g. the provider's `baseURL`) that is
510
+ * exempt from URL validation when `validateUrl` is `true`. A response URL
511
+ * (or redirect hop) that is same-origin with it is fetched without target
512
+ * validation — it points at exactly the host a config-derived
513
+ * `validateUrl: false` request would fetch anyway, so blocking it would
514
+ * only break legitimate self-hosted / localhost deployments whose response
515
+ * URLs point back at the configured host. Hops on any other origin are
516
+ * still validated. Must never be derived from response data.
517
+ */
518
+ trustedOrigin?: string;
477
519
  }) => Promise<{
478
520
  value: T;
479
521
  rawValue?: unknown;
package/dist/index.d.ts CHANGED
@@ -250,24 +250,34 @@ declare function fetchWithValidatedEndpoint({ url, init, fetch: customFetch, tru
250
250
  redirect?: 'error' | 'manual';
251
251
  }): Promise<Response>;
252
252
  /**
253
- * Fetches a URL while enforcing the SSRF download guard on every hop.
253
+ * Fetches a URL while enforcing the download guard on every hop.
254
254
  *
255
255
  * Redirects are followed manually (`redirect: 'manual'`) so each hop is
256
256
  * validated with {@link validateDownloadUrl} *before* it is requested. Relying
257
257
  * on the default `redirect: 'follow'` would issue the request to a redirect
258
258
  * target (e.g. an internal address) before we ever see its URL, defeating the
259
- * SSRF guard.
259
+ * guard.
260
+ *
261
+ * Request headers are also protected: {@link sanitizeRequestHeaders} strips
262
+ * proxy/metadata/cookie/hop-by-hop headers before the first request, and all
263
+ * caller headers except `User-Agent` are dropped on a cross-origin redirect.
264
+ * Credentials and custom headers are preserved on the first hop for backwards
265
+ * compatibility. The caller must ensure that the initial URL may receive them.
266
+ * The fetch spec only strips `Authorization` on cross-origin redirects because
267
+ * in a browser, CORS preflighting protects custom headers; there is no CORS on
268
+ * the server, so provider API keys carried in custom headers (e.g. `x-key`)
269
+ * must be dropped here as well.
260
270
  *
261
271
  * A `redirect: 'manual'` request yields an unreadable opaque response in the
262
272
  * browser (and in other spec-compliant fetch implementations), so the redirect
263
273
  * target cannot be validated here. In a real browser this is safe to follow
264
- * natively because SSRF is not reachable (fetch is constrained by CORS and
265
- * cannot reach a server's internal network or cloud-metadata). On any other
266
- * runtime we cannot validate the hop, so we fail closed rather than follow it
267
- * blindly and bypass the SSRF guard.
274
+ * natively because reaching an internal network is not possible (fetch is
275
+ * constrained by CORS and cannot reach a server's internal network or
276
+ * cloud-metadata). On any other runtime we cannot validate the hop, so we fail
277
+ * closed rather than follow it blindly and bypass the guard.
268
278
  *
269
279
  * A hop that is same-origin with `trustedOrigin` (the developer-configured
270
- * endpoint) skips target validation: that origin is exactly what an
280
+ * provider endpoint) skips target validation: that origin is exactly what an
271
281
  * unvalidated, config-derived request would fetch anyway, and validating it
272
282
  * would break legitimate self-hosted / localhost deployments whose response
273
283
  * URLs point back at the configured host. Hops on any other origin are always
@@ -278,7 +288,8 @@ declare function fetchWithValidatedEndpoint({ url, init, fetch: customFetch, tru
278
288
  *
279
289
  * On Node.js, the default fetch resolves every hostname through a validating
280
290
  * lookup hook and passes those exact addresses to the connector, preventing
281
- * hostname-to-private-IP and DNS-rebinding bypasses. Other runtimes should
291
+ * hostname-to-private-IP and DNS-rebinding bypasses. An injected fetch is
292
+ * responsible for equivalent connect-time validation. Other runtimes should
282
293
  * constrain egress at the network layer when handling untrusted URLs.
283
294
  *
284
295
  * @throws DownloadError if a hop is unsafe, the redirect limit is exceeded, or
@@ -291,8 +302,8 @@ declare function fetchWithValidatedRedirects({ url, headers, abortSignal, maxRed
291
302
  maxRedirects?: number;
292
303
  fetch?: FetchFunction;
293
304
  /**
294
- * A developer-configured origin whose hops skip target validation. Must
295
- * never be derived from response data.
305
+ * A developer-configured origin (e.g. the provider's `baseURL`) whose hops
306
+ * skip target validation. Must never be derived from response data.
296
307
  */
297
308
  trustedOrigin?: string;
298
309
  }): Promise<Response>;
@@ -467,13 +478,44 @@ declare const createJsonResponseHandler: <T>(responseSchema: FlexibleSchema<T>)
467
478
  declare const createBinaryResponseHandler: () => ResponseHandler<Uint8Array>;
468
479
  declare const createStatusCodeErrorResponseHandler: () => ResponseHandler<APICallError>;
469
480
 
470
- declare const getFromApi: <T>({ url, headers, successfulResponseHandler, failedResponseHandler, abortSignal, fetch, }: {
481
+ declare const getFromApi: <T>({ url, headers, successfulResponseHandler, failedResponseHandler, abortSignal, fetch, validateUrl, credentialedOrigin, trustedOrigin, }: {
471
482
  url: string;
472
483
  headers?: Record<string, string | undefined>;
473
484
  failedResponseHandler: ResponseHandler<Error>;
474
485
  successfulResponseHandler: ResponseHandler<T>;
475
486
  abortSignal?: AbortSignal;
476
487
  fetch?: FetchFunction;
488
+ /**
489
+ * Set `true` when `url` is untrusted (e.g. taken from a provider response
490
+ * body): it is routed through {@link fetchWithValidatedRedirects}, which
491
+ * rejects private/loopback/link-local targets and re-validates redirect
492
+ * hops; blocked URLs throw `DownloadError`. Set `false` only for URLs built
493
+ * from a developer-configured endpoint.
494
+ *
495
+ * Optional for backwards compatibility with existing callers; omitting it
496
+ * behaves like `false` (no validation).
497
+ */
498
+ validateUrl?: boolean;
499
+ /**
500
+ * When set, `headers` are sent only if `url` is same-origin with this origin
501
+ * (the user-agent suffix is always kept). Pass the provider's configured
502
+ * base URL alongside `validateUrl: true` so credentials never ride a request
503
+ * to a response-supplied host on a different origin (e.g. a CDN). Redirects
504
+ * that later cross origin drop all caller headers regardless (see
505
+ * {@link fetchWithValidatedRedirects}).
506
+ */
507
+ credentialedOrigin?: string;
508
+ /**
509
+ * A developer-configured origin (e.g. the provider's `baseURL`) that is
510
+ * exempt from URL validation when `validateUrl` is `true`. A response URL
511
+ * (or redirect hop) that is same-origin with it is fetched without target
512
+ * validation — it points at exactly the host a config-derived
513
+ * `validateUrl: false` request would fetch anyway, so blocking it would
514
+ * only break legitimate self-hosted / localhost deployments whose response
515
+ * URLs point back at the configured host. Hops on any other origin are
516
+ * still validated. Must never be derived from response data.
517
+ */
518
+ trustedOrigin?: string;
477
519
  }) => Promise<{
478
520
  value: T;
479
521
  rawValue?: unknown;
package/dist/index.js CHANGED
@@ -605,8 +605,45 @@ function getCurrentModulePath() {
605
605
  }
606
606
  }
607
607
 
608
+ // src/sanitize-request-headers.ts
609
+ var BLOCKED_REQUEST_HEADERS = [
610
+ // Hop-by-hop / transport (RFC 7230 §6.1)
611
+ "connection",
612
+ "keep-alive",
613
+ "te",
614
+ "trailer",
615
+ "transfer-encoding",
616
+ "upgrade",
617
+ // Host / virtual-host routing
618
+ "host",
619
+ // Proxy / origin spoofing
620
+ "forwarded",
621
+ "proxy-authorization",
622
+ "via",
623
+ "x-forwarded-for",
624
+ "x-forwarded-host",
625
+ "x-forwarded-proto",
626
+ "x-real-ip",
627
+ // Cloud metadata (GCP, AWS IMDSv1/v2, Azure, Alibaba, DigitalOcean)
628
+ "metadata",
629
+ "metadata-flavor",
630
+ "x-aws-ec2-metadata-token",
631
+ "x-metadata-token",
632
+ // Session / cookie
633
+ "cookie",
634
+ "set-cookie"
635
+ ];
636
+ function sanitizeRequestHeaders(input) {
637
+ const headers = new Headers(input);
638
+ for (const name2 of BLOCKED_REQUEST_HEADERS) {
639
+ headers.delete(name2);
640
+ }
641
+ return headers;
642
+ }
643
+
608
644
  // src/fetch-with-validated-redirects.ts
609
645
  var MAX_DOWNLOAD_REDIRECTS = 10;
646
+ var REDIRECT_STATUS_CODES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
610
647
  async function getValidatedFetch(customFetch) {
611
648
  return customFetch == null || customFetch === globalThis.fetch ? await getDefaultDownloadFetch() : customFetch;
612
649
  }
@@ -637,10 +674,14 @@ async function fetchWithValidatedRedirects({
637
674
  trustedOrigin
638
675
  }) {
639
676
  var _a2;
640
- const baseInit = { signal: abortSignal };
641
- if (headers !== void 0) {
642
- baseInit.headers = headers;
643
- }
677
+ let currentHeaders = headers === void 0 ? void 0 : sanitizeRequestHeaders(headers);
678
+ const perHopInit = (redirect) => {
679
+ const init = { signal: abortSignal, redirect };
680
+ if (currentHeaders !== void 0) {
681
+ init.headers = new Headers(currentHeaders);
682
+ }
683
+ return init;
684
+ };
644
685
  let currentUrl = url;
645
686
  for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) {
646
687
  const isTrustedHop = trustedOrigin !== void 0 && isSameOrigin(currentUrl, trustedOrigin);
@@ -648,10 +689,7 @@ async function fetchWithValidatedRedirects({
648
689
  validateDownloadUrl(currentUrl);
649
690
  }
650
691
  const fetch = isTrustedHop && customFetch != null ? customFetch : isTrustedHop ? globalThis.fetch : await getValidatedFetch(customFetch);
651
- const response = await fetch(currentUrl, {
652
- ...baseInit,
653
- redirect: "manual"
654
- });
692
+ const response = await fetch(currentUrl, perHopInit("manual"));
655
693
  if (response.type === "opaqueredirect") {
656
694
  if (!isBrowserRuntime()) {
657
695
  throw new DownloadError({
@@ -659,12 +697,19 @@ async function fetchWithValidatedRedirects({
659
697
  message: `Redirect from ${currentUrl} could not be validated and was blocked`
660
698
  });
661
699
  }
662
- return await fetch(currentUrl, { ...baseInit, redirect: "follow" });
700
+ return await fetch(currentUrl, perHopInit("follow"));
663
701
  }
664
702
  const location = (_a2 = response.headers) == null ? void 0 : _a2.get("location");
665
- if (response.status >= 300 && response.status < 400 && location) {
666
- await cancelResponseBody(response);
667
- currentUrl = new URL(location, currentUrl).toString();
703
+ if (REDIRECT_STATUS_CODES.has(response.status) && location) {
704
+ void cancelResponseBody(response);
705
+ const nextUrl = new URL(location, currentUrl).toString();
706
+ if (currentHeaders !== void 0 && !isSameOrigin(nextUrl, currentUrl)) {
707
+ const userAgent = currentHeaders.get("user-agent");
708
+ currentHeaders = new Headers(
709
+ userAgent == null ? void 0 : { "user-agent": userAgent }
710
+ );
711
+ }
712
+ currentUrl = nextUrl;
668
713
  continue;
669
714
  }
670
715
  return response;
@@ -954,7 +999,7 @@ function withUserAgentSuffix(headers, ...userAgentSuffixParts) {
954
999
  }
955
1000
 
956
1001
  // src/version.ts
957
- var VERSION = true ? "4.0.59" : "0.0.0-test";
1002
+ var VERSION = true ? "4.0.61" : "0.0.0-test";
958
1003
 
959
1004
  // src/get-from-api.ts
960
1005
  var getOriginalFetch = () => globalThis.fetch;
@@ -964,16 +1009,28 @@ var getFromApi = async ({
964
1009
  successfulResponseHandler,
965
1010
  failedResponseHandler,
966
1011
  abortSignal,
967
- fetch = getOriginalFetch()
1012
+ fetch,
1013
+ validateUrl,
1014
+ credentialedOrigin,
1015
+ trustedOrigin
968
1016
  }) => {
969
1017
  try {
970
- const response = await fetch(url, {
1018
+ const requestFetch = fetch != null ? fetch : getOriginalFetch();
1019
+ const outgoingHeaders = credentialedOrigin !== void 0 && !isSameOrigin(url, credentialedOrigin) ? {} : headers;
1020
+ const requestHeaders = withUserAgentSuffix(
1021
+ outgoingHeaders,
1022
+ `ai-sdk/provider-utils/${VERSION}`,
1023
+ getRuntimeEnvironmentUserAgent()
1024
+ );
1025
+ const response = validateUrl ? await fetchWithValidatedRedirects({
1026
+ url,
1027
+ headers: requestHeaders,
1028
+ abortSignal,
1029
+ fetch,
1030
+ trustedOrigin
1031
+ }) : await requestFetch(url, {
971
1032
  method: "GET",
972
- headers: withUserAgentSuffix(
973
- headers,
974
- `ai-sdk/provider-utils/${VERSION}`,
975
- getRuntimeEnvironmentUserAgent()
976
- ),
1033
+ headers: requestHeaders,
977
1034
  signal: abortSignal
978
1035
  });
979
1036
  const responseHeaders = extractResponseHeaders(response);