@ai-sdk/provider-utils 4.0.56 → 4.0.57
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 +8 -0
- package/dist/index.d.mts +39 -7
- package/dist/index.d.ts +39 -7
- package/dist/index.js +49 -30
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +48 -30
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/fetch-with-validated-redirects.ts +90 -3
- package/src/index.ts +4 -1
- package/src/safe-node-fetch.ts +20 -25
package/package.json
CHANGED
|
@@ -1,11 +1,70 @@
|
|
|
1
1
|
import { cancelResponseBody } from './cancel-response-body';
|
|
2
2
|
import { DownloadError } from './download-error';
|
|
3
|
+
import type { FetchFunction } from './fetch-function';
|
|
3
4
|
import { isBrowserRuntime } from './is-browser-runtime';
|
|
5
|
+
import { isSameOrigin } from './is-same-origin';
|
|
4
6
|
import { getDefaultDownloadFetch } from './safe-node-fetch';
|
|
5
7
|
import { validateDownloadUrl } from './validate-download-url';
|
|
6
8
|
|
|
7
9
|
const MAX_DOWNLOAD_REDIRECTS = 10;
|
|
8
10
|
|
|
11
|
+
async function getValidatedFetch(
|
|
12
|
+
customFetch: FetchFunction | undefined,
|
|
13
|
+
): Promise<FetchFunction> {
|
|
14
|
+
// Callers commonly pass globalThis.fetch through several abstraction
|
|
15
|
+
// layers. Preserve the DNS-pinned Node.js default in that case rather than
|
|
16
|
+
// accidentally treating it as an intentionally custom fetch.
|
|
17
|
+
return customFetch == null || customFetch === globalThis.fetch
|
|
18
|
+
? await getDefaultDownloadFetch()
|
|
19
|
+
: customFetch;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Fetches one validated URL without following redirects.
|
|
24
|
+
*
|
|
25
|
+
* On Node.js, the default fetch validates and pins DNS results at connect time.
|
|
26
|
+
* An injected fetch is responsible for equivalent connect-time validation.
|
|
27
|
+
* Redirects are rejected by default. Callers using `redirect: 'manual'` must
|
|
28
|
+
* validate the Location target before issuing another request.
|
|
29
|
+
*/
|
|
30
|
+
export async function fetchWithValidatedEndpoint({
|
|
31
|
+
url,
|
|
32
|
+
init,
|
|
33
|
+
fetch: customFetch,
|
|
34
|
+
trustedOrigin,
|
|
35
|
+
redirect = 'error',
|
|
36
|
+
}: {
|
|
37
|
+
url: string | URL;
|
|
38
|
+
init?: RequestInit;
|
|
39
|
+
fetch?: FetchFunction;
|
|
40
|
+
/**
|
|
41
|
+
* A developer-configured origin that may legitimately resolve to a private
|
|
42
|
+
* address. This must never be derived from untrusted response data.
|
|
43
|
+
*/
|
|
44
|
+
trustedOrigin?: string;
|
|
45
|
+
redirect?: 'error' | 'manual';
|
|
46
|
+
}): Promise<Response> {
|
|
47
|
+
const urlText = url.toString();
|
|
48
|
+
const isTrusted =
|
|
49
|
+
trustedOrigin !== undefined && isSameOrigin(urlText, trustedOrigin);
|
|
50
|
+
|
|
51
|
+
if (!isTrusted) {
|
|
52
|
+
validateDownloadUrl(urlText);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const fetch =
|
|
56
|
+
isTrusted && customFetch != null
|
|
57
|
+
? customFetch
|
|
58
|
+
: isTrusted
|
|
59
|
+
? globalThis.fetch
|
|
60
|
+
: await getValidatedFetch(customFetch);
|
|
61
|
+
|
|
62
|
+
return await fetch(url, {
|
|
63
|
+
...init,
|
|
64
|
+
redirect,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
9
68
|
/**
|
|
10
69
|
* Fetches a URL while enforcing the SSRF download guard on every hop.
|
|
11
70
|
*
|
|
@@ -23,6 +82,13 @@ const MAX_DOWNLOAD_REDIRECTS = 10;
|
|
|
23
82
|
* runtime we cannot validate the hop, so we fail closed rather than follow it
|
|
24
83
|
* blindly and bypass the SSRF guard.
|
|
25
84
|
*
|
|
85
|
+
* A hop that is same-origin with `trustedOrigin` (the developer-configured
|
|
86
|
+
* endpoint) skips target validation: that origin is exactly what an
|
|
87
|
+
* unvalidated, config-derived request would fetch anyway, and validating it
|
|
88
|
+
* would break legitimate self-hosted / localhost deployments whose response
|
|
89
|
+
* URLs point back at the configured host. Hops on any other origin are always
|
|
90
|
+
* validated.
|
|
91
|
+
*
|
|
26
92
|
* The returned response is the final (non-redirect) response. The caller is
|
|
27
93
|
* responsible for checking `response.ok` and reading the body.
|
|
28
94
|
*
|
|
@@ -39,11 +105,19 @@ export async function fetchWithValidatedRedirects({
|
|
|
39
105
|
headers,
|
|
40
106
|
abortSignal,
|
|
41
107
|
maxRedirects = MAX_DOWNLOAD_REDIRECTS,
|
|
108
|
+
fetch: customFetch,
|
|
109
|
+
trustedOrigin,
|
|
42
110
|
}: {
|
|
43
111
|
url: string;
|
|
44
112
|
headers?: HeadersInit;
|
|
45
113
|
abortSignal?: AbortSignal;
|
|
46
114
|
maxRedirects?: number;
|
|
115
|
+
fetch?: FetchFunction;
|
|
116
|
+
/**
|
|
117
|
+
* A developer-configured origin whose hops skip target validation. Must
|
|
118
|
+
* never be derived from response data.
|
|
119
|
+
*/
|
|
120
|
+
trustedOrigin?: string;
|
|
47
121
|
}): Promise<Response> {
|
|
48
122
|
// Per-hop request options. Only the `redirect` mode varies between hops, so
|
|
49
123
|
// the rest is assembled once. `headers` is omitted entirely when not provided
|
|
@@ -56,9 +130,22 @@ export async function fetchWithValidatedRedirects({
|
|
|
56
130
|
let currentUrl = url;
|
|
57
131
|
// The bound also acts as a backstop against an unterminated redirect chain.
|
|
58
132
|
for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) {
|
|
59
|
-
|
|
133
|
+
// The developer-configured origin is trusted by definition; validating it
|
|
134
|
+
// would reject legitimate self-hosted / localhost deployments.
|
|
135
|
+
const isTrustedHop =
|
|
136
|
+
trustedOrigin !== undefined && isSameOrigin(currentUrl, trustedOrigin);
|
|
137
|
+
|
|
138
|
+
if (!isTrustedHop) {
|
|
139
|
+
validateDownloadUrl(currentUrl);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const fetch =
|
|
143
|
+
isTrustedHop && customFetch != null
|
|
144
|
+
? customFetch
|
|
145
|
+
: isTrustedHop
|
|
146
|
+
? globalThis.fetch
|
|
147
|
+
: await getValidatedFetch(customFetch);
|
|
60
148
|
|
|
61
|
-
const fetch = await getDefaultDownloadFetch();
|
|
62
149
|
const response = await fetch(currentUrl, {
|
|
63
150
|
...baseInit,
|
|
64
151
|
redirect: 'manual',
|
|
@@ -74,7 +161,7 @@ export async function fetchWithValidatedRedirects({
|
|
|
74
161
|
return await fetch(currentUrl, { ...baseInit, redirect: 'follow' });
|
|
75
162
|
}
|
|
76
163
|
|
|
77
|
-
const location = response.headers
|
|
164
|
+
const location = response.headers?.get('location');
|
|
78
165
|
if (response.status >= 300 && response.status < 400 && location) {
|
|
79
166
|
// Release the redirect response's connection before moving to the next
|
|
80
167
|
// hop. Whether that hop is followed or rejected by the SSRF guard, an
|
package/src/index.ts
CHANGED
|
@@ -20,7 +20,10 @@ export {
|
|
|
20
20
|
readResponseWithSizeLimit,
|
|
21
21
|
DEFAULT_MAX_DOWNLOAD_SIZE,
|
|
22
22
|
} from './read-response-with-size-limit';
|
|
23
|
-
export {
|
|
23
|
+
export {
|
|
24
|
+
fetchWithValidatedEndpoint,
|
|
25
|
+
fetchWithValidatedRedirects,
|
|
26
|
+
} from './fetch-with-validated-redirects';
|
|
24
27
|
export * from './fetch-function';
|
|
25
28
|
export { createIdGenerator, generateId, type IdGenerator } from './generate-id';
|
|
26
29
|
export * from './get-error-message';
|
package/src/safe-node-fetch.ts
CHANGED
|
@@ -102,57 +102,52 @@ export function createSafeLookup(lookup: Lookup): SafeLookup {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
let safeNodeFetchPromise: Promise<FetchFunction> | undefined;
|
|
105
|
-
const initialGlobalFetch = globalThis.fetch;
|
|
106
|
-
const initialGlobalFetchIsNodeDefault = isNodeDefaultFetch(initialGlobalFetch);
|
|
107
105
|
|
|
108
106
|
export function isNodeRuntime(): boolean {
|
|
109
107
|
const runtimeProcess = globalThis.process as
|
|
110
108
|
| {
|
|
111
109
|
release?: { name?: string };
|
|
112
|
-
|
|
110
|
+
title?: string;
|
|
111
|
+
versions?: { bun?: string; deno?: string };
|
|
113
112
|
}
|
|
114
113
|
| undefined;
|
|
115
114
|
|
|
115
|
+
// Node-compatible process objects do not imply support for Node DNS/socket
|
|
116
|
+
// hooks. Workers identifies itself as workerd, including without navigator.
|
|
116
117
|
return (
|
|
117
118
|
runtimeProcess?.release?.name === 'node' &&
|
|
118
|
-
runtimeProcess.versions?.bun == null
|
|
119
|
+
runtimeProcess.versions?.bun == null &&
|
|
120
|
+
runtimeProcess.versions?.deno == null &&
|
|
121
|
+
runtimeProcess.title !== 'workerd' &&
|
|
122
|
+
(globalThis as { EdgeRuntime?: unknown }).EdgeRuntime == null
|
|
119
123
|
);
|
|
120
124
|
}
|
|
121
125
|
|
|
122
126
|
export async function getDefaultDownloadFetch(): Promise<FetchFunction> {
|
|
123
|
-
if (
|
|
124
|
-
!isNodeRuntime() ||
|
|
125
|
-
!initialGlobalFetchIsNodeDefault ||
|
|
126
|
-
globalThis.fetch !== initialGlobalFetch
|
|
127
|
-
) {
|
|
127
|
+
if (!isNodeRuntime()) {
|
|
128
128
|
return globalThis.fetch;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// Global fetch wrappers cannot be relied on to preserve the dispatcher
|
|
132
|
+
// that pins connections to validated DNS results.
|
|
131
133
|
return (safeNodeFetchPromise ??= createSafeNodeFetch());
|
|
132
134
|
}
|
|
133
135
|
|
|
134
|
-
function isNodeDefaultFetch(fetch: unknown): boolean {
|
|
135
|
-
if (typeof fetch !== 'function') {
|
|
136
|
-
return false;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const source = Function.prototype.toString.call(fetch);
|
|
140
|
-
return (
|
|
141
|
-
source.includes('internal/deps/undici') ||
|
|
142
|
-
source.includes('lazy loading of undici')
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
136
|
async function createSafeNodeFetch(): Promise<FetchFunction> {
|
|
147
137
|
// Load Node-only modules indirectly so browser bundlers do not pull undici
|
|
148
138
|
// and Node built-ins into the browser-facing provider-utils entry point.
|
|
149
|
-
|
|
139
|
+
// @vercel/nft (node file trace) only recognizes an indirectly loaded createRequire when its receiver
|
|
140
|
+
// is named `module` and the returned require function is assigned.
|
|
141
|
+
// eslint-disable-next-line @next/next/no-assign-module-variable
|
|
142
|
+
const [module, { lookup }] = await Promise.all([
|
|
150
143
|
loadNodeModule<NodeModule>('node:module'),
|
|
151
144
|
loadNodeModule<NodeDns>('node:dns'),
|
|
152
145
|
]);
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
146
|
+
|
|
147
|
+
// Assign the created require function so deployment tracers can recognize
|
|
148
|
+
// the static dependency without bundlers inlining undici.
|
|
149
|
+
const nodeRequire = module.createRequire(getCurrentModulePath());
|
|
150
|
+
const { Agent, fetch } = nodeRequire('undici') as Undici;
|
|
156
151
|
|
|
157
152
|
const dispatcher = new Agent({
|
|
158
153
|
connect: {
|