@ai-sdk/provider-utils 5.0.7 → 5.0.9

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.7",
3
+ "version": "5.0.9",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -0,0 +1,116 @@
1
+ import { removeUndefinedEntries } from './remove-undefined-entries';
2
+ import {
3
+ getWebSocketConstructor,
4
+ readWebSocketMessageText,
5
+ type WebSocketConstructor,
6
+ type WebSocketLike,
7
+ } from './websocket';
8
+
9
+ export interface WebSocketConnection {
10
+ /** Undefined when the constructor threw or the signal was already aborted. */
11
+ socket: WebSocketLike | undefined;
12
+ /**
13
+ * Unregisters the abort listener and closes the socket. Never throws.
14
+ * Handlers stay attached; callers guard their own terminal state.
15
+ */
16
+ close: (code?: number) => void;
17
+ }
18
+
19
+ /**
20
+ * Opens a WebSocket for a provider model, owning the transport-generic layer
21
+ * (analogous to `postToApi` for HTTP): constructor resolution, header hygiene,
22
+ * abort wiring, and message decoding. Callers own the URL, the auth channel
23
+ * (subprotocols vs headers), and the wire protocol.
24
+ */
25
+ export function connectToWebSocket({
26
+ url,
27
+ protocols,
28
+ headers,
29
+ webSocket,
30
+ abortSignal,
31
+ onOpen,
32
+ onMessageText,
33
+ onProcessingError,
34
+ onSocketError,
35
+ onClose,
36
+ onAbort,
37
+ }: {
38
+ url: string | URL;
39
+ protocols?: string | string[];
40
+ headers?: Record<string, string | undefined>;
41
+ webSocket?: WebSocketConstructor;
42
+ abortSignal?: AbortSignal;
43
+ onOpen?: (socket: WebSocketLike) => void;
44
+ /** One decoded message. Throws and rejections go to `onProcessingError`. */
45
+ onMessageText: (text: string) => void | PromiseLike<void>;
46
+ /** Constructor throws and message decoding/processing failures. */
47
+ onProcessingError: (error: unknown) => void;
48
+ onSocketError?: () => void;
49
+ onClose?: () => void;
50
+ /** Also called (without opening a socket) when the signal is already aborted. */
51
+ onAbort?: (reason: unknown) => void;
52
+ }): WebSocketConnection {
53
+ let socket: WebSocketLike | undefined;
54
+ let abortListener: (() => void) | undefined;
55
+
56
+ const close = (code?: number) => {
57
+ if (abortListener != null) {
58
+ abortSignal?.removeEventListener('abort', abortListener);
59
+ abortListener = undefined;
60
+ }
61
+ try {
62
+ socket?.close(code);
63
+ } catch {
64
+ // socket may already be closed
65
+ }
66
+ };
67
+
68
+ if (abortSignal?.aborted) {
69
+ onAbort?.(abortSignal.reason ?? new Error('Aborted'));
70
+ return { socket: undefined, close };
71
+ }
72
+
73
+ try {
74
+ const WebSocketConstructor = getWebSocketConstructor(webSocket);
75
+ // native `WebSocket` ignores the headers option; header-capable
76
+ // implementations like `ws` forward it and throw on undefined values:
77
+ socket = new WebSocketConstructor(url, protocols, {
78
+ headers: removeUndefinedEntries(headers ?? {}),
79
+ });
80
+ } catch (error) {
81
+ onProcessingError(error);
82
+ return { socket: undefined, close };
83
+ }
84
+
85
+ if (abortSignal != null && onAbort != null) {
86
+ abortListener = () => onAbort(abortSignal.reason ?? new Error('Aborted'));
87
+ abortSignal.addEventListener('abort', abortListener, { once: true });
88
+ }
89
+
90
+ const openedSocket = socket;
91
+ socket.onopen = () => {
92
+ try {
93
+ onOpen?.(openedSocket);
94
+ } catch (error) {
95
+ onProcessingError(error);
96
+ }
97
+ };
98
+ // Messages are processed through a promise tail so async decoding (e.g.
99
+ // Blob frames) cannot reorder them, and error/close handling cannot
100
+ // overtake a still-decoding terminal frame.
101
+ let tail: Promise<void> = Promise.resolve();
102
+ socket.onmessage = event => {
103
+ tail = tail
104
+ .then(() => readWebSocketMessageText(event.data))
105
+ .then(text => onMessageText(text))
106
+ .catch(onProcessingError);
107
+ };
108
+ socket.onerror = () => {
109
+ tail = tail.then(() => onSocketError?.()).catch(onProcessingError);
110
+ };
111
+ socket.onclose = () => {
112
+ tail = tail.then(() => onClose?.()).catch(onProcessingError);
113
+ };
114
+
115
+ return { socket, close };
116
+ }
@@ -1,30 +1,61 @@
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';
6
+ import { sanitizeRequestHeaders } from './sanitize-request-headers';
4
7
  import { validateDownloadUrl } from './validate-download-url';
5
8
 
6
9
  const MAX_DOWNLOAD_REDIRECTS = 10;
7
10
 
11
+ // Redirect status codes per the fetch spec (https://fetch.spec.whatwg.org/#redirect-status).
12
+ // Notably 300 (Multiple Choices) and 304 (Not Modified) are NOT redirects,
13
+ // even when a server attaches a Location header.
14
+ const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]);
15
+
8
16
  /**
9
- * Fetches a URL while enforcing the SSRF download guard on every hop.
17
+ * Fetches a URL while enforcing the download guard on every hop.
10
18
  *
11
19
  * Redirects are followed manually (`redirect: 'manual'`) so each hop is
12
20
  * validated with {@link validateDownloadUrl} *before* it is requested. Relying
13
21
  * on the default `redirect: 'follow'` would issue the request to a redirect
14
22
  * target (e.g. an internal address) before we ever see its URL, defeating the
15
- * SSRF guard.
23
+ * guard.
24
+ *
25
+ * Request headers are also protected: {@link sanitizeRequestHeaders} strips
26
+ * proxy/metadata/cookie/hop-by-hop headers before the first request, and all
27
+ * caller headers except `User-Agent` are dropped on a cross-origin redirect.
28
+ * The fetch spec only strips `Authorization` on cross-origin redirects because
29
+ * in a browser, CORS preflighting protects custom headers; there is no CORS on
30
+ * the server, so provider API keys carried in custom headers (e.g. `x-key`)
31
+ * must be dropped here as well.
16
32
  *
17
33
  * A `redirect: 'manual'` request yields an unreadable opaque response in the
18
34
  * browser (and in other spec-compliant fetch implementations), so the redirect
19
35
  * 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.
36
+ * natively because reaching an internal network is not possible (fetch is
37
+ * constrained by CORS and cannot reach a server's internal network or
38
+ * cloud-metadata). On any other runtime we cannot validate the hop, so we fail
39
+ * closed rather than follow it blindly and bypass the guard.
40
+ *
41
+ * A hop that is same-origin with `trustedOrigin` (the developer-configured
42
+ * provider endpoint) skips target validation: that origin is exactly what an
43
+ * unvalidated, config-derived request would fetch anyway, and validating it
44
+ * would break legitimate self-hosted / localhost deployments whose response
45
+ * URLs point back at the configured host. Hops on any other origin are always
46
+ * validated.
24
47
  *
25
48
  * The returned response is the final (non-redirect) response. The caller is
26
49
  * responsible for checking `response.ok` and reading the body.
27
50
  *
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.
58
+ *
28
59
  * @throws DownloadError if a hop is unsafe, the redirect limit is exceeded, or
29
60
  * a redirect cannot be validated on a non-browser runtime.
30
61
  */
@@ -33,29 +64,49 @@ export async function fetchWithValidatedRedirects({
33
64
  headers,
34
65
  abortSignal,
35
66
  maxRedirects = MAX_DOWNLOAD_REDIRECTS,
67
+ fetch = globalThis.fetch,
68
+ trustedOrigin,
36
69
  }: {
37
70
  url: string;
38
71
  headers?: HeadersInit;
39
72
  abortSignal?: AbortSignal;
40
73
  maxRedirects?: number;
74
+ fetch?: FetchFunction;
75
+ /**
76
+ * A developer-configured origin (e.g. the provider's `baseURL`) whose hops
77
+ * skip target validation. Must never be derived from response data.
78
+ */
79
+ trustedOrigin?: string;
41
80
  }): 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
- }
81
+ // Left undefined when no headers are provided (bare request); otherwise
82
+ // sanitized once and replaced on a cross-origin hop (credential drop below).
83
+ let currentHeaders =
84
+ headers === undefined ? undefined : sanitizeRequestHeaders(headers);
85
+
86
+ const perHopInit = (redirect: RequestRedirect): RequestInit => {
87
+ const init: RequestInit = { signal: abortSignal, redirect };
88
+ if (currentHeaders !== undefined) {
89
+ // Snapshot per hop: the platform fetch reads headers synchronously, but
90
+ // an injected fetch may defer, and hops between credential drops would
91
+ // otherwise share one instance.
92
+ init.headers = new Headers(currentHeaders);
93
+ }
94
+ return init;
95
+ };
49
96
 
50
97
  let currentUrl = url;
51
98
  // The bound also acts as a backstop against an unterminated redirect chain.
52
99
  for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) {
53
- validateDownloadUrl(currentUrl);
100
+ // The developer-configured origin is trusted by definition; validating it
101
+ // would reject legitimate self-hosted / localhost deployments.
102
+ if (
103
+ trustedOrigin === undefined ||
104
+ !isSameOrigin(currentUrl, trustedOrigin)
105
+ ) {
106
+ validateDownloadUrl(currentUrl);
107
+ }
54
108
 
55
- const response = await fetch(currentUrl, {
56
- ...baseInit,
57
- redirect: 'manual',
58
- });
109
+ const response = await fetch(currentUrl, perHopInit('manual'));
59
110
 
60
111
  if (response.type === 'opaqueredirect') {
61
112
  if (!isBrowserRuntime()) {
@@ -64,16 +115,30 @@ export async function fetchWithValidatedRedirects({
64
115
  message: `Redirect from ${currentUrl} could not be validated and was blocked`,
65
116
  });
66
117
  }
67
- return await fetch(currentUrl, { ...baseInit, redirect: 'follow' });
118
+ return await fetch(currentUrl, perHopInit('follow'));
68
119
  }
69
120
 
70
121
  const location = response.headers.get('location');
71
- if (response.status >= 300 && response.status < 400 && location) {
122
+ if (REDIRECT_STATUS_CODES.has(response.status) && location) {
72
123
  // 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
124
+ // hop. Whether that hop is followed or rejected by the guard, an
74
125
  // unconsumed 3xx body would leak the underlying socket.
75
126
  await cancelResponseBody(response);
76
- currentUrl = new URL(location, currentUrl).toString();
127
+ const nextUrl = new URL(location, currentUrl).toString();
128
+
129
+ // Drop all caller headers except the user-agent before following a
130
+ // redirect that crosses origin. Only stripping Authorization (as the
131
+ // fetch spec does) is not enough on the server: providers authenticate
132
+ // with custom headers too (e.g. `x-key`), and without CORS there is
133
+ // nothing else stopping them from riding to a foreign host.
134
+ if (currentHeaders !== undefined && !isSameOrigin(nextUrl, currentUrl)) {
135
+ const userAgent = currentHeaders.get('user-agent');
136
+ currentHeaders = new Headers(
137
+ userAgent == null ? undefined : { 'user-agent': userAgent },
138
+ );
139
+ }
140
+
141
+ currentUrl = nextUrl;
77
142
  continue;
78
143
  }
79
144
 
@@ -1,8 +1,10 @@
1
1
  import { APICallError } from '@ai-sdk/provider';
2
2
  import { extractResponseHeaders } from './extract-response-headers';
3
+ import { fetchWithValidatedRedirects } from './fetch-with-validated-redirects';
3
4
  import type { FetchFunction } from './fetch-function';
4
5
  import { handleFetchError } from './handle-fetch-error';
5
6
  import { isAbortError } from './is-abort-error';
7
+ import { isSameOrigin } from './is-same-origin';
6
8
  import type { ResponseHandler } from './response-handler';
7
9
  import { getRuntimeEnvironmentUserAgent } from './get-runtime-environment-user-agent';
8
10
  import { withUserAgentSuffix } from './with-user-agent-suffix';
@@ -18,6 +20,9 @@ export const getFromApi = async <T>({
18
20
  failedResponseHandler,
19
21
  abortSignal,
20
22
  fetch = getOriginalFetch(),
23
+ validateUrl,
24
+ credentialedOrigin,
25
+ trustedOrigin,
21
26
  }: {
22
27
  url: string;
23
28
  headers?: Record<string, string | undefined>;
@@ -25,17 +30,67 @@ export const getFromApi = async <T>({
25
30
  successfulResponseHandler: ResponseHandler<T>;
26
31
  abortSignal?: AbortSignal;
27
32
  fetch?: FetchFunction;
33
+ /**
34
+ * Set `true` when `url` is untrusted (e.g. taken from a provider response
35
+ * body): it is routed through {@link fetchWithValidatedRedirects}, which
36
+ * rejects private/loopback/link-local targets and re-validates redirect
37
+ * hops; blocked URLs throw `DownloadError`. Set `false` only for URLs built
38
+ * from a developer-configured endpoint.
39
+ *
40
+ * Optional for backwards compatibility with existing callers; omitting it
41
+ * behaves like `false` (no validation). Provider code in this repository
42
+ * must always pass it explicitly so every call site makes a visible trust
43
+ * decision — see `contributing/secure-url-handling.md`.
44
+ */
45
+ validateUrl?: boolean;
46
+ /**
47
+ * When set, `headers` are sent only if `url` is same-origin with this origin
48
+ * (the user-agent suffix is always kept). Pass the provider's configured
49
+ * base URL alongside `validateUrl: true` so credentials never ride a request
50
+ * to a response-supplied host on a different origin (e.g. a CDN). Redirects
51
+ * that later cross origin drop all caller headers regardless (see
52
+ * {@link fetchWithValidatedRedirects}).
53
+ */
54
+ credentialedOrigin?: string;
55
+ /**
56
+ * A developer-configured origin (e.g. the provider's `baseURL`) that is
57
+ * exempt from URL validation when `validateUrl` is `true`. A response URL
58
+ * (or redirect hop) that is same-origin with it is fetched without target
59
+ * validation — it points at exactly the host a config-derived
60
+ * `validateUrl: false` request would fetch anyway, so blocking it would
61
+ * only break legitimate self-hosted / localhost deployments whose response
62
+ * URLs point back at the configured host. Hops on any other origin are
63
+ * still validated. Must never be derived from response data.
64
+ */
65
+ trustedOrigin?: string;
28
66
  }) => {
29
67
  try {
30
- const response = await fetch(url, {
31
- method: 'GET',
32
- headers: withUserAgentSuffix(
33
- headers,
34
- `ai-sdk/provider-utils/${VERSION}`,
35
- getRuntimeEnvironmentUserAgent(),
36
- ),
37
- signal: abortSignal,
38
- });
68
+ // Withhold caller headers when the URL is not same-origin with the origin
69
+ // allowed to receive credentials; the user-agent suffix is still applied.
70
+ const outgoingHeaders =
71
+ credentialedOrigin !== undefined && !isSameOrigin(url, credentialedOrigin)
72
+ ? {}
73
+ : headers;
74
+
75
+ const requestHeaders = withUserAgentSuffix(
76
+ outgoingHeaders,
77
+ `ai-sdk/provider-utils/${VERSION}`,
78
+ getRuntimeEnvironmentUserAgent(),
79
+ );
80
+
81
+ const response = validateUrl
82
+ ? await fetchWithValidatedRedirects({
83
+ url,
84
+ headers: requestHeaders,
85
+ abortSignal,
86
+ fetch,
87
+ trustedOrigin,
88
+ })
89
+ : await fetch(url, {
90
+ method: 'GET',
91
+ headers: requestHeaders,
92
+ signal: abortSignal,
93
+ });
39
94
 
40
95
  const responseHeaders = extractResponseHeaders(response);
41
96
 
package/src/index.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  export { asArray } from './as-array';
2
2
  export type { Arrayable } from './as-array';
3
3
  export * from './combine-headers';
4
+ export {
5
+ connectToWebSocket,
6
+ type WebSocketConnection,
7
+ } from './connect-to-websocket';
4
8
  export { convertAsyncIteratorToReadableStream } from './convert-async-iterator-to-readable-stream';
5
9
  export { convertInlineFileDataToUint8Array } from './convert-inline-file-data-to-uint8-array';
6
10
  export { convertImageModelFileToDataUri } from './convert-image-model-file-to-data-uri';
@@ -91,7 +95,17 @@ export {
91
95
  type StreamingToolCallTrackerOptions,
92
96
  } from './streaming-tool-call-tracker';
93
97
  export { stripFileExtension } from './strip-file-extension';
98
+ export {
99
+ TRANSCRIPTION_STREAM_AUDIO_DONE_FRAME_TYPE as EXPERIMENTAL_TRANSCRIPTION_STREAM_AUDIO_DONE_FRAME_TYPE,
100
+ TRANSCRIPTION_STREAM_START_FRAME_TYPE as EXPERIMENTAL_TRANSCRIPTION_STREAM_START_FRAME_TYPE,
101
+ parseTranscriptionStreamClientFrame as experimental_parseTranscriptionStreamClientFrame,
102
+ parseTranscriptionStreamPart as experimental_parseTranscriptionStreamPart,
103
+ serializeTranscriptionStreamPart as experimental_serializeTranscriptionStreamPart,
104
+ type TranscriptionStreamClientFrame as Experimental_TranscriptionStreamClientFrame,
105
+ type TranscriptionStreamStartFrame as Experimental_TranscriptionStreamStartFrame,
106
+ } from './transcription-stream-envelope';
94
107
  export * from './uint8-utils';
108
+ export { validateBaseURL } from './validate-base-url';
95
109
  export { validateDownloadUrl } from './validate-download-url';
96
110
  export * from './validate-types';
97
111
  export { VERSION } from './version';
@@ -99,6 +113,7 @@ export {
99
113
  getWebSocketConstructor,
100
114
  readWebSocketMessageText,
101
115
  toWebSocketUrl,
116
+ waitForWebSocketBufferDrain,
102
117
  type WebSocketConstructor,
103
118
  type WebSocketLike,
104
119
  } from './websocket';
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Request headers stripped before fetching an untrusted URL: host/virtual-host
3
+ * routing, proxy/origin spoofing, cloud-metadata, cookies, and hop-by-hop
4
+ * transport headers (RFC 7230 §6.1).
5
+ *
6
+ * `Authorization` and other credential-bearing caller headers (e.g. `x-key`)
7
+ * are intentionally not listed — they're needed on the first hop of some
8
+ * provider polling calls. Instead, all caller headers except the user-agent are
9
+ * dropped on a cross-origin redirect (see `fetch-with-validated-redirects`).
10
+ */
11
+ const BLOCKED_REQUEST_HEADERS: readonly string[] = [
12
+ // Hop-by-hop / transport (RFC 7230 §6.1)
13
+ 'connection',
14
+ 'keep-alive',
15
+ 'te',
16
+ 'trailer',
17
+ 'transfer-encoding',
18
+ 'upgrade',
19
+
20
+ // Host / virtual-host routing
21
+ 'host',
22
+
23
+ // Proxy / origin spoofing
24
+ 'forwarded',
25
+ 'proxy-authorization',
26
+ 'via',
27
+ 'x-forwarded-for',
28
+ 'x-forwarded-host',
29
+ 'x-forwarded-proto',
30
+ 'x-real-ip',
31
+
32
+ // Cloud metadata (GCP, AWS IMDSv1/v2, Azure, Alibaba, DigitalOcean)
33
+ 'metadata',
34
+ 'metadata-flavor',
35
+ 'x-aws-ec2-metadata-token',
36
+ 'x-metadata-token',
37
+
38
+ // Session / cookie
39
+ 'cookie',
40
+ 'set-cookie',
41
+ ];
42
+
43
+ /**
44
+ * Returns a fresh `Headers` built from `input` with {@link BLOCKED_REQUEST_HEADERS}
45
+ * removed. The input is never mutated.
46
+ */
47
+ export function sanitizeRequestHeaders(input: HeadersInit): Headers {
48
+ const headers = new Headers(input);
49
+ for (const name of BLOCKED_REQUEST_HEADERS) {
50
+ headers.delete(name);
51
+ }
52
+ return headers;
53
+ }