@ai-sdk/provider-utils 4.0.27 → 4.0.29

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.27",
3
+ "version": "4.0.29",
4
4
  "license": "Apache-2.0",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -1,9 +1,9 @@
1
1
  import { DownloadError } from './download-error';
2
+ import { fetchWithValidatedRedirects } from './fetch-with-validated-redirects';
2
3
  import {
3
4
  readResponseWithSizeLimit,
4
5
  DEFAULT_MAX_DOWNLOAD_SIZE,
5
6
  } from './read-response-with-size-limit';
6
- import { validateDownloadUrl } from './validate-download-url';
7
7
 
8
8
  /**
9
9
  * Download a file from a URL and return it as a Blob.
@@ -20,17 +20,12 @@ export async function downloadBlob(
20
20
  url: string,
21
21
  options?: { maxBytes?: number; abortSignal?: AbortSignal },
22
22
  ): Promise<Blob> {
23
- validateDownloadUrl(url);
24
23
  try {
25
- const response = await fetch(url, {
26
- signal: options?.abortSignal,
24
+ const response = await fetchWithValidatedRedirects({
25
+ url,
26
+ abortSignal: options?.abortSignal,
27
27
  });
28
28
 
29
- // Validate final URL after redirects to prevent SSRF via open redirect
30
- if (response.redirected) {
31
- validateDownloadUrl(response.url);
32
- }
33
-
34
29
  if (!response.ok) {
35
30
  throw new DownloadError({
36
31
  url,
@@ -0,0 +1,82 @@
1
+ import { DownloadError } from './download-error';
2
+ import { isBrowserRuntime } from './is-browser-runtime';
3
+ import { validateDownloadUrl } from './validate-download-url';
4
+
5
+ const MAX_DOWNLOAD_REDIRECTS = 10;
6
+
7
+ /**
8
+ * Fetches a URL while enforcing the SSRF download guard on every hop.
9
+ *
10
+ * Redirects are followed manually (`redirect: 'manual'`) so each hop is
11
+ * validated with {@link validateDownloadUrl} *before* it is requested. Relying
12
+ * on the default `redirect: 'follow'` would issue the request to a redirect
13
+ * target (e.g. an internal address) before we ever see its URL, defeating the
14
+ * SSRF guard.
15
+ *
16
+ * A `redirect: 'manual'` request yields an unreadable opaque response in the
17
+ * browser (and in other spec-compliant fetch implementations), so the redirect
18
+ * target cannot be validated here. In a real browser this is safe to follow
19
+ * natively because SSRF is not reachable (fetch is constrained by CORS and
20
+ * cannot reach a server's internal network or cloud-metadata). On any other
21
+ * runtime we cannot validate the hop, so we fail closed rather than follow it
22
+ * blindly and bypass the SSRF guard.
23
+ *
24
+ * The returned response is the final (non-redirect) response. The caller is
25
+ * responsible for checking `response.ok` and reading the body.
26
+ *
27
+ * @throws DownloadError if a hop is unsafe, the redirect limit is exceeded, or
28
+ * a redirect cannot be validated on a non-browser runtime.
29
+ */
30
+ export async function fetchWithValidatedRedirects({
31
+ url,
32
+ headers,
33
+ abortSignal,
34
+ maxRedirects = MAX_DOWNLOAD_REDIRECTS,
35
+ }: {
36
+ url: string;
37
+ headers?: HeadersInit;
38
+ abortSignal?: AbortSignal;
39
+ maxRedirects?: number;
40
+ }): Promise<Response> {
41
+ // Per-hop request options. Only the `redirect` mode varies between hops, so
42
+ // the rest is assembled once. `headers` is omitted entirely when not provided
43
+ // so callers that send none issue a bare request.
44
+ const baseInit: RequestInit = { signal: abortSignal };
45
+ if (headers !== undefined) {
46
+ baseInit.headers = headers;
47
+ }
48
+
49
+ let currentUrl = url;
50
+ // The bound also acts as a backstop against an unterminated redirect chain.
51
+ for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) {
52
+ validateDownloadUrl(currentUrl);
53
+
54
+ const response = await fetch(currentUrl, {
55
+ ...baseInit,
56
+ redirect: 'manual',
57
+ });
58
+
59
+ if (response.type === 'opaqueredirect') {
60
+ if (!isBrowserRuntime()) {
61
+ throw new DownloadError({
62
+ url,
63
+ message: `Redirect from ${currentUrl} could not be validated and was blocked`,
64
+ });
65
+ }
66
+ return await fetch(currentUrl, { ...baseInit, redirect: 'follow' });
67
+ }
68
+
69
+ const location = response.headers.get('location');
70
+ if (response.status >= 300 && response.status < 400 && location) {
71
+ currentUrl = new URL(location, currentUrl).toString();
72
+ continue;
73
+ }
74
+
75
+ return response;
76
+ }
77
+
78
+ throw new DownloadError({
79
+ url,
80
+ message: `Too many redirects (max ${maxRedirects})`,
81
+ });
82
+ }
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ export {
15
15
  readResponseWithSizeLimit,
16
16
  DEFAULT_MAX_DOWNLOAD_SIZE,
17
17
  } from './read-response-with-size-limit';
18
+ export { fetchWithValidatedRedirects } from './fetch-with-validated-redirects';
18
19
  export * from './fetch-function';
19
20
  export { createIdGenerator, generateId, type IdGenerator } from './generate-id';
20
21
  export * from './get-error-message';
@@ -22,7 +23,9 @@ export * from './get-from-api';
22
23
  export { getRuntimeEnvironmentUserAgent } from './get-runtime-environment-user-agent';
23
24
  export { injectJsonInstructionIntoMessages } from './inject-json-instruction';
24
25
  export * from './is-abort-error';
26
+ export { isBrowserRuntime } from './is-browser-runtime';
25
27
  export { isNonNullable } from './is-non-nullable';
28
+ export { isSameOrigin } from './is-same-origin';
26
29
  export { isUrlSupported } from './is-url-supported';
27
30
  export * from './load-api-key';
28
31
  export { loadOptionalSetting } from './load-optional-setting';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Returns `true` when running in a browser.
3
+ *
4
+ * Detection keys on the presence of a global `window`, matching the browser
5
+ * check used elsewhere in this package (see `getRuntimeEnvironmentUserAgent`)
6
+ * so the SDK has a single, consistent definition of "browser". Server runtimes
7
+ * (Node.js, Deno, Bun, edge/workers) do not define `window`.
8
+ */
9
+ export function isBrowserRuntime(
10
+ globalThisAny: any = globalThis as any,
11
+ ): boolean {
12
+ return globalThisAny.window != null;
13
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Returns true when `url` has the same origin (scheme + host + port) as
3
+ * `baseUrl`.
4
+ *
5
+ * Used to decide whether provider credentials may be attached to a request to a
6
+ * URL taken from a provider response (e.g. a polling or media-download URL).
7
+ * Credentials must only be sent to the provider's own origin; a response that
8
+ * names a foreign host (a CDN, or an attacker-controlled host if the response
9
+ * is tampered with) must not receive the API key.
10
+ *
11
+ * Returns false if either value is not a valid absolute URL (fail-closed).
12
+ */
13
+ export function isSameOrigin(url: string, baseUrl: string): boolean {
14
+ try {
15
+ return new URL(url).origin === new URL(baseUrl).origin;
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
@@ -13,4 +13,10 @@ export type ToolApprovalRequest = {
13
13
  * ID of the tool call that the approval request is for.
14
14
  */
15
15
  toolCallId: string;
16
+
17
+ /**
18
+ * HMAC-SHA256 signature binding this approval to its tool call.
19
+ * Present only when `experimental_toolApprovalSecret` is configured.
20
+ */
21
+ signature?: string;
16
22
  };
@@ -4,6 +4,10 @@ import { DownloadError } from './download-error';
4
4
  * Validates that a URL is safe to download from, blocking private/internal addresses
5
5
  * to prevent SSRF attacks.
6
6
  *
7
+ * Note: this performs string/literal-IP checks only. It does not resolve DNS, so a
8
+ * hostname that resolves to a private address is not blocked here (see callers, which
9
+ * should additionally constrain egress at the network layer when handling untrusted URLs).
10
+ *
7
11
  * @param url - The URL string to validate.
8
12
  * @throws DownloadError if the URL is unsafe.
9
13
  */
@@ -31,7 +35,9 @@ export function validateDownloadUrl(url: string): void {
31
35
  });
32
36
  }
33
37
 
34
- const hostname = parsed.hostname;
38
+ // Strip a trailing dot so a fully-qualified name like `localhost.` (which resolves
39
+ // identically to `localhost`) cannot bypass the hostname blocklist below.
40
+ const hostname = parsed.hostname.toLowerCase().replace(/\.+$/, '');
35
41
 
36
42
  // Block empty hostname
37
43
  if (!hostname) {
@@ -90,59 +96,135 @@ function isIPv4(hostname: string): boolean {
90
96
 
91
97
  function isPrivateIPv4(ip: string): boolean {
92
98
  const parts = ip.split('.').map(Number);
93
- const [a, b] = parts;
99
+ const [a, b, c] = parts;
94
100
 
95
101
  // 0.0.0.0/8
96
102
  if (a === 0) return true;
97
103
  // 10.0.0.0/8
98
104
  if (a === 10) return true;
105
+ // 100.64.0.0/10 (CGNAT, used by some cloud providers for internal traffic)
106
+ if (a === 100 && b >= 64 && b <= 127) return true;
99
107
  // 127.0.0.0/8
100
108
  if (a === 127) return true;
101
109
  // 169.254.0.0/16
102
110
  if (a === 169 && b === 254) return true;
103
111
  // 172.16.0.0/12
104
112
  if (a === 172 && b >= 16 && b <= 31) return true;
113
+ // 192.0.0.0/24 (IETF protocol assignments)
114
+ if (a === 192 && b === 0 && c === 0) return true;
105
115
  // 192.168.0.0/16
106
116
  if (a === 192 && b === 168) return true;
117
+ // 198.18.0.0/15 (benchmarking)
118
+ if (a === 198 && (b === 18 || b === 19)) return true;
119
+ // 240.0.0.0/4 (reserved, includes 255.255.255.255 broadcast)
120
+ if (a >= 240) return true;
107
121
 
108
122
  return false;
109
123
  }
110
124
 
111
- function isPrivateIPv6(ip: string): boolean {
112
- const normalized = ip.toLowerCase();
113
-
114
- // ::1 (loopback)
115
- if (normalized === '::1') return true;
116
- // :: (unspecified)
117
- if (normalized === '::') return true;
118
-
119
- // Check for IPv4-mapped addresses (::ffff:x.x.x.x or ::ffff:HHHH:HHHH)
120
- if (normalized.startsWith('::ffff:')) {
121
- const mappedPart = normalized.slice(7);
122
- // Dotted-decimal form: ::ffff:127.0.0.1
123
- if (isIPv4(mappedPart)) {
124
- return isPrivateIPv4(mappedPart);
125
- }
126
- // Hex form: ::ffff:7f00:1 (URL parser normalizes to this)
127
- const hexParts = mappedPart.split(':');
128
- if (hexParts.length === 2) {
129
- const high = parseInt(hexParts[0], 16);
130
- const low = parseInt(hexParts[1], 16);
131
- if (!isNaN(high) && !isNaN(low)) {
132
- const a = (high >> 8) & 0xff;
133
- const b = high & 0xff;
134
- const c = (low >> 8) & 0xff;
135
- const d = low & 0xff;
136
- return isPrivateIPv4(`${a}.${b}.${c}.${d}`);
125
+ /**
126
+ * Expands an IPv6 address string into its 8 16-bit groups, handling `::`
127
+ * compression and an optional dotted-decimal IPv4 tail (e.g. `::ffff:127.0.0.1`).
128
+ *
129
+ * @returns the 8 groups, or null if the input is not a parseable IPv6 address.
130
+ */
131
+ function parseIPv6(ip: string): number[] | null {
132
+ // Strip an optional zone id (e.g. `fe80::1%eth0`).
133
+ let address = ip.toLowerCase();
134
+ const zoneIndex = address.indexOf('%');
135
+ if (zoneIndex !== -1) {
136
+ address = address.slice(0, zoneIndex);
137
+ }
138
+
139
+ // At most one `::` compression marker is allowed.
140
+ const halves = address.split('::');
141
+ if (halves.length > 2) return null;
142
+
143
+ const toGroups = (segment: string): number[] | null => {
144
+ if (segment === '') return [];
145
+ const groups: number[] = [];
146
+ const parts = segment.split(':');
147
+ for (let i = 0; i < parts.length; i++) {
148
+ const part = parts[i];
149
+ // A dotted-decimal IPv4 tail is only valid as the final part.
150
+ if (part.includes('.')) {
151
+ if (i !== parts.length - 1 || !isIPv4(part)) return null;
152
+ const [a, b, c, d] = part.split('.').map(Number);
153
+ groups.push((a << 8) | b, (c << 8) | d);
154
+ continue;
137
155
  }
156
+ if (!/^[0-9a-f]{1,4}$/.test(part)) return null;
157
+ groups.push(parseInt(part, 16));
138
158
  }
159
+ return groups;
160
+ };
161
+
162
+ const head = toGroups(halves[0]);
163
+ if (head === null) return null;
164
+
165
+ if (halves.length === 2) {
166
+ const tail = toGroups(halves[1]);
167
+ if (tail === null) return null;
168
+ const fill = 8 - head.length - tail.length;
169
+ if (fill < 0) return null;
170
+ return [...head, ...new Array<number>(fill).fill(0), ...tail];
139
171
  }
140
172
 
141
- // fc00::/7 (unique local addresses - fc00:: and fd00::)
142
- if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true;
173
+ // No `::` compression: the address must contain exactly 8 groups.
174
+ return head.length === 8 ? head : null;
175
+ }
176
+
177
+ function isPrivateIPv6(ip: string): boolean {
178
+ const groups = parseIPv6(ip);
179
+
180
+ // Fail closed: if the address cannot be parsed, treat it as unsafe.
181
+ if (groups === null) return true;
182
+
183
+ const topZero = (count: number) =>
184
+ groups.slice(0, count).every(group => group === 0);
185
+
186
+ // ::1 (loopback) and :: (unspecified)
187
+ if (topZero(7) && (groups[7] === 0 || groups[7] === 1)) return true;
188
+
189
+ // fc00::/7 (unique local addresses)
190
+ if ((groups[0] & 0xfe00) === 0xfc00) return true;
143
191
 
144
192
  // fe80::/10 (link-local)
145
- if (normalized.startsWith('fe80')) return true;
193
+ if ((groups[0] & 0xffc0) === 0xfe80) return true;
194
+
195
+ // fec0::/10 (site-local, deprecated but still routable internally)
196
+ if ((groups[0] & 0xffc0) === 0xfec0) return true;
197
+
198
+ // ff00::/8 (multicast)
199
+ if ((groups[0] & 0xff00) === 0xff00) return true;
200
+
201
+ // Addresses that embed an IPv4 address in their last 32 bits. For these we
202
+ // extract the embedded IPv4 and reuse the IPv4 private-range checks, so that
203
+ // e.g. ::ffff:127.0.0.1 or 64:ff9b::169.254.169.254 are blocked.
204
+ const embedsIPv4 =
205
+ // ::/96 — IPv4-compatible (deprecated)
206
+ topZero(6) ||
207
+ // ::ffff:0:0/96 — IPv4-mapped (ffff in group 5)
208
+ (topZero(5) && groups[5] === 0xffff) ||
209
+ // ::ffff:0:0/96 — IPv4-translated form (ffff in group 4, group 5 zero)
210
+ (topZero(4) && groups[4] === 0xffff && groups[5] === 0) ||
211
+ // 64:ff9b::/96 — NAT64 well-known prefix
212
+ (groups[0] === 0x0064 &&
213
+ groups[1] === 0xff9b &&
214
+ groups[2] === 0 &&
215
+ groups[3] === 0 &&
216
+ groups[4] === 0 &&
217
+ groups[5] === 0) ||
218
+ // 64:ff9b:1::/48 — NAT64 local-use prefix
219
+ (groups[0] === 0x0064 && groups[1] === 0xff9b && groups[2] === 0x0001);
220
+
221
+ if (embedsIPv4) {
222
+ const a = (groups[6] >> 8) & 0xff;
223
+ const b = groups[6] & 0xff;
224
+ const c = (groups[7] >> 8) & 0xff;
225
+ const d = groups[7] & 0xff;
226
+ return isPrivateIPv4(`${a}.${b}.${c}.${d}`);
227
+ }
146
228
 
147
229
  return false;
148
230
  }