@ai-sdk/provider-utils 5.0.14 → 5.0.16
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 +14 -0
- package/dist/index.d.ts +28 -12
- package/dist/index.js +179 -51
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/fetch-with-validated-redirects.ts +15 -12
- package/src/get-from-api.ts +4 -2
- package/src/index.ts +1 -0
- package/src/is-record.ts +6 -0
- package/src/safe-node-fetch.ts +204 -0
- package/src/transcription-stream-envelope.ts +11 -14
- package/src/types/index.ts +6 -0
- package/src/types/tool-caller.ts +36 -0
- package/src/validate-download-url.ts +30 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-sdk/provider-utils",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.16",
|
|
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": {
|
|
@@ -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
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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
|
|
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
|
-
|
|
103
|
-
trustedOrigin
|
|
104
|
-
|
|
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') {
|
package/src/get-from-api.ts
CHANGED
|
@@ -19,7 +19,7 @@ export const getFromApi = async <T>({
|
|
|
19
19
|
successfulResponseHandler,
|
|
20
20
|
failedResponseHandler,
|
|
21
21
|
abortSignal,
|
|
22
|
-
fetch
|
|
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
|
|
91
|
+
: await requestFetch(url, {
|
|
90
92
|
method: 'GET',
|
|
91
93
|
headers: requestHeaders,
|
|
92
94
|
signal: abortSignal,
|
package/src/index.ts
CHANGED
|
@@ -39,6 +39,7 @@ export { isBuffer } from './is-buffer';
|
|
|
39
39
|
export { isSameOrigin } from './is-same-origin';
|
|
40
40
|
export { isNonNullable } from './is-non-nullable';
|
|
41
41
|
export { isProviderReference } from './is-provider-reference';
|
|
42
|
+
export { isRecord } from './is-record';
|
|
42
43
|
export { isUrlSupported } from './is-url-supported';
|
|
43
44
|
export * from './load-api-key';
|
|
44
45
|
export { loadOptionalSetting } from './load-optional-setting';
|
package/src/is-record.ts
ADDED
|
@@ -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
|
+
}
|
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
Experimental_TranscriptionModelV4StreamPart as TranscriptionModelV4StreamPart,
|
|
3
3
|
JSONObject,
|
|
4
4
|
} from '@ai-sdk/provider';
|
|
5
|
+
import { isRecord } from './is-record';
|
|
5
6
|
import { secureJsonParse } from './secure-json-parse';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -240,7 +241,7 @@ export function parseTranscriptionStreamPart(
|
|
|
240
241
|
case 'transcript-delta':
|
|
241
242
|
return isString(part.delta) &&
|
|
242
243
|
isOptional(part.id, isString) &&
|
|
243
|
-
isOptional(part.providerMetadata,
|
|
244
|
+
isOptional(part.providerMetadata, isRecord)
|
|
244
245
|
? part
|
|
245
246
|
: undefined;
|
|
246
247
|
|
|
@@ -250,7 +251,7 @@ export function parseTranscriptionStreamPart(
|
|
|
250
251
|
isOptional(part.startSecond, isNumber) &&
|
|
251
252
|
isOptional(part.durationInSeconds, isNumber) &&
|
|
252
253
|
isOptional(part.channelIndex, isNumber) &&
|
|
253
|
-
isOptional(part.providerMetadata,
|
|
254
|
+
isOptional(part.providerMetadata, isRecord)
|
|
254
255
|
? part
|
|
255
256
|
: undefined;
|
|
256
257
|
|
|
@@ -260,7 +261,7 @@ export function parseTranscriptionStreamPart(
|
|
|
260
261
|
isOptional(part.startSecond, isNumber) &&
|
|
261
262
|
isOptional(part.endSecond, isNumber) &&
|
|
262
263
|
isOptional(part.channelIndex, isNumber) &&
|
|
263
|
-
isOptional(part.providerMetadata,
|
|
264
|
+
isOptional(part.providerMetadata, isRecord)
|
|
264
265
|
? part
|
|
265
266
|
: undefined;
|
|
266
267
|
|
|
@@ -270,7 +271,7 @@ export function parseTranscriptionStreamPart(
|
|
|
270
271
|
part.segments.every(isSegment) &&
|
|
271
272
|
isOptional(part.language, isString) &&
|
|
272
273
|
isOptional(part.durationInSeconds, isNumber) &&
|
|
273
|
-
isOptional(part.providerMetadata,
|
|
274
|
+
isOptional(part.providerMetadata, isRecord)
|
|
274
275
|
? part
|
|
275
276
|
: undefined;
|
|
276
277
|
|
|
@@ -278,7 +279,7 @@ export function parseTranscriptionStreamPart(
|
|
|
278
279
|
if (
|
|
279
280
|
!(
|
|
280
281
|
isOptional(part.modelId, isString) &&
|
|
281
|
-
isOptional(part.headers,
|
|
282
|
+
isOptional(part.headers, isRecord)
|
|
282
283
|
)
|
|
283
284
|
) {
|
|
284
285
|
return undefined;
|
|
@@ -323,19 +324,15 @@ function isOptional(
|
|
|
323
324
|
return value === undefined || check(value);
|
|
324
325
|
}
|
|
325
326
|
|
|
326
|
-
function isPlainObject(value: unknown): boolean {
|
|
327
|
-
return typeof value === 'object' && value != null && !Array.isArray(value);
|
|
328
|
-
}
|
|
329
|
-
|
|
330
327
|
function isWarning(value: unknown): boolean {
|
|
331
|
-
return
|
|
328
|
+
return isRecord(value) && isString(value.type);
|
|
332
329
|
}
|
|
333
330
|
|
|
334
331
|
function isSegment(value: unknown): boolean {
|
|
335
332
|
return (
|
|
336
|
-
|
|
337
|
-
isString(
|
|
338
|
-
isNumber(
|
|
339
|
-
isNumber(
|
|
333
|
+
isRecord(value) &&
|
|
334
|
+
isString(value.text) &&
|
|
335
|
+
isNumber(value.startSecond) &&
|
|
336
|
+
isNumber(value.endSecond)
|
|
340
337
|
);
|
|
341
338
|
}
|
package/src/types/index.ts
CHANGED
|
@@ -48,6 +48,12 @@ export {
|
|
|
48
48
|
export type { ToolApprovalRequest } from './tool-approval-request';
|
|
49
49
|
export type { ToolApprovalResponse } from './tool-approval-response';
|
|
50
50
|
export type { ToolCall } from './tool-call';
|
|
51
|
+
export {
|
|
52
|
+
getToolCaller as experimental_getToolCaller,
|
|
53
|
+
toolCaller as experimental_toolCaller,
|
|
54
|
+
type ToolCallerDefinition as Experimental_ToolCallerDefinition,
|
|
55
|
+
type ToolCallerTool as Experimental_ToolCallerTool,
|
|
56
|
+
} from './tool-caller';
|
|
51
57
|
export type {
|
|
52
58
|
ToolExecuteFunction,
|
|
53
59
|
ToolExecutionOptions,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ProviderOptions } from './provider-options';
|
|
2
|
+
import type { Tool } from './tool';
|
|
3
|
+
import type { ToolSet } from './tool-set';
|
|
4
|
+
|
|
5
|
+
const toolCallerSymbol = Symbol.for('vercel.ai.experimental.toolCaller');
|
|
6
|
+
|
|
7
|
+
export type ToolCallerDefinition =
|
|
8
|
+
| {
|
|
9
|
+
type: 'local';
|
|
10
|
+
bind: (tools: ToolSet) => Tool;
|
|
11
|
+
}
|
|
12
|
+
| {
|
|
13
|
+
type: 'provider';
|
|
14
|
+
prepareProviderOptions: (
|
|
15
|
+
providerOptions: ProviderOptions | undefined,
|
|
16
|
+
) => ProviderOptions;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type ToolCallerTool<TOOL extends Tool = Tool> = TOOL & {
|
|
20
|
+
readonly [toolCallerSymbol]: ToolCallerDefinition;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function toolCaller<TOOL extends Tool>(
|
|
24
|
+
tool: TOOL,
|
|
25
|
+
definition: ToolCallerDefinition,
|
|
26
|
+
): ToolCallerTool<TOOL> {
|
|
27
|
+
return Object.defineProperty({ ...tool }, toolCallerSymbol, {
|
|
28
|
+
value: definition,
|
|
29
|
+
}) as ToolCallerTool<TOOL>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getToolCaller(
|
|
33
|
+
tool: Tool | undefined,
|
|
34
|
+
): ToolCallerDefinition | undefined {
|
|
35
|
+
return (tool as ToolCallerTool | undefined)?.[toolCallerSymbol];
|
|
36
|
+
}
|
|
@@ -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.
|
|
8
|
-
*
|
|
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;
|