@ai-sdk/provider-utils 5.0.13 → 5.0.15

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": "5.0.13",
3
+ "version": "5.0.15",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -35,6 +35,7 @@
35
35
  "@standard-schema/spec": "^1.1.0",
36
36
  "@workflow/serde": "4.1.0",
37
37
  "eventsource-parser": "^3.0.8",
38
+ "undici": "^7.28.0",
38
39
  "@ai-sdk/provider": "4.0.4"
39
40
  },
40
41
  "devDependencies": {
@@ -46,7 +46,11 @@ export function connectToWebSocket({
46
46
  /** Constructor throws and message decoding/processing failures. */
47
47
  onProcessingError: (error: unknown) => void;
48
48
  onSocketError?: () => void;
49
- onClose?: () => void;
49
+ /**
50
+ * Receives the close code and reason when the transport provides them
51
+ * (native `CloseEvent` / `ws` close event).
52
+ */
53
+ onClose?: (info: { code?: number; reason?: string }) => void;
50
54
  /** Also called (without opening a socket) when the signal is already aborted. */
51
55
  onAbort?: (reason: unknown) => void;
52
56
  }): WebSocketConnection {
@@ -108,8 +112,20 @@ export function connectToWebSocket({
108
112
  socket.onerror = () => {
109
113
  tail = tail.then(() => onSocketError?.()).catch(onProcessingError);
110
114
  };
111
- socket.onclose = () => {
112
- tail = tail.then(() => onClose?.()).catch(onProcessingError);
115
+ socket.onclose = event => {
116
+ // Extract close diagnostics when the transport provides them (native
117
+ // `CloseEvent` and `ws` both carry `code` and `reason`).
118
+ const closeEvent = event as
119
+ | { code?: unknown; reason?: unknown }
120
+ | null
121
+ | undefined;
122
+ const code =
123
+ typeof closeEvent?.code === 'number' ? closeEvent.code : undefined;
124
+ const reason =
125
+ typeof closeEvent?.reason === 'string' ? closeEvent.reason : undefined;
126
+ tail = tail
127
+ .then(() => onClose?.({ code, reason }))
128
+ .catch(onProcessingError);
113
129
  };
114
130
 
115
131
  return { socket, close };
@@ -3,6 +3,7 @@ import { DownloadError } from './download-error';
3
3
  import type { FetchFunction } from './fetch-function';
4
4
  import { isBrowserRuntime } from './is-browser-runtime';
5
5
  import { isSameOrigin } from './is-same-origin';
6
+ import { getDefaultDownloadFetch } from './safe-node-fetch';
6
7
  import { sanitizeRequestHeaders } from './sanitize-request-headers';
7
8
  import { validateDownloadUrl } from './validate-download-url';
8
9
 
@@ -48,13 +49,11 @@ const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]);
48
49
  * The returned response is the final (non-redirect) response. The caller is
49
50
  * responsible for checking `response.ok` and reading the body.
50
51
  *
51
- * Not solved here: this does string/literal checks only and does not resolve
52
- * DNS, so a hostname that *resolves* to a private address, and DNS rebinding
53
- * (the resolved IP flipping between validation and connect), are not blocked.
54
- * Server deployments fetching untrusted URLs should constrain egress at the
55
- * network layer or inject a Node `fetch` that pins the resolved IP at connect
56
- * time — those need DNS/socket APIs not available on all target runtimes
57
- * (edge, browser, Bun), so they are intentionally not built in.
52
+ * On Node.js, the default fetch resolves every hostname through a validating
53
+ * lookup hook and passes those exact addresses to the connector, preventing
54
+ * hostname-to-private-IP and DNS-rebinding bypasses. An injected fetch is
55
+ * responsible for equivalent connect-time validation. Other runtimes should
56
+ * constrain egress at the network layer when handling untrusted URLs.
58
57
  *
59
58
  * @throws DownloadError if a hop is unsafe, the redirect limit is exceeded, or
60
59
  * a redirect cannot be validated on a non-browser runtime.
@@ -64,7 +63,7 @@ export async function fetchWithValidatedRedirects({
64
63
  headers,
65
64
  abortSignal,
66
65
  maxRedirects = MAX_DOWNLOAD_REDIRECTS,
67
- fetch = globalThis.fetch,
66
+ fetch: customFetch,
68
67
  trustedOrigin,
69
68
  }: {
70
69
  url: string;
@@ -99,13 +98,17 @@ export async function fetchWithValidatedRedirects({
99
98
  for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) {
100
99
  // The developer-configured origin is trusted by definition; validating it
101
100
  // would reject legitimate self-hosted / localhost deployments.
102
- if (
103
- trustedOrigin === undefined ||
104
- !isSameOrigin(currentUrl, trustedOrigin)
105
- ) {
101
+ const isTrustedHop =
102
+ trustedOrigin !== undefined && isSameOrigin(currentUrl, trustedOrigin);
103
+
104
+ if (!isTrustedHop) {
106
105
  validateDownloadUrl(currentUrl);
107
106
  }
108
107
 
108
+ const fetch =
109
+ customFetch ??
110
+ (isTrustedHop ? globalThis.fetch : await getDefaultDownloadFetch());
111
+
109
112
  const response = await fetch(currentUrl, perHopInit('manual'));
110
113
 
111
114
  if (response.type === 'opaqueredirect') {
@@ -19,7 +19,7 @@ export const getFromApi = async <T>({
19
19
  successfulResponseHandler,
20
20
  failedResponseHandler,
21
21
  abortSignal,
22
- fetch = getOriginalFetch(),
22
+ fetch,
23
23
  validateUrl,
24
24
  credentialedOrigin,
25
25
  trustedOrigin,
@@ -65,6 +65,8 @@ export const getFromApi = async <T>({
65
65
  trustedOrigin?: string;
66
66
  }) => {
67
67
  try {
68
+ const requestFetch = fetch ?? getOriginalFetch();
69
+
68
70
  // Withhold caller headers when the URL is not same-origin with the origin
69
71
  // allowed to receive credentials; the user-agent suffix is still applied.
70
72
  const outgoingHeaders =
@@ -86,7 +88,7 @@ export const getFromApi = async <T>({
86
88
  fetch,
87
89
  trustedOrigin,
88
90
  })
89
- : await fetch(url, {
91
+ : await requestFetch(url, {
90
92
  method: 'GET',
91
93
  headers: requestHeaders,
92
94
  signal: abortSignal,
package/src/index.ts CHANGED
@@ -88,6 +88,7 @@ export {
88
88
  type ValidationResult,
89
89
  } from './schema';
90
90
  export { serializeModelOptions } from './serialize-model-options';
91
+ export { SerializationError } from './serialization-error';
91
92
  export { secureJsonParse } from './secure-json-parse';
92
93
  export {
93
94
  StreamingToolCallTracker,
@@ -0,0 +1,204 @@
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 ??= Promise.resolve().then(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
+ function createSafeNodeFetch(): FetchFunction {
143
+ // Load Node-only modules indirectly so browser bundlers do not pull undici
144
+ // and Node built-ins into the browser-facing provider-utils entry point.
145
+ const { createRequire } = loadBuiltinModule<NodeModule>('node:module');
146
+ const { lookup } = loadBuiltinModule<NodeDns>('node:dns');
147
+ const { Agent, fetch } = createRequire(getCurrentModulePath())(
148
+ 'undici',
149
+ ) as Undici;
150
+
151
+ const dispatcher = new Agent({
152
+ connect: {
153
+ lookup: createSafeLookup(lookup as Lookup) as never,
154
+ },
155
+ });
156
+
157
+ return ((input, init) =>
158
+ fetch(
159
+ input as Parameters<typeof fetch>[0],
160
+ {
161
+ ...init,
162
+ dispatcher,
163
+ } as Parameters<typeof fetch>[1],
164
+ ) as unknown as Promise<Response>) satisfies FetchFunction;
165
+ }
166
+
167
+ function loadBuiltinModule<T>(id: string): T {
168
+ const processWithBuiltins = globalThis.process as
169
+ | {
170
+ getBuiltinModule?: (id: string) => unknown;
171
+ }
172
+ | undefined;
173
+ const builtinModule = processWithBuiltins?.getBuiltinModule?.(id);
174
+
175
+ if (builtinModule == null) {
176
+ throw new Error(`Node.js built-in module ${id} is unavailable`);
177
+ }
178
+
179
+ return builtinModule as T;
180
+ }
181
+
182
+ function getCurrentModulePath(): string {
183
+ // `import.meta.url` breaks when provider-utils is rebundled as CommonJS.
184
+ // The caller frame points at this package when loaded directly and at the
185
+ // consuming bundle when inlined, giving createRequire the correct base path.
186
+ const originalPrepareStackTrace = Error.prepareStackTrace;
187
+
188
+ try {
189
+ Error.prepareStackTrace = (_error, callSites) => callSites as never;
190
+
191
+ const error = new Error('Capture current module path');
192
+ Error.captureStackTrace(error, getCurrentModulePath);
193
+ const [caller] = error.stack as unknown as NodeJS.CallSite[];
194
+ const fileName = caller?.getFileName();
195
+
196
+ if (fileName == null) {
197
+ throw new Error('Unable to determine the current module path');
198
+ }
199
+
200
+ return fileName;
201
+ } finally {
202
+ Error.prepareStackTrace = originalPrepareStackTrace;
203
+ }
204
+ }
@@ -0,0 +1,23 @@
1
+ import { AISDKError } from '@ai-sdk/provider';
2
+
3
+ const name = 'AI_SerializationError';
4
+ const marker = `vercel.ai.error.${name}`;
5
+ const symbol = Symbol.for(marker);
6
+
7
+ export class SerializationError extends AISDKError {
8
+ private readonly [symbol] = true; // used in isInstance
9
+
10
+ constructor({
11
+ message = 'Failed to serialize value.',
12
+ cause,
13
+ }: {
14
+ message?: string;
15
+ cause?: unknown;
16
+ } = {}) {
17
+ super({ name, message, cause });
18
+ }
19
+
20
+ static isInstance(error: unknown): error is SerializationError {
21
+ return AISDKError.hasMarker(error, marker);
22
+ }
23
+ }
@@ -1,6 +1,7 @@
1
1
  import type { JSONObject } from '@ai-sdk/provider';
2
2
  import { isJSONSerializable } from './is-json-serializable';
3
3
  import type { Resolvable } from './resolve';
4
+ import { SerializationError } from './serialization-error';
4
5
 
5
6
  /**
6
7
  * Serializes a model instance for workflow step boundaries.
@@ -53,10 +54,10 @@ function resolveSync<T>(value: Resolvable<T>): T {
53
54
  next = (value as () => unknown)();
54
55
  }
55
56
 
56
- // the serialization for workflows currently only supports synchronous values
57
- // TODO introduce SerializationError
58
57
  if (next instanceof Promise) {
59
- throw new Error('Promise returned from resolveSync');
58
+ throw new SerializationError({
59
+ message: 'Cannot serialize asynchronous model options.',
60
+ });
60
61
  }
61
62
 
62
63
  return next as T;
@@ -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;