@ai-sdk/provider-utils 4.0.28 → 4.0.30
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 +29 -0
- package/dist/index.d.mts +73 -1
- package/dist/index.d.ts +73 -1
- package/dist/index.js +195 -83
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +191 -83
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/cancel-response-body.ts +19 -0
- package/src/download-blob.ts +8 -9
- package/src/fetch-with-validated-redirects.ts +87 -0
- package/src/index.ts +4 -0
- package/src/is-browser-runtime.ts +13 -0
- package/src/is-same-origin.ts +19 -0
- package/src/read-response-with-size-limit.ts +4 -0
- package/src/validate-download-url.ts +113 -31
package/package.json
CHANGED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cancels a response body to release the underlying connection.
|
|
3
|
+
*
|
|
4
|
+
* When a fetch Response is rejected without consuming its body (e.g. a failed
|
|
5
|
+
* status code, an open-redirect rejection, or a Content-Length that exceeds the
|
|
6
|
+
* size limit), the underlying TCP socket is not returned to the connection pool
|
|
7
|
+
* and may stay open until the process runs out of file descriptors. Cancelling
|
|
8
|
+
* the body avoids this leak.
|
|
9
|
+
*
|
|
10
|
+
* Errors thrown while cancelling are ignored: the body may already be locked,
|
|
11
|
+
* disturbed, or absent, none of which should mask the original rejection.
|
|
12
|
+
*/
|
|
13
|
+
export async function cancelResponseBody(response: Response): Promise<void> {
|
|
14
|
+
try {
|
|
15
|
+
await response.body?.cancel();
|
|
16
|
+
} catch {
|
|
17
|
+
// Ignore cancel errors so the original rejection is preserved.
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/download-blob.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { cancelResponseBody } from './cancel-response-body';
|
|
1
2
|
import { DownloadError } from './download-error';
|
|
3
|
+
import { fetchWithValidatedRedirects } from './fetch-with-validated-redirects';
|
|
2
4
|
import {
|
|
3
5
|
readResponseWithSizeLimit,
|
|
4
6
|
DEFAULT_MAX_DOWNLOAD_SIZE,
|
|
5
7
|
} from './read-response-with-size-limit';
|
|
6
|
-
import { validateDownloadUrl } from './validate-download-url';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Download a file from a URL and return it as a Blob.
|
|
@@ -20,18 +21,16 @@ export async function downloadBlob(
|
|
|
20
21
|
url: string,
|
|
21
22
|
options?: { maxBytes?: number; abortSignal?: AbortSignal },
|
|
22
23
|
): Promise<Blob> {
|
|
23
|
-
validateDownloadUrl(url);
|
|
24
24
|
try {
|
|
25
|
-
const response = await
|
|
26
|
-
|
|
25
|
+
const response = await fetchWithValidatedRedirects({
|
|
26
|
+
url,
|
|
27
|
+
abortSignal: options?.abortSignal,
|
|
27
28
|
});
|
|
28
29
|
|
|
29
|
-
// Validate final URL after redirects to prevent SSRF via open redirect
|
|
30
|
-
if (response.redirected) {
|
|
31
|
-
validateDownloadUrl(response.url);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
30
|
if (!response.ok) {
|
|
31
|
+
// Release the connection before rejecting so an error status from an
|
|
32
|
+
// attacker-controlled origin cannot leak open sockets.
|
|
33
|
+
await cancelResponseBody(response);
|
|
35
34
|
throw new DownloadError({
|
|
36
35
|
url,
|
|
37
36
|
statusCode: response.status,
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { cancelResponseBody } from './cancel-response-body';
|
|
2
|
+
import { DownloadError } from './download-error';
|
|
3
|
+
import { isBrowserRuntime } from './is-browser-runtime';
|
|
4
|
+
import { validateDownloadUrl } from './validate-download-url';
|
|
5
|
+
|
|
6
|
+
const MAX_DOWNLOAD_REDIRECTS = 10;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Fetches a URL while enforcing the SSRF download guard on every hop.
|
|
10
|
+
*
|
|
11
|
+
* Redirects are followed manually (`redirect: 'manual'`) so each hop is
|
|
12
|
+
* validated with {@link validateDownloadUrl} *before* it is requested. Relying
|
|
13
|
+
* on the default `redirect: 'follow'` would issue the request to a redirect
|
|
14
|
+
* target (e.g. an internal address) before we ever see its URL, defeating the
|
|
15
|
+
* SSRF guard.
|
|
16
|
+
*
|
|
17
|
+
* A `redirect: 'manual'` request yields an unreadable opaque response in the
|
|
18
|
+
* browser (and in other spec-compliant fetch implementations), so the redirect
|
|
19
|
+
* target cannot be validated here. In a real browser this is safe to follow
|
|
20
|
+
* natively because SSRF is not reachable (fetch is constrained by CORS and
|
|
21
|
+
* cannot reach a server's internal network or cloud-metadata). On any other
|
|
22
|
+
* runtime we cannot validate the hop, so we fail closed rather than follow it
|
|
23
|
+
* blindly and bypass the SSRF guard.
|
|
24
|
+
*
|
|
25
|
+
* The returned response is the final (non-redirect) response. The caller is
|
|
26
|
+
* responsible for checking `response.ok` and reading the body.
|
|
27
|
+
*
|
|
28
|
+
* @throws DownloadError if a hop is unsafe, the redirect limit is exceeded, or
|
|
29
|
+
* a redirect cannot be validated on a non-browser runtime.
|
|
30
|
+
*/
|
|
31
|
+
export async function fetchWithValidatedRedirects({
|
|
32
|
+
url,
|
|
33
|
+
headers,
|
|
34
|
+
abortSignal,
|
|
35
|
+
maxRedirects = MAX_DOWNLOAD_REDIRECTS,
|
|
36
|
+
}: {
|
|
37
|
+
url: string;
|
|
38
|
+
headers?: HeadersInit;
|
|
39
|
+
abortSignal?: AbortSignal;
|
|
40
|
+
maxRedirects?: number;
|
|
41
|
+
}): Promise<Response> {
|
|
42
|
+
// Per-hop request options. Only the `redirect` mode varies between hops, so
|
|
43
|
+
// the rest is assembled once. `headers` is omitted entirely when not provided
|
|
44
|
+
// so callers that send none issue a bare request.
|
|
45
|
+
const baseInit: RequestInit = { signal: abortSignal };
|
|
46
|
+
if (headers !== undefined) {
|
|
47
|
+
baseInit.headers = headers;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let currentUrl = url;
|
|
51
|
+
// The bound also acts as a backstop against an unterminated redirect chain.
|
|
52
|
+
for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) {
|
|
53
|
+
validateDownloadUrl(currentUrl);
|
|
54
|
+
|
|
55
|
+
const response = await fetch(currentUrl, {
|
|
56
|
+
...baseInit,
|
|
57
|
+
redirect: 'manual',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (response.type === 'opaqueredirect') {
|
|
61
|
+
if (!isBrowserRuntime()) {
|
|
62
|
+
throw new DownloadError({
|
|
63
|
+
url,
|
|
64
|
+
message: `Redirect from ${currentUrl} could not be validated and was blocked`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return await fetch(currentUrl, { ...baseInit, redirect: 'follow' });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const location = response.headers.get('location');
|
|
71
|
+
if (response.status >= 300 && response.status < 400 && location) {
|
|
72
|
+
// Release the redirect response's connection before moving to the next
|
|
73
|
+
// hop. Whether that hop is followed or rejected by the SSRF guard, an
|
|
74
|
+
// unconsumed 3xx body would leak the underlying socket.
|
|
75
|
+
await cancelResponseBody(response);
|
|
76
|
+
currentUrl = new URL(location, currentUrl).toString();
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return response;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
throw new DownloadError({
|
|
84
|
+
url,
|
|
85
|
+
message: `Too many redirects (max ${maxRedirects})`,
|
|
86
|
+
});
|
|
87
|
+
}
|
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';
|
|
@@ -40,6 +43,7 @@ export {
|
|
|
40
43
|
type ProviderToolFactory,
|
|
41
44
|
type ProviderToolFactoryWithOutputSchema,
|
|
42
45
|
} from './provider-tool-factory';
|
|
46
|
+
export { cancelResponseBody } from './cancel-response-body';
|
|
43
47
|
export * from './remove-undefined-entries';
|
|
44
48
|
export * from './resolve';
|
|
45
49
|
export * from './response-handler';
|
|
@@ -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
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { cancelResponseBody } from './cancel-response-body';
|
|
1
2
|
import { DownloadError } from './download-error';
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -40,6 +41,9 @@ export async function readResponseWithSizeLimit({
|
|
|
40
41
|
if (contentLength != null) {
|
|
41
42
|
const length = parseInt(contentLength, 10);
|
|
42
43
|
if (!isNaN(length) && length > maxBytes) {
|
|
44
|
+
// Cancel the body so the underlying connection is released back to the
|
|
45
|
+
// pool instead of being left open until the socket is exhausted.
|
|
46
|
+
await cancelResponseBody(response);
|
|
43
47
|
throw new DownloadError({
|
|
44
48
|
url,
|
|
45
49
|
message: `Download of ${url} exceeded maximum size of ${maxBytes} bytes (Content-Length: ${length}).`,
|
|
@@ -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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
//
|
|
142
|
-
|
|
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 (
|
|
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
|
}
|