@ai-sdk/provider-utils 4.0.60 → 4.0.62

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/provider-utils",
3
- "version": "4.0.60",
3
+ "version": "4.0.62",
4
4
  "license": "Apache-2.0",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Encodes a raw identifier as one URL path segment, including standalone dot
3
+ * segments that URL parsers would otherwise normalize. Pass unencoded values;
4
+ * for resource paths with intentional slashes, encode each segment separately.
5
+ */
6
+ export function encodePathSegment(value: string): string {
7
+ const encodedValue = encodeURIComponent(value);
8
+
9
+ // URL parsing normalizes both literal and percent-encoded dot segments.
10
+ return encodedValue === '.'
11
+ ? '%252E'
12
+ : encodedValue === '..'
13
+ ? '%252E%252E'
14
+ : encodedValue;
15
+ }
@@ -4,10 +4,16 @@ import type { FetchFunction } from './fetch-function';
4
4
  import { isBrowserRuntime } from './is-browser-runtime';
5
5
  import { isSameOrigin } from './is-same-origin';
6
6
  import { getDefaultDownloadFetch } from './safe-node-fetch';
7
+ import { sanitizeRequestHeaders } from './sanitize-request-headers';
7
8
  import { validateDownloadUrl } from './validate-download-url';
8
9
 
9
10
  const MAX_DOWNLOAD_REDIRECTS = 10;
10
11
 
12
+ // Redirect status codes per the fetch spec (https://fetch.spec.whatwg.org/#redirect-status).
13
+ // Notably 300 (Multiple Choices) and 304 (Not Modified) are NOT redirects,
14
+ // even when a server attaches a Location header.
15
+ const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]);
16
+
11
17
  async function getValidatedFetch(
12
18
  customFetch: FetchFunction | undefined,
13
19
  ): Promise<FetchFunction> {
@@ -66,24 +72,34 @@ export async function fetchWithValidatedEndpoint({
66
72
  }
67
73
 
68
74
  /**
69
- * Fetches a URL while enforcing the SSRF download guard on every hop.
75
+ * Fetches a URL while enforcing the download guard on every hop.
70
76
  *
71
77
  * Redirects are followed manually (`redirect: 'manual'`) so each hop is
72
78
  * validated with {@link validateDownloadUrl} *before* it is requested. Relying
73
79
  * on the default `redirect: 'follow'` would issue the request to a redirect
74
80
  * target (e.g. an internal address) before we ever see its URL, defeating the
75
- * SSRF guard.
81
+ * guard.
82
+ *
83
+ * Request headers are also protected: {@link sanitizeRequestHeaders} strips
84
+ * proxy/metadata/cookie/hop-by-hop headers before the first request, and all
85
+ * caller headers except `User-Agent` are dropped on a cross-origin redirect.
86
+ * Credentials and custom headers are preserved on the first hop for backwards
87
+ * compatibility. The caller must ensure that the initial URL may receive them.
88
+ * The fetch spec only strips `Authorization` on cross-origin redirects because
89
+ * in a browser, CORS preflighting protects custom headers; there is no CORS on
90
+ * the server, so provider API keys carried in custom headers (e.g. `x-key`)
91
+ * must be dropped here as well.
76
92
  *
77
93
  * A `redirect: 'manual'` request yields an unreadable opaque response in the
78
94
  * browser (and in other spec-compliant fetch implementations), so the redirect
79
95
  * target cannot be validated here. In a real browser this is safe to follow
80
- * natively because SSRF is not reachable (fetch is constrained by CORS and
81
- * cannot reach a server's internal network or cloud-metadata). On any other
82
- * runtime we cannot validate the hop, so we fail closed rather than follow it
83
- * blindly and bypass the SSRF guard.
96
+ * natively because reaching an internal network is not possible (fetch is
97
+ * constrained by CORS and cannot reach a server's internal network or
98
+ * cloud-metadata). On any other runtime we cannot validate the hop, so we fail
99
+ * closed rather than follow it blindly and bypass the guard.
84
100
  *
85
101
  * A hop that is same-origin with `trustedOrigin` (the developer-configured
86
- * endpoint) skips target validation: that origin is exactly what an
102
+ * provider endpoint) skips target validation: that origin is exactly what an
87
103
  * unvalidated, config-derived request would fetch anyway, and validating it
88
104
  * would break legitimate self-hosted / localhost deployments whose response
89
105
  * URLs point back at the configured host. Hops on any other origin are always
@@ -94,7 +110,8 @@ export async function fetchWithValidatedEndpoint({
94
110
  *
95
111
  * On Node.js, the default fetch resolves every hostname through a validating
96
112
  * lookup hook and passes those exact addresses to the connector, preventing
97
- * hostname-to-private-IP and DNS-rebinding bypasses. Other runtimes should
113
+ * hostname-to-private-IP and DNS-rebinding bypasses. An injected fetch is
114
+ * responsible for equivalent connect-time validation. Other runtimes should
98
115
  * constrain egress at the network layer when handling untrusted URLs.
99
116
  *
100
117
  * @throws DownloadError if a hop is unsafe, the redirect limit is exceeded, or
@@ -114,18 +131,26 @@ export async function fetchWithValidatedRedirects({
114
131
  maxRedirects?: number;
115
132
  fetch?: FetchFunction;
116
133
  /**
117
- * A developer-configured origin whose hops skip target validation. Must
118
- * never be derived from response data.
134
+ * A developer-configured origin (e.g. the provider's `baseURL`) whose hops
135
+ * skip target validation. Must never be derived from response data.
119
136
  */
120
137
  trustedOrigin?: string;
121
138
  }): Promise<Response> {
122
- // Per-hop request options. Only the `redirect` mode varies between hops, so
123
- // the rest is assembled once. `headers` is omitted entirely when not provided
124
- // so callers that send none issue a bare request.
125
- const baseInit: RequestInit = { signal: abortSignal };
126
- if (headers !== undefined) {
127
- baseInit.headers = headers;
128
- }
139
+ // Left undefined when no headers are provided (bare request); otherwise
140
+ // sanitized once and replaced on a cross-origin hop (credential drop below).
141
+ let currentHeaders =
142
+ headers === undefined ? undefined : sanitizeRequestHeaders(headers);
143
+
144
+ const perHopInit = (redirect: RequestRedirect): RequestInit => {
145
+ const init: RequestInit = { signal: abortSignal, redirect };
146
+ if (currentHeaders !== undefined) {
147
+ // Snapshot per hop: the platform fetch reads headers synchronously, but
148
+ // an injected fetch may defer, and hops between credential drops would
149
+ // otherwise share one instance.
150
+ init.headers = new Headers(currentHeaders);
151
+ }
152
+ return init;
153
+ };
129
154
 
130
155
  let currentUrl = url;
131
156
  // The bound also acts as a backstop against an unterminated redirect chain.
@@ -146,10 +171,7 @@ export async function fetchWithValidatedRedirects({
146
171
  ? globalThis.fetch
147
172
  : await getValidatedFetch(customFetch);
148
173
 
149
- const response = await fetch(currentUrl, {
150
- ...baseInit,
151
- redirect: 'manual',
152
- });
174
+ const response = await fetch(currentUrl, perHopInit('manual'));
153
175
 
154
176
  if (response.type === 'opaqueredirect') {
155
177
  if (!isBrowserRuntime()) {
@@ -158,16 +180,31 @@ export async function fetchWithValidatedRedirects({
158
180
  message: `Redirect from ${currentUrl} could not be validated and was blocked`,
159
181
  });
160
182
  }
161
- return await fetch(currentUrl, { ...baseInit, redirect: 'follow' });
183
+ return await fetch(currentUrl, perHopInit('follow'));
162
184
  }
163
185
 
164
186
  const location = response.headers?.get('location');
165
- if (response.status >= 300 && response.status < 400 && location) {
166
- // Release the redirect response's connection before moving to the next
167
- // hop. Whether that hop is followed or rejected by the SSRF guard, an
168
- // unconsumed 3xx body would leak the underlying socket.
169
- await cancelResponseBody(response);
170
- currentUrl = new URL(location, currentUrl).toString();
187
+ if (REDIRECT_STATUS_CODES.has(response.status) && location) {
188
+ // Start releasing the redirect response's connection before moving to
189
+ // the next hop. Do not wait for cancellation: when a fetch wrapper keeps
190
+ // an unread response clone, tee cancellation can remain pending until
191
+ // the cloned branch is consumed.
192
+ void cancelResponseBody(response);
193
+ const nextUrl = new URL(location, currentUrl).toString();
194
+
195
+ // Drop all caller headers except the user-agent before following a
196
+ // redirect that crosses origin. Only stripping Authorization (as the
197
+ // fetch spec does) is not enough on the server: providers authenticate
198
+ // with custom headers too (e.g. `x-key`), and without CORS there is
199
+ // nothing else stopping them from riding to a foreign host.
200
+ if (currentHeaders !== undefined && !isSameOrigin(nextUrl, currentUrl)) {
201
+ const userAgent = currentHeaders.get('user-agent');
202
+ currentHeaders = new Headers(
203
+ userAgent == null ? undefined : { 'user-agent': userAgent },
204
+ );
205
+ }
206
+
207
+ currentUrl = nextUrl;
171
208
  continue;
172
209
  }
173
210
 
@@ -1,8 +1,10 @@
1
1
  import { APICallError } from '@ai-sdk/provider';
2
2
  import { extractResponseHeaders } from './extract-response-headers';
3
+ import { fetchWithValidatedRedirects } from './fetch-with-validated-redirects';
3
4
  import type { FetchFunction } from './fetch-function';
4
5
  import { handleFetchError } from './handle-fetch-error';
5
6
  import { isAbortError } from './is-abort-error';
7
+ import { isSameOrigin } from './is-same-origin';
6
8
  import type { ResponseHandler } from './response-handler';
7
9
  import { getRuntimeEnvironmentUserAgent } from './get-runtime-environment-user-agent';
8
10
  import { withUserAgentSuffix } from './with-user-agent-suffix';
@@ -17,7 +19,10 @@ export const getFromApi = async <T>({
17
19
  successfulResponseHandler,
18
20
  failedResponseHandler,
19
21
  abortSignal,
20
- fetch = getOriginalFetch(),
22
+ fetch,
23
+ validateUrl,
24
+ credentialedOrigin,
25
+ trustedOrigin,
21
26
  }: {
22
27
  url: string;
23
28
  headers?: Record<string, string | undefined>;
@@ -25,17 +30,67 @@ export const getFromApi = async <T>({
25
30
  successfulResponseHandler: ResponseHandler<T>;
26
31
  abortSignal?: AbortSignal;
27
32
  fetch?: FetchFunction;
33
+ /**
34
+ * Set `true` when `url` is untrusted (e.g. taken from a provider response
35
+ * body): it is routed through {@link fetchWithValidatedRedirects}, which
36
+ * rejects private/loopback/link-local targets and re-validates redirect
37
+ * hops; blocked URLs throw `DownloadError`. Set `false` only for URLs built
38
+ * from a developer-configured endpoint.
39
+ *
40
+ * Optional for backwards compatibility with existing callers; omitting it
41
+ * behaves like `false` (no validation).
42
+ */
43
+ validateUrl?: boolean;
44
+ /**
45
+ * When set, `headers` are sent only if `url` is same-origin with this origin
46
+ * (the user-agent suffix is always kept). Pass the provider's configured
47
+ * base URL alongside `validateUrl: true` so credentials never ride a request
48
+ * to a response-supplied host on a different origin (e.g. a CDN). Redirects
49
+ * that later cross origin drop all caller headers regardless (see
50
+ * {@link fetchWithValidatedRedirects}).
51
+ */
52
+ credentialedOrigin?: string;
53
+ /**
54
+ * A developer-configured origin (e.g. the provider's `baseURL`) that is
55
+ * exempt from URL validation when `validateUrl` is `true`. A response URL
56
+ * (or redirect hop) that is same-origin with it is fetched without target
57
+ * validation — it points at exactly the host a config-derived
58
+ * `validateUrl: false` request would fetch anyway, so blocking it would
59
+ * only break legitimate self-hosted / localhost deployments whose response
60
+ * URLs point back at the configured host. Hops on any other origin are
61
+ * still validated. Must never be derived from response data.
62
+ */
63
+ trustedOrigin?: string;
28
64
  }) => {
29
65
  try {
30
- const response = await fetch(url, {
31
- method: 'GET',
32
- headers: withUserAgentSuffix(
33
- headers,
34
- `ai-sdk/provider-utils/${VERSION}`,
35
- getRuntimeEnvironmentUserAgent(),
36
- ),
37
- signal: abortSignal,
38
- });
66
+ const requestFetch = fetch ?? getOriginalFetch();
67
+
68
+ // Withhold caller headers when the URL is not same-origin with the origin
69
+ // allowed to receive credentials; the user-agent suffix is still applied.
70
+ const outgoingHeaders =
71
+ credentialedOrigin !== undefined && !isSameOrigin(url, credentialedOrigin)
72
+ ? {}
73
+ : headers;
74
+
75
+ const requestHeaders = withUserAgentSuffix(
76
+ outgoingHeaders,
77
+ `ai-sdk/provider-utils/${VERSION}`,
78
+ getRuntimeEnvironmentUserAgent(),
79
+ );
80
+
81
+ const response = validateUrl
82
+ ? await fetchWithValidatedRedirects({
83
+ url,
84
+ headers: requestHeaders,
85
+ abortSignal,
86
+ fetch,
87
+ trustedOrigin,
88
+ })
89
+ : await requestFetch(url, {
90
+ method: 'GET',
91
+ headers: requestHeaders,
92
+ signal: abortSignal,
93
+ });
39
94
 
40
95
  const responseHeaders = extractResponseHeaders(response);
41
96
 
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ export {
26
26
  } from './fetch-with-validated-redirects';
27
27
  export * from './fetch-function';
28
28
  export { createIdGenerator, generateId, type IdGenerator } from './generate-id';
29
+ export { encodePathSegment } from './encode-path-segment';
29
30
  export * from './get-error-message';
30
31
  export * from './get-from-api';
31
32
  export { getRuntimeEnvironmentUserAgent } from './get-runtime-environment-user-agent';
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Request headers stripped before fetching an untrusted URL: host/virtual-host
3
+ * routing, proxy/origin spoofing, cloud-metadata, cookies, and hop-by-hop
4
+ * transport headers (RFC 7230 §6.1).
5
+ *
6
+ * `Authorization` and other credential-bearing caller headers (e.g. `x-key`)
7
+ * are intentionally not listed because trusted provider requests may need
8
+ * them. `fetchWithValidatedRedirects` drops all caller headers except the
9
+ * user-agent on a cross-origin redirect.
10
+ */
11
+ const BLOCKED_REQUEST_HEADERS: readonly string[] = [
12
+ // Hop-by-hop / transport (RFC 7230 §6.1)
13
+ 'connection',
14
+ 'keep-alive',
15
+ 'te',
16
+ 'trailer',
17
+ 'transfer-encoding',
18
+ 'upgrade',
19
+
20
+ // Host / virtual-host routing
21
+ 'host',
22
+
23
+ // Proxy / origin spoofing
24
+ 'forwarded',
25
+ 'proxy-authorization',
26
+ 'via',
27
+ 'x-forwarded-for',
28
+ 'x-forwarded-host',
29
+ 'x-forwarded-proto',
30
+ 'x-real-ip',
31
+
32
+ // Cloud metadata (GCP, AWS IMDSv1/v2, Azure, Alibaba, DigitalOcean)
33
+ 'metadata',
34
+ 'metadata-flavor',
35
+ 'x-aws-ec2-metadata-token',
36
+ 'x-metadata-token',
37
+
38
+ // Session / cookie
39
+ 'cookie',
40
+ 'set-cookie',
41
+ ];
42
+
43
+ /**
44
+ * Returns a fresh `Headers` built from `input` with {@link BLOCKED_REQUEST_HEADERS}
45
+ * removed. The input is never mutated.
46
+ */
47
+ export function sanitizeRequestHeaders(input: HeadersInit): Headers {
48
+ const headers = new Headers(input);
49
+ for (const name of BLOCKED_REQUEST_HEADERS) {
50
+ headers.delete(name);
51
+ }
52
+ return headers;
53
+ }
@@ -46,11 +46,12 @@ export function validateDownloadUrl(url: string): void {
46
46
  });
47
47
  }
48
48
 
49
- // Block localhost and .local domains
49
+ // Block localhost and domains reserved for local/private use.
50
50
  if (
51
51
  hostname === 'localhost' ||
52
52
  hostname.endsWith('.local') ||
53
- hostname.endsWith('.localhost')
53
+ hostname.endsWith('.localhost') ||
54
+ hostname.endsWith('.internal')
54
55
  ) {
55
56
  throw new DownloadError({
56
57
  url,