@ai-sdk/provider-utils 4.0.60 → 4.0.62
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 +15 -0
- package/dist/index.d.mts +61 -12
- package/dist/index.d.ts +61 -12
- package/dist/index.js +86 -21
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +85 -21
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/encode-path-segment.ts +15 -0
- package/src/fetch-with-validated-redirects.ts +65 -28
- package/src/get-from-api.ts +65 -10
- package/src/index.ts +1 -0
- package/src/sanitize-request-headers.ts +53 -0
- package/src/validate-download-url.ts +3 -2
package/package.json
CHANGED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encodes a raw identifier as one URL path segment, including standalone dot
|
|
3
|
+
* segments that URL parsers would otherwise normalize. Pass unencoded values;
|
|
4
|
+
* for resource paths with intentional slashes, encode each segment separately.
|
|
5
|
+
*/
|
|
6
|
+
export function encodePathSegment(value: string): string {
|
|
7
|
+
const encodedValue = encodeURIComponent(value);
|
|
8
|
+
|
|
9
|
+
// URL parsing normalizes both literal and percent-encoded dot segments.
|
|
10
|
+
return encodedValue === '.'
|
|
11
|
+
? '%252E'
|
|
12
|
+
: encodedValue === '..'
|
|
13
|
+
? '%252E%252E'
|
|
14
|
+
: encodedValue;
|
|
15
|
+
}
|
|
@@ -4,10 +4,16 @@ import type { FetchFunction } from './fetch-function';
|
|
|
4
4
|
import { isBrowserRuntime } from './is-browser-runtime';
|
|
5
5
|
import { isSameOrigin } from './is-same-origin';
|
|
6
6
|
import { getDefaultDownloadFetch } from './safe-node-fetch';
|
|
7
|
+
import { sanitizeRequestHeaders } from './sanitize-request-headers';
|
|
7
8
|
import { validateDownloadUrl } from './validate-download-url';
|
|
8
9
|
|
|
9
10
|
const MAX_DOWNLOAD_REDIRECTS = 10;
|
|
10
11
|
|
|
12
|
+
// Redirect status codes per the fetch spec (https://fetch.spec.whatwg.org/#redirect-status).
|
|
13
|
+
// Notably 300 (Multiple Choices) and 304 (Not Modified) are NOT redirects,
|
|
14
|
+
// even when a server attaches a Location header.
|
|
15
|
+
const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]);
|
|
16
|
+
|
|
11
17
|
async function getValidatedFetch(
|
|
12
18
|
customFetch: FetchFunction | undefined,
|
|
13
19
|
): Promise<FetchFunction> {
|
|
@@ -66,24 +72,34 @@ export async function fetchWithValidatedEndpoint({
|
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
/**
|
|
69
|
-
* Fetches a URL while enforcing the
|
|
75
|
+
* Fetches a URL while enforcing the download guard on every hop.
|
|
70
76
|
*
|
|
71
77
|
* Redirects are followed manually (`redirect: 'manual'`) so each hop is
|
|
72
78
|
* validated with {@link validateDownloadUrl} *before* it is requested. Relying
|
|
73
79
|
* on the default `redirect: 'follow'` would issue the request to a redirect
|
|
74
80
|
* target (e.g. an internal address) before we ever see its URL, defeating the
|
|
75
|
-
*
|
|
81
|
+
* guard.
|
|
82
|
+
*
|
|
83
|
+
* Request headers are also protected: {@link sanitizeRequestHeaders} strips
|
|
84
|
+
* proxy/metadata/cookie/hop-by-hop headers before the first request, and all
|
|
85
|
+
* caller headers except `User-Agent` are dropped on a cross-origin redirect.
|
|
86
|
+
* Credentials and custom headers are preserved on the first hop for backwards
|
|
87
|
+
* compatibility. The caller must ensure that the initial URL may receive them.
|
|
88
|
+
* The fetch spec only strips `Authorization` on cross-origin redirects because
|
|
89
|
+
* in a browser, CORS preflighting protects custom headers; there is no CORS on
|
|
90
|
+
* the server, so provider API keys carried in custom headers (e.g. `x-key`)
|
|
91
|
+
* must be dropped here as well.
|
|
76
92
|
*
|
|
77
93
|
* A `redirect: 'manual'` request yields an unreadable opaque response in the
|
|
78
94
|
* browser (and in other spec-compliant fetch implementations), so the redirect
|
|
79
95
|
* target cannot be validated here. In a real browser this is safe to follow
|
|
80
|
-
* natively because
|
|
81
|
-
* cannot reach a server's internal network or
|
|
82
|
-
* runtime we cannot validate the hop, so we fail
|
|
83
|
-
* blindly and bypass the
|
|
96
|
+
* natively because reaching an internal network is not possible (fetch is
|
|
97
|
+
* constrained by CORS and cannot reach a server's internal network or
|
|
98
|
+
* cloud-metadata). On any other runtime we cannot validate the hop, so we fail
|
|
99
|
+
* closed rather than follow it blindly and bypass the guard.
|
|
84
100
|
*
|
|
85
101
|
* A hop that is same-origin with `trustedOrigin` (the developer-configured
|
|
86
|
-
* endpoint) skips target validation: that origin is exactly what an
|
|
102
|
+
* provider endpoint) skips target validation: that origin is exactly what an
|
|
87
103
|
* unvalidated, config-derived request would fetch anyway, and validating it
|
|
88
104
|
* would break legitimate self-hosted / localhost deployments whose response
|
|
89
105
|
* URLs point back at the configured host. Hops on any other origin are always
|
|
@@ -94,7 +110,8 @@ export async function fetchWithValidatedEndpoint({
|
|
|
94
110
|
*
|
|
95
111
|
* On Node.js, the default fetch resolves every hostname through a validating
|
|
96
112
|
* lookup hook and passes those exact addresses to the connector, preventing
|
|
97
|
-
* hostname-to-private-IP and DNS-rebinding bypasses.
|
|
113
|
+
* hostname-to-private-IP and DNS-rebinding bypasses. An injected fetch is
|
|
114
|
+
* responsible for equivalent connect-time validation. Other runtimes should
|
|
98
115
|
* constrain egress at the network layer when handling untrusted URLs.
|
|
99
116
|
*
|
|
100
117
|
* @throws DownloadError if a hop is unsafe, the redirect limit is exceeded, or
|
|
@@ -114,18 +131,26 @@ export async function fetchWithValidatedRedirects({
|
|
|
114
131
|
maxRedirects?: number;
|
|
115
132
|
fetch?: FetchFunction;
|
|
116
133
|
/**
|
|
117
|
-
* A developer-configured origin
|
|
118
|
-
* never be derived from response data.
|
|
134
|
+
* A developer-configured origin (e.g. the provider's `baseURL`) whose hops
|
|
135
|
+
* skip target validation. Must never be derived from response data.
|
|
119
136
|
*/
|
|
120
137
|
trustedOrigin?: string;
|
|
121
138
|
}): Promise<Response> {
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
139
|
+
// Left undefined when no headers are provided (bare request); otherwise
|
|
140
|
+
// sanitized once and replaced on a cross-origin hop (credential drop below).
|
|
141
|
+
let currentHeaders =
|
|
142
|
+
headers === undefined ? undefined : sanitizeRequestHeaders(headers);
|
|
143
|
+
|
|
144
|
+
const perHopInit = (redirect: RequestRedirect): RequestInit => {
|
|
145
|
+
const init: RequestInit = { signal: abortSignal, redirect };
|
|
146
|
+
if (currentHeaders !== undefined) {
|
|
147
|
+
// Snapshot per hop: the platform fetch reads headers synchronously, but
|
|
148
|
+
// an injected fetch may defer, and hops between credential drops would
|
|
149
|
+
// otherwise share one instance.
|
|
150
|
+
init.headers = new Headers(currentHeaders);
|
|
151
|
+
}
|
|
152
|
+
return init;
|
|
153
|
+
};
|
|
129
154
|
|
|
130
155
|
let currentUrl = url;
|
|
131
156
|
// The bound also acts as a backstop against an unterminated redirect chain.
|
|
@@ -146,10 +171,7 @@ export async function fetchWithValidatedRedirects({
|
|
|
146
171
|
? globalThis.fetch
|
|
147
172
|
: await getValidatedFetch(customFetch);
|
|
148
173
|
|
|
149
|
-
const response = await fetch(currentUrl,
|
|
150
|
-
...baseInit,
|
|
151
|
-
redirect: 'manual',
|
|
152
|
-
});
|
|
174
|
+
const response = await fetch(currentUrl, perHopInit('manual'));
|
|
153
175
|
|
|
154
176
|
if (response.type === 'opaqueredirect') {
|
|
155
177
|
if (!isBrowserRuntime()) {
|
|
@@ -158,16 +180,31 @@ export async function fetchWithValidatedRedirects({
|
|
|
158
180
|
message: `Redirect from ${currentUrl} could not be validated and was blocked`,
|
|
159
181
|
});
|
|
160
182
|
}
|
|
161
|
-
return await fetch(currentUrl,
|
|
183
|
+
return await fetch(currentUrl, perHopInit('follow'));
|
|
162
184
|
}
|
|
163
185
|
|
|
164
186
|
const location = response.headers?.get('location');
|
|
165
|
-
if (
|
|
166
|
-
//
|
|
167
|
-
// hop.
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
187
|
+
if (REDIRECT_STATUS_CODES.has(response.status) && location) {
|
|
188
|
+
// Start releasing the redirect response's connection before moving to
|
|
189
|
+
// the next hop. Do not wait for cancellation: when a fetch wrapper keeps
|
|
190
|
+
// an unread response clone, tee cancellation can remain pending until
|
|
191
|
+
// the cloned branch is consumed.
|
|
192
|
+
void cancelResponseBody(response);
|
|
193
|
+
const nextUrl = new URL(location, currentUrl).toString();
|
|
194
|
+
|
|
195
|
+
// Drop all caller headers except the user-agent before following a
|
|
196
|
+
// redirect that crosses origin. Only stripping Authorization (as the
|
|
197
|
+
// fetch spec does) is not enough on the server: providers authenticate
|
|
198
|
+
// with custom headers too (e.g. `x-key`), and without CORS there is
|
|
199
|
+
// nothing else stopping them from riding to a foreign host.
|
|
200
|
+
if (currentHeaders !== undefined && !isSameOrigin(nextUrl, currentUrl)) {
|
|
201
|
+
const userAgent = currentHeaders.get('user-agent');
|
|
202
|
+
currentHeaders = new Headers(
|
|
203
|
+
userAgent == null ? undefined : { 'user-agent': userAgent },
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
currentUrl = nextUrl;
|
|
171
208
|
continue;
|
|
172
209
|
}
|
|
173
210
|
|
package/src/get-from-api.ts
CHANGED
|
@@ -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';
|
|
@@ -17,7 +19,10 @@ export const getFromApi = async <T>({
|
|
|
17
19
|
successfulResponseHandler,
|
|
18
20
|
failedResponseHandler,
|
|
19
21
|
abortSignal,
|
|
20
|
-
fetch
|
|
22
|
+
fetch,
|
|
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).
|
|
42
|
+
*/
|
|
43
|
+
validateUrl?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* When set, `headers` are sent only if `url` is same-origin with this origin
|
|
46
|
+
* (the user-agent suffix is always kept). Pass the provider's configured
|
|
47
|
+
* base URL alongside `validateUrl: true` so credentials never ride a request
|
|
48
|
+
* to a response-supplied host on a different origin (e.g. a CDN). Redirects
|
|
49
|
+
* that later cross origin drop all caller headers regardless (see
|
|
50
|
+
* {@link fetchWithValidatedRedirects}).
|
|
51
|
+
*/
|
|
52
|
+
credentialedOrigin?: string;
|
|
53
|
+
/**
|
|
54
|
+
* A developer-configured origin (e.g. the provider's `baseURL`) that is
|
|
55
|
+
* exempt from URL validation when `validateUrl` is `true`. A response URL
|
|
56
|
+
* (or redirect hop) that is same-origin with it is fetched without target
|
|
57
|
+
* validation — it points at exactly the host a config-derived
|
|
58
|
+
* `validateUrl: false` request would fetch anyway, so blocking it would
|
|
59
|
+
* only break legitimate self-hosted / localhost deployments whose response
|
|
60
|
+
* URLs point back at the configured host. Hops on any other origin are
|
|
61
|
+
* still validated. Must never be derived from response data.
|
|
62
|
+
*/
|
|
63
|
+
trustedOrigin?: string;
|
|
28
64
|
}) => {
|
|
29
65
|
try {
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
66
|
+
const requestFetch = fetch ?? getOriginalFetch();
|
|
67
|
+
|
|
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 requestFetch(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
|
@@ -26,6 +26,7 @@ export {
|
|
|
26
26
|
} from './fetch-with-validated-redirects';
|
|
27
27
|
export * from './fetch-function';
|
|
28
28
|
export { createIdGenerator, generateId, type IdGenerator } from './generate-id';
|
|
29
|
+
export { encodePathSegment } from './encode-path-segment';
|
|
29
30
|
export * from './get-error-message';
|
|
30
31
|
export * from './get-from-api';
|
|
31
32
|
export { getRuntimeEnvironmentUserAgent } from './get-runtime-environment-user-agent';
|
|
@@ -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 because trusted provider requests may need
|
|
8
|
+
* them. `fetchWithValidatedRedirects` drops all caller headers except the
|
|
9
|
+
* user-agent on a cross-origin redirect.
|
|
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
|
+
}
|
|
@@ -46,11 +46,12 @@ export function validateDownloadUrl(url: string): void {
|
|
|
46
46
|
});
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
// Block localhost and
|
|
49
|
+
// Block localhost and domains reserved for local/private use.
|
|
50
50
|
if (
|
|
51
51
|
hostname === 'localhost' ||
|
|
52
52
|
hostname.endsWith('.local') ||
|
|
53
|
-
hostname.endsWith('.localhost')
|
|
53
|
+
hostname.endsWith('.localhost') ||
|
|
54
|
+
hostname.endsWith('.internal')
|
|
54
55
|
) {
|
|
55
56
|
throw new DownloadError({
|
|
56
57
|
url,
|