@ai-sdk/provider-utils 4.0.39 → 4.0.41

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.39",
3
+ "version": "4.0.41",
4
4
  "license": "Apache-2.0",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -35,6 +35,7 @@
35
35
  "dependencies": {
36
36
  "@standard-schema/spec": "^1.1.0",
37
37
  "eventsource-parser": "^3.0.8",
38
+ "undici": "^5.29.0",
38
39
  "@ai-sdk/provider": "3.0.14"
39
40
  },
40
41
  "devDependencies": {
@@ -1,6 +1,7 @@
1
1
  import { cancelResponseBody } from './cancel-response-body';
2
2
  import { DownloadError } from './download-error';
3
3
  import { isBrowserRuntime } from './is-browser-runtime';
4
+ import { getDefaultDownloadFetch } from './safe-node-fetch';
4
5
  import { validateDownloadUrl } from './validate-download-url';
5
6
 
6
7
  const MAX_DOWNLOAD_REDIRECTS = 10;
@@ -25,6 +26,11 @@ const MAX_DOWNLOAD_REDIRECTS = 10;
25
26
  * The returned response is the final (non-redirect) response. The caller is
26
27
  * responsible for checking `response.ok` and reading the body.
27
28
  *
29
+ * On Node.js, the default fetch resolves every hostname through a validating
30
+ * lookup hook and passes those exact addresses to the connector, preventing
31
+ * hostname-to-private-IP and DNS-rebinding bypasses. Other runtimes should
32
+ * constrain egress at the network layer when handling untrusted URLs.
33
+ *
28
34
  * @throws DownloadError if a hop is unsafe, the redirect limit is exceeded, or
29
35
  * a redirect cannot be validated on a non-browser runtime.
30
36
  */
@@ -52,6 +58,7 @@ export async function fetchWithValidatedRedirects({
52
58
  for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) {
53
59
  validateDownloadUrl(currentUrl);
54
60
 
61
+ const fetch = await getDefaultDownloadFetch();
55
62
  const response = await fetch(currentUrl, {
56
63
  ...baseInit,
57
64
  redirect: 'manual',
@@ -0,0 +1,209 @@
1
+ import type * as nodeDnsModule from 'node:dns';
2
+ import type * as nodeModule from 'node:module';
3
+ import type * as undiciModule from 'undici';
4
+ import type { FetchFunction } from './fetch-function';
5
+ import { validateDownloadAddress } from './validate-download-url';
6
+
7
+ type NodeDns = typeof nodeDnsModule;
8
+ type NodeModule = typeof nodeModule;
9
+ type Undici = typeof undiciModule;
10
+
11
+ type LookupAddress = {
12
+ address: string;
13
+ family: number;
14
+ };
15
+
16
+ type LookupOptions = {
17
+ all?: boolean;
18
+ family?: number;
19
+ hints?: number;
20
+ order?: 'ipv4first' | 'ipv6first' | 'verbatim';
21
+ verbatim?: boolean;
22
+ };
23
+
24
+ type Lookup = (
25
+ hostname: string,
26
+ options: LookupOptions & { all: true },
27
+ callback: (
28
+ error: NodeJS.ErrnoException | null,
29
+ addresses: LookupAddress[],
30
+ ) => void,
31
+ ) => void;
32
+
33
+ type LookupAllCallback = (
34
+ error: NodeJS.ErrnoException | null,
35
+ addresses: LookupAddress[],
36
+ ) => void;
37
+
38
+ type LookupOneCallback = (
39
+ error: NodeJS.ErrnoException | null,
40
+ address: string,
41
+ family: number,
42
+ ) => void;
43
+
44
+ type SafeLookup = {
45
+ (
46
+ hostname: string,
47
+ options: LookupOptions & { all: true },
48
+ callback: LookupAllCallback,
49
+ ): void;
50
+ (
51
+ hostname: string,
52
+ options: LookupOptions & { all?: false },
53
+ callback: LookupOneCallback,
54
+ ): void;
55
+ };
56
+
57
+ /**
58
+ * Creates a DNS lookup hook that validates every returned address before
59
+ * returning the callback shape requested by the HTTP connector. Because
60
+ * resolution and validation happen inside the connector, the socket is pinned
61
+ * to the validated result and DNS rebinding cannot introduce a second lookup.
62
+ */
63
+ export function createSafeLookup(lookup: Lookup): SafeLookup {
64
+ return ((
65
+ hostname: string,
66
+ options: LookupOptions,
67
+ callback: LookupAllCallback | LookupOneCallback,
68
+ ): void => {
69
+ lookup(hostname, { ...options, all: true }, (error, addresses) => {
70
+ if (error) {
71
+ (callback as (error: Error) => void)(error);
72
+ return;
73
+ }
74
+
75
+ try {
76
+ const [firstAddress] = addresses;
77
+
78
+ if (firstAddress == null) {
79
+ throw new Error(`Hostname ${hostname} did not resolve to an address`);
80
+ }
81
+
82
+ for (const { address, family } of addresses) {
83
+ validateDownloadAddress({ address, family, hostname });
84
+ }
85
+
86
+ if (options.all === true) {
87
+ (callback as LookupAllCallback)(null, addresses);
88
+ } else {
89
+ (callback as LookupOneCallback)(
90
+ null,
91
+ firstAddress.address,
92
+ firstAddress.family,
93
+ );
94
+ }
95
+ } catch (error) {
96
+ (callback as (error: Error) => void)(
97
+ error instanceof Error ? error : new Error(String(error)),
98
+ );
99
+ }
100
+ });
101
+ }) as SafeLookup;
102
+ }
103
+
104
+ let safeNodeFetchPromise: Promise<FetchFunction> | undefined;
105
+ const initialGlobalFetch = globalThis.fetch;
106
+ const initialGlobalFetchIsNodeDefault = isNodeDefaultFetch(initialGlobalFetch);
107
+
108
+ export function isNodeRuntime(): boolean {
109
+ const runtimeProcess = globalThis.process as
110
+ | {
111
+ release?: { name?: string };
112
+ versions?: { bun?: string };
113
+ }
114
+ | undefined;
115
+
116
+ return (
117
+ runtimeProcess?.release?.name === 'node' &&
118
+ runtimeProcess.versions?.bun == null
119
+ );
120
+ }
121
+
122
+ export async function getDefaultDownloadFetch(): Promise<FetchFunction> {
123
+ if (
124
+ !isNodeRuntime() ||
125
+ !initialGlobalFetchIsNodeDefault ||
126
+ globalThis.fetch !== initialGlobalFetch
127
+ ) {
128
+ return globalThis.fetch;
129
+ }
130
+
131
+ return (safeNodeFetchPromise ??= createSafeNodeFetch());
132
+ }
133
+
134
+ function isNodeDefaultFetch(fetch: FetchFunction): boolean {
135
+ const source = Function.prototype.toString.call(fetch);
136
+ return (
137
+ source.includes('internal/deps/undici') ||
138
+ source.includes('lazy loading of undici')
139
+ );
140
+ }
141
+
142
+ async function createSafeNodeFetch(): Promise<FetchFunction> {
143
+ // Node 20.16+ exposes getBuiltinModule; older supported Node versions use an
144
+ // indirect dynamic import. Keeping the specifier non-literal prevents browser
145
+ // bundlers from pulling Node built-ins into the provider-utils entry point.
146
+ const [{ createRequire }, { lookup }] = await Promise.all([
147
+ loadNodeModule<NodeModule>('node:module'),
148
+ loadNodeModule<NodeDns>('node:dns'),
149
+ ]);
150
+ const { Agent, fetch } = createRequire(getCurrentModulePath())(
151
+ 'undici',
152
+ ) as Undici;
153
+
154
+ const dispatcher = new Agent({
155
+ connect: {
156
+ lookup: createSafeLookup(lookup as Lookup) as never,
157
+ },
158
+ });
159
+
160
+ return ((input, init) =>
161
+ fetch(
162
+ input as Parameters<typeof fetch>[0],
163
+ {
164
+ ...init,
165
+ dispatcher,
166
+ } as Parameters<typeof fetch>[1],
167
+ ) as unknown as Promise<Response>) satisfies FetchFunction;
168
+ }
169
+
170
+ async function loadNodeModule<T>(id: string): Promise<T> {
171
+ const processWithBuiltins = globalThis.process as
172
+ | {
173
+ getBuiltinModule?: (id: string) => unknown;
174
+ }
175
+ | undefined;
176
+ const builtinModule = processWithBuiltins?.getBuiltinModule?.(id);
177
+
178
+ return builtinModule == null
179
+ ? ((await importNodeModule(id)) as T)
180
+ : (builtinModule as T);
181
+ }
182
+
183
+ function importNodeModule(id: string): Promise<unknown> {
184
+ return import(id);
185
+ }
186
+
187
+ function getCurrentModulePath(): string {
188
+ // `import.meta.url` breaks when provider-utils is rebundled as CommonJS.
189
+ // The caller frame points at this package when loaded directly and at the
190
+ // consuming bundle when inlined, giving createRequire the correct base path.
191
+ const originalPrepareStackTrace = Error.prepareStackTrace;
192
+
193
+ try {
194
+ Error.prepareStackTrace = (_error, callSites) => callSites as never;
195
+
196
+ const error = new Error('Capture current module path');
197
+ Error.captureStackTrace(error, getCurrentModulePath);
198
+ const [caller] = error.stack as unknown as NodeJS.CallSite[];
199
+ const fileName = caller?.getFileName();
200
+
201
+ if (fileName == null) {
202
+ throw new Error('Unable to determine the current module path');
203
+ }
204
+
205
+ return fileName;
206
+ } finally {
207
+ Error.prepareStackTrace = originalPrepareStackTrace;
208
+ }
209
+ }
package/src/types/tool.ts CHANGED
@@ -171,8 +171,8 @@ export type Tool<
171
171
  strict?: boolean;
172
172
 
173
173
  /**
174
- * Optional function that is called when the argument streaming starts.
175
- * Only called when the tool is used in a streaming context.
174
+ * Optional function that is called when the model starts generating the tool input.
175
+ * In non-streaming contexts, it is called immediately before `onInputAvailable`.
176
176
  */
177
177
  onInputStart?: (options: ToolExecutionOptions) => void | PromiseLike<void>;
178
178
 
@@ -4,9 +4,8 @@ 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).
7
+ * Note: this function performs string/literal-IP checks only. The Node.js
8
+ * download fetch additionally validates and pins DNS results at connect time.
10
9
  *
11
10
  * @param url - The URL string to validate.
12
11
  * @throws DownloadError if the URL is unsafe.
@@ -83,6 +82,34 @@ export function validateDownloadUrl(url: string): void {
83
82
  }
84
83
  }
85
84
 
85
+ /**
86
+ * Validates an address returned by DNS before it is used to open a socket.
87
+ * This is intentionally not exported from the package entry point.
88
+ */
89
+ export function validateDownloadAddress({
90
+ address,
91
+ family,
92
+ hostname,
93
+ }: {
94
+ address: string;
95
+ family: number;
96
+ hostname: string;
97
+ }): void {
98
+ const isUnsafe =
99
+ family === 4
100
+ ? !isIPv4(address) || isPrivateIPv4(address)
101
+ : family === 6
102
+ ? isPrivateIPv6(address)
103
+ : true;
104
+
105
+ if (isUnsafe) {
106
+ throw new DownloadError({
107
+ url: hostname,
108
+ message: `Hostname ${hostname} resolved to disallowed IP address ${address}`,
109
+ });
110
+ }
111
+ }
112
+
86
113
  function isIPv4(hostname: string): boolean {
87
114
  const parts = hostname.split('.');
88
115
  if (parts.length !== 4) return false;