🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

@ai-sdk/provider-utils

Package Overview
Dependencies
Maintainers
3
Versions
297
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@ai-sdk/provider-utils - npm Package Compare versions

Comparing version
4.0.28
to
4.0.29
+82
src/fetch-with-validated-redirects.ts
import { DownloadError } from './download-error';
import { isBrowserRuntime } from './is-browser-runtime';
import { validateDownloadUrl } from './validate-download-url';
const MAX_DOWNLOAD_REDIRECTS = 10;
/**
* Fetches a URL while enforcing the SSRF download guard on every hop.
*
* Redirects are followed manually (`redirect: 'manual'`) so each hop is
* validated with {@link validateDownloadUrl} *before* it is requested. Relying
* on the default `redirect: 'follow'` would issue the request to a redirect
* target (e.g. an internal address) before we ever see its URL, defeating the
* SSRF guard.
*
* A `redirect: 'manual'` request yields an unreadable opaque response in the
* browser (and in other spec-compliant fetch implementations), so the redirect
* target cannot be validated here. In a real browser this is safe to follow
* natively because SSRF is not reachable (fetch is constrained by CORS and
* cannot reach a server's internal network or cloud-metadata). On any other
* runtime we cannot validate the hop, so we fail closed rather than follow it
* blindly and bypass the SSRF guard.
*
* The returned response is the final (non-redirect) response. The caller is
* responsible for checking `response.ok` and reading the body.
*
* @throws DownloadError if a hop is unsafe, the redirect limit is exceeded, or
* a redirect cannot be validated on a non-browser runtime.
*/
export async function fetchWithValidatedRedirects({
url,
headers,
abortSignal,
maxRedirects = MAX_DOWNLOAD_REDIRECTS,
}: {
url: string;
headers?: HeadersInit;
abortSignal?: AbortSignal;
maxRedirects?: number;
}): Promise<Response> {
// Per-hop request options. Only the `redirect` mode varies between hops, so
// the rest is assembled once. `headers` is omitted entirely when not provided
// so callers that send none issue a bare request.
const baseInit: RequestInit = { signal: abortSignal };
if (headers !== undefined) {
baseInit.headers = headers;
}
let currentUrl = url;
// The bound also acts as a backstop against an unterminated redirect chain.
for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) {
validateDownloadUrl(currentUrl);
const response = await fetch(currentUrl, {
...baseInit,
redirect: 'manual',
});
if (response.type === 'opaqueredirect') {
if (!isBrowserRuntime()) {
throw new DownloadError({
url,
message: `Redirect from ${currentUrl} could not be validated and was blocked`,
});
}
return await fetch(currentUrl, { ...baseInit, redirect: 'follow' });
}
const location = response.headers.get('location');
if (response.status >= 300 && response.status < 400 && location) {
currentUrl = new URL(location, currentUrl).toString();
continue;
}
return response;
}
throw new DownloadError({
url,
message: `Too many redirects (max ${maxRedirects})`,
});
}
/**
* Returns `true` when running in a browser.
*
* Detection keys on the presence of a global `window`, matching the browser
* check used elsewhere in this package (see `getRuntimeEnvironmentUserAgent`)
* so the SDK has a single, consistent definition of "browser". Server runtimes
* (Node.js, Deno, Bun, edge/workers) do not define `window`.
*/
export function isBrowserRuntime(
globalThisAny: any = globalThis as any,
): boolean {
return globalThisAny.window != null;
}
/**
* Returns true when `url` has the same origin (scheme + host + port) as
* `baseUrl`.
*
* Used to decide whether provider credentials may be attached to a request to a
* URL taken from a provider response (e.g. a polling or media-download URL).
* Credentials must only be sent to the provider's own origin; a response that
* names a foreign host (a CDN, or an attacker-controlled host if the response
* is tampered with) must not receive the API key.
*
* Returns false if either value is not a valid absolute URL (fail-closed).
*/
export function isSameOrigin(url: string, baseUrl: string): boolean {
try {
return new URL(url).origin === new URL(baseUrl).origin;
} catch {
return false;
}
}
+21
-0
# @ai-sdk/provider-utils
## 4.0.29
### Patch Changes
- bfa5864: fix: only send provider credentials to same-origin response-supplied URLs
Several provider clients followed a URL taken from the provider's API response (a polling/status URL or a final media URL such as `polling_url`, `urls.get`, `result_url`, `result.sample`, or `video.uri`) and reused the authenticated headers — or appended `?key=<API_KEY>` — on that request. Because the host of the response-supplied URL was never validated, the long-lived API key was sent to whatever host the response named (a CDN in the benign case, or an attacker-chosen host if the provider response was tampered with), allowing credential exfiltration.
A new `isSameOrigin` helper is added to `@ai-sdk/provider-utils`, and the affected fetches in `@ai-sdk/black-forest-labs`, `@ai-sdk/fireworks`, `@ai-sdk/replicate`, `@ai-sdk/gladia`, `@ai-sdk/fal`, and `@ai-sdk/google` now attach credentials only when the followed URL is same-origin with the provider's configured API origin. Requests to a foreign origin are made without the credential.
- f42aa79: fix: harden download URL SSRF guard against hostname and redirect bypasses
`validateDownloadUrl` and the file download helpers (`downloadBlob`, `download`) could be bypassed in several ways when handling untrusted URLs:
- A fully-qualified hostname with a trailing dot (e.g. `localhost.`, `myhost.local.`) skipped the localhost/`.local` blocklist.
- IPv6 addresses that embed an IPv4 address in their last 32 bits — IPv4-compatible (`::127.0.0.1`), IPv4-translated (`::ffff:0:127.0.0.1`), and NAT64 (`64:ff9b::127.0.0.1`, including the `64:ff9b:1::/48` local-use prefix) — were not decoded and checked against the private IPv4 ranges.
- Redirects were validated only _after_ `fetch` had already followed them, so the request to a redirect target (e.g. an internal/metadata address) had already been issued before the check ran.
- Several reserved/internal address ranges were not blocked: CGNAT (`100.64.0.0/10`, used by some cloud providers for internal traffic), benchmarking (`198.18.0.0/15`), IETF protocol assignments (`192.0.0.0/24`), the reserved `240.0.0.0/4` block (including the `255.255.255.255` broadcast address), and IPv6 site-local (`fec0::/10`) and multicast (`ff00::/8`).
The validator now strips trailing dots before the hostname checks and fully expands IPv6 addresses to detect embedded private IPv4 targets. The download helpers now follow redirects manually (`redirect: 'manual'`), re-validating each hop before requesting it, so an unsafe redirect target is never fetched. When a redirect cannot be inspected because the runtime returns an opaque response, the helpers fail closed (reject the redirect) on the server; only in a real browser — where SSRF is not reachable (fetch is constrained by CORS and cannot reach a server's internal network or cloud-metadata endpoints) — is the redirect followed natively so legitimate redirected downloads keep working.
## 4.0.28

@@ -4,0 +25,0 @@

+59
-1

@@ -207,2 +207,32 @@ import { LanguageModelV3FunctionTool, LanguageModelV3ProviderTool, ImageModelV3File, AISDKError, JSONSchema7, JSONParseError, TypeValidationError, JSONValue, APICallError, LanguageModelV3Prompt, SharedV3ProviderOptions, JSONObject, TypeValidationContext } from '@ai-sdk/provider';

/**
* Fetches a URL while enforcing the SSRF download guard on every hop.
*
* Redirects are followed manually (`redirect: 'manual'`) so each hop is
* validated with {@link validateDownloadUrl} *before* it is requested. Relying
* on the default `redirect: 'follow'` would issue the request to a redirect
* target (e.g. an internal address) before we ever see its URL, defeating the
* SSRF guard.
*
* A `redirect: 'manual'` request yields an unreadable opaque response in the
* browser (and in other spec-compliant fetch implementations), so the redirect
* target cannot be validated here. In a real browser this is safe to follow
* natively because SSRF is not reachable (fetch is constrained by CORS and
* cannot reach a server's internal network or cloud-metadata). On any other
* runtime we cannot validate the hop, so we fail closed rather than follow it
* blindly and bypass the SSRF guard.
*
* The returned response is the final (non-redirect) response. The caller is
* responsible for checking `response.ok` and reading the body.
*
* @throws DownloadError if a hop is unsafe, the redirect limit is exceeded, or
* a redirect cannot be validated on a non-browser runtime.
*/
declare function fetchWithValidatedRedirects({ url, headers, abortSignal, maxRedirects, }: {
url: string;
headers?: HeadersInit;
abortSignal?: AbortSignal;
maxRedirects?: number;
}): Promise<Response>;
/**
* Fetch function type (standardizes the version of fetch used).

@@ -403,2 +433,12 @@ */

/**
* Returns `true` when running in a browser.
*
* Detection keys on the presence of a global `window`, matching the browser
* check used elsewhere in this package (see `getRuntimeEnvironmentUserAgent`)
* so the SDK has a single, consistent definition of "browser". Server runtimes
* (Node.js, Deno, Bun, edge/workers) do not define `window`.
*/
declare function isBrowserRuntime(globalThisAny?: any): boolean;
/**
* Type guard that checks whether a value is not `null` or `undefined`.

@@ -413,2 +453,16 @@ *

/**
* Returns true when `url` has the same origin (scheme + host + port) as
* `baseUrl`.
*
* Used to decide whether provider credentials may be attached to a request to a
* URL taken from a provider response (e.g. a polling or media-download URL).
* Credentials must only be sent to the provider's own origin; a response that
* names a foreign host (a CDN, or an attacker-controlled host if the response
* is tampered with) must not receive the API key.
*
* Returns false if either value is not a valid absolute URL (fail-closed).
*/
declare function isSameOrigin(url: string, baseUrl: string): boolean;
/**
* Checks if the given URL is supported natively by the model.

@@ -1316,2 +1370,6 @@ *

*
* Note: this performs string/literal-IP checks only. It does not resolve DNS, so a
* hostname that resolves to a private address is not blocked here (see callers, which
* should additionally constrain egress at the network layer when handling untrusted URLs).
*
* @param url - The URL string to validate.

@@ -1453,2 +1511,2 @@ * @throws DownloadError if the URL is unsafe.

export { type AssistantContent, type AssistantModelMessage, DEFAULT_MAX_DOWNLOAD_SIZE, type DataContent, DelayedPromise, DownloadError, type FetchFunction, type FilePart, type FlexibleSchema, type IdGenerator, type ImagePart, type InferSchema, type InferToolInput, type InferToolOutput, type LazySchema, type MaybePromiseLike, type ModelMessage, type ParseResult, type ProviderOptions, type ProviderToolFactory, type ProviderToolFactoryWithOutputSchema, type ReasoningPart, type Resolvable, type ResponseHandler, type Schema, type SystemModelMessage, type TextPart, type Tool, type ToolApprovalRequest, type ToolApprovalResponse, type ToolCall, type ToolCallOptions, type ToolCallPart, type ToolContent, type ToolExecuteFunction, type ToolExecutionOptions, type ToolModelMessage, type ToolNameMapping, type ToolNeedsApprovalFunction, type ToolResult, type ToolResultOutput, type ToolResultPart, type UserContent, type UserModelMessage, VERSION, type ValidationResult, asSchema, combineHeaders, convertAsyncIteratorToReadableStream, convertBase64ToUint8Array, convertImageModelFileToDataUri, convertToBase64, convertToFormData, convertUint8ArrayToBase64, createBinaryResponseHandler, createEventSourceResponseHandler, createIdGenerator, createJsonErrorResponseHandler, createJsonResponseHandler, createProviderToolFactory, createProviderToolFactoryWithOutputSchema, createStatusCodeErrorResponseHandler, createToolNameMapping, delay, downloadBlob, dynamicTool, executeTool, extractResponseHeaders, generateId, getErrorMessage, getFromApi, getRuntimeEnvironmentUserAgent, injectJsonInstructionIntoMessages, isAbortError, isNonNullable, isParsableJson, isUrlSupported, jsonSchema, lazySchema, loadApiKey, loadOptionalSetting, loadSetting, mediaTypeToExtension, normalizeHeaders, parseJSON, parseJsonEventStream, parseProviderOptions, postFormDataToApi, postJsonToApi, postToApi, readResponseWithSizeLimit, removeUndefinedEntries, resolve, safeParseJSON, safeValidateTypes, stripFileExtension, tool, validateDownloadUrl, validateTypes, withUserAgentSuffix, withoutTrailingSlash, zodSchema };
export { type AssistantContent, type AssistantModelMessage, DEFAULT_MAX_DOWNLOAD_SIZE, type DataContent, DelayedPromise, DownloadError, type FetchFunction, type FilePart, type FlexibleSchema, type IdGenerator, type ImagePart, type InferSchema, type InferToolInput, type InferToolOutput, type LazySchema, type MaybePromiseLike, type ModelMessage, type ParseResult, type ProviderOptions, type ProviderToolFactory, type ProviderToolFactoryWithOutputSchema, type ReasoningPart, type Resolvable, type ResponseHandler, type Schema, type SystemModelMessage, type TextPart, type Tool, type ToolApprovalRequest, type ToolApprovalResponse, type ToolCall, type ToolCallOptions, type ToolCallPart, type ToolContent, type ToolExecuteFunction, type ToolExecutionOptions, type ToolModelMessage, type ToolNameMapping, type ToolNeedsApprovalFunction, type ToolResult, type ToolResultOutput, type ToolResultPart, type UserContent, type UserModelMessage, VERSION, type ValidationResult, asSchema, combineHeaders, convertAsyncIteratorToReadableStream, convertBase64ToUint8Array, convertImageModelFileToDataUri, convertToBase64, convertToFormData, convertUint8ArrayToBase64, createBinaryResponseHandler, createEventSourceResponseHandler, createIdGenerator, createJsonErrorResponseHandler, createJsonResponseHandler, createProviderToolFactory, createProviderToolFactoryWithOutputSchema, createStatusCodeErrorResponseHandler, createToolNameMapping, delay, downloadBlob, dynamicTool, executeTool, extractResponseHeaders, fetchWithValidatedRedirects, generateId, getErrorMessage, getFromApi, getRuntimeEnvironmentUserAgent, injectJsonInstructionIntoMessages, isAbortError, isBrowserRuntime, isNonNullable, isParsableJson, isSameOrigin, isUrlSupported, jsonSchema, lazySchema, loadApiKey, loadOptionalSetting, loadSetting, mediaTypeToExtension, normalizeHeaders, parseJSON, parseJsonEventStream, parseProviderOptions, postFormDataToApi, postJsonToApi, postToApi, readResponseWithSizeLimit, removeUndefinedEntries, resolve, safeParseJSON, safeValidateTypes, stripFileExtension, tool, validateDownloadUrl, validateTypes, withUserAgentSuffix, withoutTrailingSlash, zodSchema };

@@ -207,2 +207,32 @@ import { LanguageModelV3FunctionTool, LanguageModelV3ProviderTool, ImageModelV3File, AISDKError, JSONSchema7, JSONParseError, TypeValidationError, JSONValue, APICallError, LanguageModelV3Prompt, SharedV3ProviderOptions, JSONObject, TypeValidationContext } from '@ai-sdk/provider';

/**
* Fetches a URL while enforcing the SSRF download guard on every hop.
*
* Redirects are followed manually (`redirect: 'manual'`) so each hop is
* validated with {@link validateDownloadUrl} *before* it is requested. Relying
* on the default `redirect: 'follow'` would issue the request to a redirect
* target (e.g. an internal address) before we ever see its URL, defeating the
* SSRF guard.
*
* A `redirect: 'manual'` request yields an unreadable opaque response in the
* browser (and in other spec-compliant fetch implementations), so the redirect
* target cannot be validated here. In a real browser this is safe to follow
* natively because SSRF is not reachable (fetch is constrained by CORS and
* cannot reach a server's internal network or cloud-metadata). On any other
* runtime we cannot validate the hop, so we fail closed rather than follow it
* blindly and bypass the SSRF guard.
*
* The returned response is the final (non-redirect) response. The caller is
* responsible for checking `response.ok` and reading the body.
*
* @throws DownloadError if a hop is unsafe, the redirect limit is exceeded, or
* a redirect cannot be validated on a non-browser runtime.
*/
declare function fetchWithValidatedRedirects({ url, headers, abortSignal, maxRedirects, }: {
url: string;
headers?: HeadersInit;
abortSignal?: AbortSignal;
maxRedirects?: number;
}): Promise<Response>;
/**
* Fetch function type (standardizes the version of fetch used).

@@ -403,2 +433,12 @@ */

/**
* Returns `true` when running in a browser.
*
* Detection keys on the presence of a global `window`, matching the browser
* check used elsewhere in this package (see `getRuntimeEnvironmentUserAgent`)
* so the SDK has a single, consistent definition of "browser". Server runtimes
* (Node.js, Deno, Bun, edge/workers) do not define `window`.
*/
declare function isBrowserRuntime(globalThisAny?: any): boolean;
/**
* Type guard that checks whether a value is not `null` or `undefined`.

@@ -413,2 +453,16 @@ *

/**
* Returns true when `url` has the same origin (scheme + host + port) as
* `baseUrl`.
*
* Used to decide whether provider credentials may be attached to a request to a
* URL taken from a provider response (e.g. a polling or media-download URL).
* Credentials must only be sent to the provider's own origin; a response that
* names a foreign host (a CDN, or an attacker-controlled host if the response
* is tampered with) must not receive the API key.
*
* Returns false if either value is not a valid absolute URL (fail-closed).
*/
declare function isSameOrigin(url: string, baseUrl: string): boolean;
/**
* Checks if the given URL is supported natively by the model.

@@ -1316,2 +1370,6 @@ *

*
* Note: this performs string/literal-IP checks only. It does not resolve DNS, so a
* hostname that resolves to a private address is not blocked here (see callers, which
* should additionally constrain egress at the network layer when handling untrusted URLs).
*
* @param url - The URL string to validate.

@@ -1453,2 +1511,2 @@ * @throws DownloadError if the URL is unsafe.

export { type AssistantContent, type AssistantModelMessage, DEFAULT_MAX_DOWNLOAD_SIZE, type DataContent, DelayedPromise, DownloadError, type FetchFunction, type FilePart, type FlexibleSchema, type IdGenerator, type ImagePart, type InferSchema, type InferToolInput, type InferToolOutput, type LazySchema, type MaybePromiseLike, type ModelMessage, type ParseResult, type ProviderOptions, type ProviderToolFactory, type ProviderToolFactoryWithOutputSchema, type ReasoningPart, type Resolvable, type ResponseHandler, type Schema, type SystemModelMessage, type TextPart, type Tool, type ToolApprovalRequest, type ToolApprovalResponse, type ToolCall, type ToolCallOptions, type ToolCallPart, type ToolContent, type ToolExecuteFunction, type ToolExecutionOptions, type ToolModelMessage, type ToolNameMapping, type ToolNeedsApprovalFunction, type ToolResult, type ToolResultOutput, type ToolResultPart, type UserContent, type UserModelMessage, VERSION, type ValidationResult, asSchema, combineHeaders, convertAsyncIteratorToReadableStream, convertBase64ToUint8Array, convertImageModelFileToDataUri, convertToBase64, convertToFormData, convertUint8ArrayToBase64, createBinaryResponseHandler, createEventSourceResponseHandler, createIdGenerator, createJsonErrorResponseHandler, createJsonResponseHandler, createProviderToolFactory, createProviderToolFactoryWithOutputSchema, createStatusCodeErrorResponseHandler, createToolNameMapping, delay, downloadBlob, dynamicTool, executeTool, extractResponseHeaders, generateId, getErrorMessage, getFromApi, getRuntimeEnvironmentUserAgent, injectJsonInstructionIntoMessages, isAbortError, isNonNullable, isParsableJson, isUrlSupported, jsonSchema, lazySchema, loadApiKey, loadOptionalSetting, loadSetting, mediaTypeToExtension, normalizeHeaders, parseJSON, parseJsonEventStream, parseProviderOptions, postFormDataToApi, postJsonToApi, postToApi, readResponseWithSizeLimit, removeUndefinedEntries, resolve, safeParseJSON, safeValidateTypes, stripFileExtension, tool, validateDownloadUrl, validateTypes, withUserAgentSuffix, withoutTrailingSlash, zodSchema };
export { type AssistantContent, type AssistantModelMessage, DEFAULT_MAX_DOWNLOAD_SIZE, type DataContent, DelayedPromise, DownloadError, type FetchFunction, type FilePart, type FlexibleSchema, type IdGenerator, type ImagePart, type InferSchema, type InferToolInput, type InferToolOutput, type LazySchema, type MaybePromiseLike, type ModelMessage, type ParseResult, type ProviderOptions, type ProviderToolFactory, type ProviderToolFactoryWithOutputSchema, type ReasoningPart, type Resolvable, type ResponseHandler, type Schema, type SystemModelMessage, type TextPart, type Tool, type ToolApprovalRequest, type ToolApprovalResponse, type ToolCall, type ToolCallOptions, type ToolCallPart, type ToolContent, type ToolExecuteFunction, type ToolExecutionOptions, type ToolModelMessage, type ToolNameMapping, type ToolNeedsApprovalFunction, type ToolResult, type ToolResultOutput, type ToolResultPart, type UserContent, type UserModelMessage, VERSION, type ValidationResult, asSchema, combineHeaders, convertAsyncIteratorToReadableStream, convertBase64ToUint8Array, convertImageModelFileToDataUri, convertToBase64, convertToFormData, convertUint8ArrayToBase64, createBinaryResponseHandler, createEventSourceResponseHandler, createIdGenerator, createJsonErrorResponseHandler, createJsonResponseHandler, createProviderToolFactory, createProviderToolFactoryWithOutputSchema, createStatusCodeErrorResponseHandler, createToolNameMapping, delay, downloadBlob, dynamicTool, executeTool, extractResponseHeaders, fetchWithValidatedRedirects, generateId, getErrorMessage, getFromApi, getRuntimeEnvironmentUserAgent, injectJsonInstructionIntoMessages, isAbortError, isBrowserRuntime, isNonNullable, isParsableJson, isSameOrigin, isUrlSupported, jsonSchema, lazySchema, loadApiKey, loadOptionalSetting, loadSetting, mediaTypeToExtension, normalizeHeaders, parseJSON, parseJsonEventStream, parseProviderOptions, postFormDataToApi, postJsonToApi, postToApi, readResponseWithSizeLimit, removeUndefinedEntries, resolve, safeParseJSON, safeValidateTypes, stripFileExtension, tool, validateDownloadUrl, validateTypes, withUserAgentSuffix, withoutTrailingSlash, zodSchema };
+1
-1
{
"name": "@ai-sdk/provider-utils",
"version": "4.0.28",
"version": "4.0.29",
"license": "Apache-2.0",

@@ -5,0 +5,0 @@ "sideEffects": false,

import { DownloadError } from './download-error';
import { fetchWithValidatedRedirects } from './fetch-with-validated-redirects';
import {

@@ -6,3 +7,2 @@ readResponseWithSizeLimit,

} from './read-response-with-size-limit';
import { validateDownloadUrl } from './validate-download-url';

@@ -24,13 +24,8 @@ /**

): Promise<Blob> {
validateDownloadUrl(url);
try {
const response = await fetch(url, {
signal: options?.abortSignal,
const response = await fetchWithValidatedRedirects({
url,
abortSignal: options?.abortSignal,
});
// Validate final URL after redirects to prevent SSRF via open redirect
if (response.redirected) {
validateDownloadUrl(response.url);
}
if (!response.ok) {

@@ -37,0 +32,0 @@ throw new DownloadError({

@@ -18,2 +18,3 @@ export * from './combine-headers';

} from './read-response-with-size-limit';
export { fetchWithValidatedRedirects } from './fetch-with-validated-redirects';
export * from './fetch-function';

@@ -26,3 +27,5 @@ export { createIdGenerator, generateId, type IdGenerator } from './generate-id';

export * from './is-abort-error';
export { isBrowserRuntime } from './is-browser-runtime';
export { isNonNullable } from './is-non-nullable';
export { isSameOrigin } from './is-same-origin';
export { isUrlSupported } from './is-url-supported';

@@ -29,0 +32,0 @@ export * from './load-api-key';

@@ -7,2 +7,6 @@ import { DownloadError } from './download-error';

*
* Note: this performs string/literal-IP checks only. It does not resolve DNS, so a
* hostname that resolves to a private address is not blocked here (see callers, which
* should additionally constrain egress at the network layer when handling untrusted URLs).
*
* @param url - The URL string to validate.

@@ -35,3 +39,5 @@ * @throws DownloadError if the URL is unsafe.

const hostname = parsed.hostname;
// Strip a trailing dot so a fully-qualified name like `localhost.` (which resolves
// identically to `localhost`) cannot bypass the hostname blocklist below.
const hostname = parsed.hostname.toLowerCase().replace(/\.+$/, '');

@@ -95,3 +101,3 @@ // Block empty hostname

const parts = ip.split('.').map(Number);
const [a, b] = parts;
const [a, b, c] = parts;

@@ -102,2 +108,4 @@ // 0.0.0.0/8

if (a === 10) return true;
// 100.64.0.0/10 (CGNAT, used by some cloud providers for internal traffic)
if (a === 100 && b >= 64 && b <= 127) return true;
// 127.0.0.0/8

@@ -109,4 +117,10 @@ if (a === 127) return true;

if (a === 172 && b >= 16 && b <= 31) return true;
// 192.0.0.0/24 (IETF protocol assignments)
if (a === 192 && b === 0 && c === 0) return true;
// 192.168.0.0/16
if (a === 192 && b === 168) return true;
// 198.18.0.0/15 (benchmarking)
if (a === 198 && (b === 18 || b === 19)) return true;
// 240.0.0.0/4 (reserved, includes 255.255.255.255 broadcast)
if (a >= 240) return true;

@@ -116,39 +130,107 @@ return false;

function isPrivateIPv6(ip: string): boolean {
const normalized = ip.toLowerCase();
/**
* Expands an IPv6 address string into its 8 16-bit groups, handling `::`
* compression and an optional dotted-decimal IPv4 tail (e.g. `::ffff:127.0.0.1`).
*
* @returns the 8 groups, or null if the input is not a parseable IPv6 address.
*/
function parseIPv6(ip: string): number[] | null {
// Strip an optional zone id (e.g. `fe80::1%eth0`).
let address = ip.toLowerCase();
const zoneIndex = address.indexOf('%');
if (zoneIndex !== -1) {
address = address.slice(0, zoneIndex);
}
// ::1 (loopback)
if (normalized === '::1') return true;
// :: (unspecified)
if (normalized === '::') return true;
// At most one `::` compression marker is allowed.
const halves = address.split('::');
if (halves.length > 2) return null;
// Check for IPv4-mapped addresses (::ffff:x.x.x.x or ::ffff:HHHH:HHHH)
if (normalized.startsWith('::ffff:')) {
const mappedPart = normalized.slice(7);
// Dotted-decimal form: ::ffff:127.0.0.1
if (isIPv4(mappedPart)) {
return isPrivateIPv4(mappedPart);
}
// Hex form: ::ffff:7f00:1 (URL parser normalizes to this)
const hexParts = mappedPart.split(':');
if (hexParts.length === 2) {
const high = parseInt(hexParts[0], 16);
const low = parseInt(hexParts[1], 16);
if (!isNaN(high) && !isNaN(low)) {
const a = (high >> 8) & 0xff;
const b = high & 0xff;
const c = (low >> 8) & 0xff;
const d = low & 0xff;
return isPrivateIPv4(`${a}.${b}.${c}.${d}`);
const toGroups = (segment: string): number[] | null => {
if (segment === '') return [];
const groups: number[] = [];
const parts = segment.split(':');
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
// A dotted-decimal IPv4 tail is only valid as the final part.
if (part.includes('.')) {
if (i !== parts.length - 1 || !isIPv4(part)) return null;
const [a, b, c, d] = part.split('.').map(Number);
groups.push((a << 8) | b, (c << 8) | d);
continue;
}
if (!/^[0-9a-f]{1,4}$/.test(part)) return null;
groups.push(parseInt(part, 16));
}
return groups;
};
const head = toGroups(halves[0]);
if (head === null) return null;
if (halves.length === 2) {
const tail = toGroups(halves[1]);
if (tail === null) return null;
const fill = 8 - head.length - tail.length;
if (fill < 0) return null;
return [...head, ...new Array<number>(fill).fill(0), ...tail];
}
// fc00::/7 (unique local addresses - fc00:: and fd00::)
if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true;
// No `::` compression: the address must contain exactly 8 groups.
return head.length === 8 ? head : null;
}
function isPrivateIPv6(ip: string): boolean {
const groups = parseIPv6(ip);
// Fail closed: if the address cannot be parsed, treat it as unsafe.
if (groups === null) return true;
const topZero = (count: number) =>
groups.slice(0, count).every(group => group === 0);
// ::1 (loopback) and :: (unspecified)
if (topZero(7) && (groups[7] === 0 || groups[7] === 1)) return true;
// fc00::/7 (unique local addresses)
if ((groups[0] & 0xfe00) === 0xfc00) return true;
// fe80::/10 (link-local)
if (normalized.startsWith('fe80')) return true;
if ((groups[0] & 0xffc0) === 0xfe80) return true;
// fec0::/10 (site-local, deprecated but still routable internally)
if ((groups[0] & 0xffc0) === 0xfec0) return true;
// ff00::/8 (multicast)
if ((groups[0] & 0xff00) === 0xff00) return true;
// Addresses that embed an IPv4 address in their last 32 bits. For these we
// extract the embedded IPv4 and reuse the IPv4 private-range checks, so that
// e.g. ::ffff:127.0.0.1 or 64:ff9b::169.254.169.254 are blocked.
const embedsIPv4 =
// ::/96 — IPv4-compatible (deprecated)
topZero(6) ||
// ::ffff:0:0/96 — IPv4-mapped (ffff in group 5)
(topZero(5) && groups[5] === 0xffff) ||
// ::ffff:0:0/96 — IPv4-translated form (ffff in group 4, group 5 zero)
(topZero(4) && groups[4] === 0xffff && groups[5] === 0) ||
// 64:ff9b::/96 — NAT64 well-known prefix
(groups[0] === 0x0064 &&
groups[1] === 0xff9b &&
groups[2] === 0 &&
groups[3] === 0 &&
groups[4] === 0 &&
groups[5] === 0) ||
// 64:ff9b:1::/48 — NAT64 local-use prefix
(groups[0] === 0x0064 && groups[1] === 0xff9b && groups[2] === 0x0001);
if (embedsIPv4) {
const a = (groups[6] >> 8) & 0xff;
const b = groups[6] & 0xff;
const c = (groups[7] >> 8) & 0xff;
const d = groups[7] & 0xff;
return isPrivateIPv4(`${a}.${b}.${c}.${d}`);
}
return false;
}

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display