🛡️ refactor: Scope allowedAddresses By Port (#13022)

* fix: Scope allowedAddresses by port

* test: Fix SSRF agent spec typing
This commit is contained in:
Danny Avila 2026-05-08 12:28:34 -04:00 committed by GitHub
parent b39bf837a7
commit 9441563b95
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 507 additions and 252 deletions

View file

@ -175,7 +175,7 @@ async function loadActionSets(searchParams) {
* @param {{ oauth_client_id?: string; oauth_client_secret?: string; }} params.encrypted - The encrypted values for the action.
* @param {string | null} [params.streamId] - The stream ID for resumable streams.
* @param {boolean} [params.useSSRFProtection] - When true, uses SSRF-safe HTTP agents that validate resolved IPs at connect time.
* @param {string[] | null} [params.allowedAddresses] - Optional admin exemption list of hostnames/IPs that bypass the SSRF private-IP block.
* @param {string[] | null} [params.allowedAddresses] - Optional admin exemption list of host:port pairs that bypass the SSRF private-IP block.
* @returns { Promise<typeof tool | { _call: (toolInput: Object | string) => unknown}> } An object with `_call` method to execute the tool input.
*/
async function createActionTool({

View file

@ -2,7 +2,7 @@
# https://www.librechat.ai/docs/configuration/librechat_yaml
# Configuration version (required)
version: 1.3.9
version: 1.3.10
# Cache settings: Set to true to enable caching
cache: true
@ -238,38 +238,41 @@ actions:
- 'google.com'
# - 'http://10.225.26.25:7894' # Internal IP with protocol/port (uncomment if needed)
# `allowedAddresses` is an SSRF exemption list, NOT a strict whitelist.
# Hostnames or IPs listed here bypass the default-deny block on private/loopback/
# link-local destinations. Public domains continue to work normally — listing
# private targets here does not restrict access to anything else.
# Hostname/IP + port pairs listed here bypass the default-deny block for that
# one private/loopback/link-local service. Public domains continue to work
# normally — listing private targets here does not restrict access to anything else.
#
# Entries must be bare hostnames or private IP literals only — no URLs, paths,
# CIDR ranges, ports, or public IPs. Public IP literals are rejected at config
# load (the field is scoped to private IP space; public IPs aren't SSRF targets).
# Entries must include a port: `host:port`, `private.ip:port`, or `[ipv6]:port`.
# Do not use URLs, paths, CIDR ranges, bare hosts/IPs, or public IP literals.
# Public IP literals are rejected at config load (the field is scoped to
# private IP space; public IPs aren't SSRF targets).
#
# NOTE on hostnames: a hostname entry trusts whatever IP that name resolves to.
# If DNS for that name is hijacked or rotated to a different private IP, the
# exemption follows. Only list hostnames whose DNS you control. Prefer literal
# IPs when you can.
# NOTE on hostnames: a hostname entry trusts whatever IP that name resolves to
# on the listed port. If DNS for that name is hijacked or rotated to a different
# private IP, the exemption follows. Only list hostnames whose DNS you control.
# Prefer literal IPs when you can.
# allowedAddresses:
# - 'host.docker.internal'
# - '127.0.0.1'
# - '10.0.0.5'
# - 'host.docker.internal:11434'
# - '127.0.0.1:11434'
# - '10.0.0.5:8080'
# Custom endpoint baseURL exemption list
# SECURITY: User-provided baseURLs (`baseURL: 'user_provided'`) are validated against
# the same SSRF block as Actions and MCP. If your users legitimately point at private
# services (self-hosted Ollama, internal LLM gateway, etc.), list those hostnames or
# IPs here so the validator allows them through. Public destinations are unaffected.
# services (self-hosted Ollama, internal LLM gateway, etc.), list those hostname/IP
# + port pairs here so the validator allows them through. Public destinations are
# unaffected.
#
# Entries must be bare hostnames or private IP literals (no URLs, paths, CIDR,
# ports, or public IPs). Hostname entries trust whatever IP they resolve to —
# only list names whose DNS you control.
# Entries must include a port: `host:port`, `private.ip:port`, or `[ipv6]:port`.
# Do not use URLs, paths, CIDR ranges, bare hosts/IPs, or public IP literals.
# Hostname entries trust whatever IP they resolve to on the listed port — only
# list names whose DNS you control.
# endpoints:
# allowedAddresses:
# - 'localhost'
# - '127.0.0.1'
# - 'ollama'
# - '10.0.0.5'
# - 'localhost:11434'
# - '127.0.0.1:11434'
# - 'ollama:11434'
# - '10.0.0.5:8080'
# MCP Server domain restrictions for remote transports (SSE, WebSocket, HTTP)
# SECURITY: If not configured, SSRF targets are blocked (localhost, private IPs, .internal/.local TLDs).
@ -286,14 +289,15 @@ actions:
# - 'https://secure.api.com' # Protocol-restricted
# - 'http://internal:8080' # Protocol and port restricted
# # allowedAddresses is an SSRF exemption list (private-IP-space only).
# # Hostnames/IPs listed here bypass the default-deny block; public destinations
# # remain reachable. Useful when you want default SSRF protection AND specific
# # internal MCP servers. Entries must be bare hostnames or private IP literals
# # (no URLs, paths, CIDR, ports, or public IPs). Hostname entries trust
# # whatever IP they resolve to — only list names whose DNS you control.
# # Hostname/IP + port pairs listed here bypass the default-deny block for that
# # one private service; public destinations remain reachable. Useful when you
# # want default SSRF protection AND specific internal MCP servers. Entries must
# # include a port (`host:port`, `private.ip:port`, or `[ipv6]:port`) and must
# # not be URLs, paths, CIDR ranges, bare hosts/IPs, or public IP literals.
# # Hostname entries trust whatever IP they resolve to on the listed port.
# allowedAddresses:
# - 'host.docker.internal'
# - '127.0.0.1'
# - 'host.docker.internal:8080'
# - '127.0.0.1:8080'
# Example MCP Servers Object Structure
# mcpServers:

View file

@ -7,12 +7,16 @@ jest.mock('node:dns', () => {
});
import dns from 'node:dns';
import http from 'node:http';
import type { LookupFunction } from 'node:net';
import { createSSRFSafeAgents, createSSRFSafeUndiciConnect } from './agent';
type LookupCallback = (err: NodeJS.ErrnoException | null, address: string, family: number) => void;
const mockedDnsLookup = dns.lookup as jest.MockedFunction<typeof dns.lookup>;
const httpAgentPrototype = http.Agent.prototype as unknown as {
createConnection: (options: Record<string, unknown>) => unknown;
};
function mockDnsResult(address: string, family: number): void {
mockedDnsLookup.mockImplementation(((
@ -36,6 +40,7 @@ function mockDnsError(err: NodeJS.ErrnoException): void {
describe('createSSRFSafeAgents', () => {
afterEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
});
@ -52,6 +57,29 @@ describe('createSSRFSafeAgents', () => {
};
expect(internal.createConnection).toBeInstanceOf(Function);
});
it('should scope allowedAddresses by the request port in the HTTP agent lookup', async () => {
mockDnsResult('10.0.0.5', 4);
let lookupError: NodeJS.ErrnoException | null = null;
jest.spyOn(httpAgentPrototype, 'createConnection').mockImplementation(((
options: Record<string, unknown>,
) => {
const lookup = options.lookup as LookupFunction;
lookup('private.example.com', {}, (err) => {
lookupError = err;
});
return {};
}) as never);
const agents = createSSRFSafeAgents(['10.0.0.5:11434']);
const internal = agents.httpAgent as unknown as {
createConnection: (opts: Record<string, unknown>) => unknown;
};
internal.createConnection({ host: 'private.example.com', port: 22 });
expect(lookupError).toBeTruthy();
expect(lookupError!.code).toBe('ESSRF');
});
});
describe('createSSRFSafeUndiciConnect', () => {
@ -132,25 +160,41 @@ describe('SSRF agents — allowedAddresses exemption', () => {
});
}
it('exempts a hostname literal that the admin permitted', async () => {
it('exempts a hostname literal on the admin-permitted port', async () => {
mockDnsResult('10.0.0.5', 4);
const { lookup } = createSSRFSafeUndiciConnect(['ollama.internal']);
const { lookup } = createSSRFSafeUndiciConnect(['ollama.internal:11434'], '11434');
const result = await runLookup(lookup, 'ollama.internal');
expect(result.err).toBeNull();
expect(result.address).toBe('10.0.0.5');
});
it('exempts a private IP that the admin permitted (DNS resolves to it)', async () => {
it('exempts a private IP on the admin-permitted port (DNS resolves to it)', async () => {
mockDnsResult('10.0.0.5', 4);
const { lookup } = createSSRFSafeUndiciConnect(['10.0.0.5']);
const { lookup } = createSSRFSafeUndiciConnect(['10.0.0.5:11434'], '11434');
const result = await runLookup(lookup, 'private.example.com');
expect(result.err).toBeNull();
expect(result.address).toBe('10.0.0.5');
});
it('blocks a private IP on a different port of an allowed address', async () => {
mockDnsResult('10.0.0.5', 4);
const { lookup } = createSSRFSafeUndiciConnect(['10.0.0.5:11434'], '22');
const result = await runLookup(lookup, 'private.example.com');
expect(result.err).toBeTruthy();
expect(result.err!.code).toBe('ESSRF');
});
it('ignores legacy bare allowedAddresses entries', async () => {
mockDnsResult('10.0.0.5', 4);
const { lookup } = createSSRFSafeUndiciConnect(['10.0.0.5'], '11434');
const result = await runLookup(lookup, 'private.example.com');
expect(result.err).toBeTruthy();
expect(result.err!.code).toBe('ESSRF');
});
it('still blocks an unlisted private IP when allowedAddresses is set', async () => {
mockDnsResult('192.168.1.42', 4);
const { lookup } = createSSRFSafeUndiciConnect(['10.0.0.5']);
const { lookup } = createSSRFSafeUndiciConnect(['10.0.0.5:11434'], '11434');
const result = await runLookup(lookup, 'other.private.example.com');
expect(result.err).toBeTruthy();
expect(result.err!.code).toBe('ESSRF');
@ -159,7 +203,7 @@ describe('SSRF agents — allowedAddresses exemption', () => {
it('drops public-IP entries from allowedAddresses (private-IP scope only)', async () => {
// Admin mistakenly listed a public IP. It must NOT grant exemption.
mockDnsResult('10.0.0.5', 4);
const { lookup } = createSSRFSafeUndiciConnect(['8.8.8.8']);
const { lookup } = createSSRFSafeUndiciConnect(['8.8.8.8:53'], '53');
const result = await runLookup(lookup, 'private.example.com');
expect(result.err).toBeTruthy();
expect(result.err!.code).toBe('ESSRF');
@ -167,11 +211,10 @@ describe('SSRF agents — allowedAddresses exemption', () => {
it('drops URL/CIDR/whitespace entries from allowedAddresses', async () => {
mockDnsResult('10.0.0.5', 4);
const { lookup } = createSSRFSafeUndiciConnect([
'http://10.0.0.5',
'10.0.0.0/24',
' 10.0.0.5 ',
]);
const { lookup } = createSSRFSafeUndiciConnect(
['http://10.0.0.5', '10.0.0.0/24', ' 10.0.0.5 '],
'11434',
);
// Even though the value 10.0.0.5 is among the admin entries, none of them
// pass the schema-shape filter (URL, CIDR, embedded whitespace), so no
// exemption is granted.
@ -182,18 +225,17 @@ describe('SSRF agents — allowedAddresses exemption', () => {
it('createSSRFSafeAgents propagates the exemption-aware lookup to both agents', async () => {
// The agent factory wraps `createConnection` to inject a custom lookup.
// We can't realistically exercise the wrapped function from a unit test
// (the underlying socket op fails), so we drive the same lookup factory
// with the same exemption list and verify it allows the exempt address.
// The HTTP wrapper is covered above; this keeps the shared lookup factory
// expectation explicit for the exemption list.
mockDnsResult('10.0.0.5', 4);
const agents = createSSRFSafeAgents(['10.0.0.5']);
const agents = createSSRFSafeAgents(['10.0.0.5:11434']);
expect(agents.httpAgent).toBeDefined();
expect(agents.httpsAgent).toBeDefined();
// The undici-connect path uses the same `buildSSRFSafeLookup` factory, so
// verifying the exemption holds there is sufficient evidence that the
// agent factory built the right lookup.
const { lookup } = createSSRFSafeUndiciConnect(['10.0.0.5']);
const { lookup } = createSSRFSafeUndiciConnect(['10.0.0.5:11434'], '11434');
const result = await runLookup(lookup, 'private.example.com');
expect(result.err).toBeNull();
expect(result.address).toBe('10.0.0.5');

View file

@ -2,25 +2,33 @@ import dns from 'node:dns';
import http from 'node:http';
import https from 'node:https';
import type { LookupFunction } from 'node:net';
import { normalizeAllowedAddressesSet, isAddressInAllowedSet } from './allowedAddresses';
import {
normalizePort,
normalizeAllowedAddressesSet,
isAddressInAllowedSet,
} from './allowedAddresses';
import { isPrivateIP } from './ip';
/**
* Builds a DNS lookup wrapper that blocks resolution to private/reserved IP
* addresses. When `allowedAddresses` is provided, hostnames or resolved IPs
* addresses. When `allowedAddresses` is provided, hostname/IP + port pairs
* matching the list bypass the block admins can permit known-good internal
* services (self-hosted Ollama, Docker host, etc.) without disabling SSRF
* protection for everything else.
* protection for every port on the same host.
*
* The exemption list is pre-normalized once at construction so the per-
* connection lookup runs an O(1) Set membership check. Normalization and
* scoping rules live in `./allowedAddresses`, shared with the preflight
* helper in `./domain` so the two layers cannot diverge.
*/
function buildSSRFSafeLookup(allowedAddresses?: string[] | null): LookupFunction {
function buildSSRFSafeLookup(
allowedAddresses?: string[] | null,
port?: string | number | null,
): LookupFunction {
const exemptSet = normalizeAllowedAddressesSet(allowedAddresses);
const normalizedPort = normalizePort(port);
return (hostname, options, callback) => {
const hostnameAllowed = isAddressInAllowedSet(hostname, exemptSet);
const hostnameAllowed = isAddressInAllowedSet(hostname, exemptSet, normalizedPort);
dns.lookup(hostname, options, (err, address, family) => {
if (err) {
callback(err, '', 0);
@ -30,7 +38,7 @@ function buildSSRFSafeLookup(allowedAddresses?: string[] | null): LookupFunction
!hostnameAllowed &&
typeof address === 'string' &&
isPrivateIP(address) &&
!isAddressInAllowedSet(address, exemptSet)
!isAddressInAllowedSet(address, exemptSet, normalizedPort)
) {
const ssrfError = Object.assign(
new Error(`SSRF protection: ${hostname} resolved to blocked address ${address}`),
@ -52,12 +60,18 @@ type AgentInternal = {
createConnection: (options: Record<string, unknown>, oncreate?: unknown) => unknown;
};
function getConnectionPort(options: Record<string, unknown>): string {
return normalizePort(options.port ?? options.defaultPort);
}
/** Patches an agent instance to inject SSRF-safe DNS lookup at connect time */
function withSSRFProtection<T extends http.Agent>(agent: T, lookup: LookupFunction): T {
function withSSRFProtection<T extends http.Agent>(agent: T, allowedAddresses?: string[] | null): T {
const internal = agent as unknown as AgentInternal;
const origCreate = internal.createConnection.bind(agent);
internal.createConnection = (options: Record<string, unknown>, oncreate?: unknown) => {
options.lookup = lookup;
options.lookup = allowedAddresses?.length
? buildSSRFSafeLookup(allowedAddresses, getConnectionPort(options))
: ssrfSafeLookup;
return origCreate(options, oncreate);
};
return agent;
@ -69,16 +83,15 @@ function withSSRFProtection<T extends http.Agent>(agent: T, lookup: LookupFuncti
* preventing DNS rebinding attacks where a hostname resolves to a public IP during
* pre-validation but to a private IP when the actual connection is made.
*
* @param allowedAddresses - Optional admin exemption list of hostnames/IPs that bypass the block.
* @param allowedAddresses - Optional admin exemption list of host:port pairs that bypass the block.
*/
export function createSSRFSafeAgents(allowedAddresses?: string[] | null): {
httpAgent: http.Agent;
httpsAgent: https.Agent;
} {
const lookup = allowedAddresses?.length ? buildSSRFSafeLookup(allowedAddresses) : ssrfSafeLookup;
return {
httpAgent: withSSRFProtection(new http.Agent(), lookup),
httpsAgent: withSSRFProtection(new https.Agent(), lookup),
httpAgent: withSSRFProtection(new http.Agent(), allowedAddresses),
httpsAgent: withSSRFProtection(new https.Agent(), allowedAddresses),
};
}
@ -86,11 +99,16 @@ export function createSSRFSafeAgents(allowedAddresses?: string[] | null): {
* Returns undici-compatible `connect` options with SSRF-safe DNS lookup.
* Pass the result as the `connect` property when constructing an undici `Agent`.
*
* @param allowedAddresses - Optional admin exemption list of hostnames/IPs that bypass the block.
* @param allowedAddresses - Optional admin exemption list of host:port pairs that bypass the block.
*/
export function createSSRFSafeUndiciConnect(allowedAddresses?: string[] | null): {
export function createSSRFSafeUndiciConnect(
allowedAddresses?: string[] | null,
port?: string | number | null,
): {
lookup: LookupFunction;
} {
const lookup = allowedAddresses?.length ? buildSSRFSafeLookup(allowedAddresses) : ssrfSafeLookup;
const lookup = allowedAddresses?.length
? buildSSRFSafeLookup(allowedAddresses, port)
: ssrfSafeLookup;
return { lookup };
}

View file

@ -7,9 +7,10 @@
*
* SECURITY scoped to private IP space:
* - Reject URLs (`://`), paths/CIDR (`/`), all whitespace (`\s`), and
* `host:port` shapes. These are admin misconfigurations that the schema
* refinement also rejects at config-load time; the runtime guard exists
* so a list assembled programmatically never silently grants exemption.
* bare host/IP shapes. Entries must be scoped as `host:port` or
* `[ipv6]:port` so an exemption cannot silently trust every service on a
* private host. The runtime guard exists so a list assembled
* programmatically never silently grants exemption.
* - Drop public IP literals public IPs are never SSRF targets, so an
* exemption there has no defensive purpose and must not grant "trusted"
* status. Hostnames pass through; their resolved IP is checked
@ -17,6 +18,14 @@
*/
import { isPrivateIP } from './ip';
const ADDRESS_PORT_SEPARATOR = '\0';
const MAX_PORT = 65535;
interface AddressPort {
address: string;
port: string;
}
/** Returns true when the (already-normalized) string looks like an IPv4 or IPv6 literal. */
export function isIPLiteral(normalized: string): boolean {
if (/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.test(normalized)) {
@ -26,9 +35,8 @@ export function isIPLiteral(normalized: string): boolean {
}
/**
* Detects `host:port` and `[ipv6]:port` shapes, which are admin-input
* mistakes. Bare `::1`, `[::1]`, and IPv6 literals with no port are not
* matched.
* Detects `host:port` and `[ipv6]:port` shapes. Bare `::1`, `[::1]`, and
* IPv6 literals with no port are not matched.
*/
export function looksLikeHostPort(entry: string): boolean {
if (/^\[[^\]]+\]:\d+$/.test(entry)) return true;
@ -37,17 +45,21 @@ export function looksLikeHostPort(entry: string): boolean {
return /^[^:]+:\d+$/.test(entry);
}
/**
* Normalizes a single `allowedAddresses` entry. Returns the canonical form
* (lowercased, trimmed, IPv6 brackets stripped) when the entry is acceptable,
* or `''` when it must be ignored (URL, path, whitespace, host:port, public
* IP literal, or empty after trimming).
*/
export function normalizeAddressEntry(entry: unknown): string {
if (typeof entry !== 'string') return '';
if (entry.includes('://') || entry.includes('/') || /\s/.test(entry)) return '';
if (looksLikeHostPort(entry)) return '';
const normalized = entry
export function normalizePort(port: unknown): string {
if (typeof port !== 'string' && typeof port !== 'number') return '';
const portString = String(port).trim();
if (!/^\d+$/.test(portString)) return '';
const parsed = Number(portString);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > MAX_PORT) return '';
return String(parsed);
}
function addressPortKey(address: string, port: string): string {
return `${address}${ADDRESS_PORT_SEPARATOR}${port}`;
}
function normalizeAddressCandidate(candidate: string): string {
const normalized = candidate
.toLowerCase()
.trim()
.replace(/^\[|\]$/g, '');
@ -56,6 +68,32 @@ export function normalizeAddressEntry(entry: unknown): string {
return normalized;
}
function parseAddressPortEntry(entry: string): AddressPort | null {
const trimmed = entry.toLowerCase().trim();
const bracketedIPv6 = trimmed.match(/^\[([^\]]+)\]:(\d+)$/);
const hostPort = bracketedIPv6 ? null : trimmed.match(/^([^:]+):(\d+)$/);
const address = bracketedIPv6?.[1] ?? hostPort?.[1] ?? '';
const port = normalizePort(bracketedIPv6?.[2] ?? hostPort?.[2] ?? '');
if (!address || !port) return null;
const normalizedAddress = normalizeAddressCandidate(address);
if (!normalizedAddress) return null;
return { address: normalizedAddress, port };
}
/**
* Normalizes a single `allowedAddresses` entry. Returns the canonical form
* when the entry is acceptable, or `''` when it must be ignored (URL, path,
* whitespace, bare host/IP, public IP literal, invalid port, or empty after
* trimming).
*/
export function normalizeAddressEntry(entry: unknown): string {
if (typeof entry !== 'string') return '';
if (entry.includes('://') || entry.includes('/') || /\s/.test(entry)) return '';
const parsed = parseAddressPortEntry(entry);
if (!parsed) return '';
return addressPortKey(parsed.address, parsed.port);
}
/**
* Pre-normalizes an admin list into a `Set<string>` for O(1) membership
* checks on the connect-time hot path. Entries that fail validation are
@ -77,16 +115,20 @@ export function normalizeAllowedAddressesSet(
/**
* Checks whether a hostname or IP literal should be exempted from the SSRF
* block. Mirrors the scoping rules of `normalizeAddressEntry`: an IP
* candidate must itself be private to be exemptable.
* block. Mirrors the scoping rules of `normalizeAddressEntry`: the exemption
* must match both address and port, and an IP candidate must itself be private
* to be exemptable.
*/
export function isAddressInAllowedSet(candidate: string, set: Set<string> | null): boolean {
export function isAddressInAllowedSet(
candidate: string,
set: Set<string> | null,
port?: string | number | null,
): boolean {
if (!set) return false;
const normalized = candidate
.toLowerCase()
.trim()
.replace(/^\[|\]$/g, '');
if (!normalized) return false;
if (isIPLiteral(normalized) && !isPrivateIP(normalized)) return false;
return set.has(normalized);
const parsedCandidate = port == null ? parseAddressPortEntry(candidate) : null;
const normalizedPort = parsedCandidate?.port ?? normalizePort(port);
if (!normalizedPort) return false;
const normalizedAddress = parsedCandidate?.address ?? normalizeAddressCandidate(candidate);
if (!normalizedAddress) return false;
return set.has(addressPortKey(normalizedAddress, normalizedPort));
}

View file

@ -1443,30 +1443,42 @@ describe('isAddressAllowed', () => {
});
it('matches literal IPv4 entries', () => {
expect(isAddressAllowed('127.0.0.1', ['127.0.0.1'])).toBe(true);
expect(isAddressAllowed('10.0.0.5', ['127.0.0.1', '10.0.0.5'])).toBe(true);
expect(isAddressAllowed('192.168.1.1', ['10.0.0.5'])).toBe(false);
expect(isAddressAllowed('127.0.0.1', ['127.0.0.1:11434'], '11434')).toBe(true);
expect(isAddressAllowed('10.0.0.5', ['127.0.0.1:11434', '10.0.0.5:8080'], 8080)).toBe(true);
expect(isAddressAllowed('192.168.1.1', ['10.0.0.5:8080'], '8080')).toBe(false);
});
it('matches literal hostnames case-insensitively', () => {
expect(isAddressAllowed('localhost', ['localhost'])).toBe(true);
expect(isAddressAllowed('LOCALHOST', ['localhost'])).toBe(true);
expect(isAddressAllowed('host.docker.internal', ['HOST.DOCKER.INTERNAL'])).toBe(true);
expect(isAddressAllowed('localhost', ['localhost:11434'], '11434')).toBe(true);
expect(isAddressAllowed('LOCALHOST', ['localhost:11434'], '11434')).toBe(true);
expect(isAddressAllowed('host.docker.internal', ['HOST.DOCKER.INTERNAL:8080'], 8080)).toBe(
true,
);
});
it('strips IPv6 brackets when matching', () => {
expect(isAddressAllowed('[::1]', ['::1'])).toBe(true);
expect(isAddressAllowed('::1', ['[::1]'])).toBe(true);
expect(isAddressAllowed('[::1]', ['[::1]:11434'], '11434')).toBe(true);
expect(isAddressAllowed('::1', ['[::1]:11434'], '11434')).toBe(true);
});
it('does not match a different port on the same address', () => {
expect(isAddressAllowed('localhost', ['localhost:11434'], '8080')).toBe(false);
expect(isAddressAllowed('127.0.0.1', ['127.0.0.1:11434'], '8080')).toBe(false);
});
it('does not match bare allowedAddresses entries', () => {
expect(isAddressAllowed('localhost', ['localhost'], '11434')).toBe(false);
expect(isAddressAllowed('127.0.0.1', ['127.0.0.1'], '11434')).toBe(false);
});
it('does not partial-match hostnames', () => {
expect(isAddressAllowed('evil.localhost', ['localhost'])).toBe(false);
expect(isAddressAllowed('host', ['host.docker.internal'])).toBe(false);
expect(isAddressAllowed('evil.localhost', ['localhost:11434'], '11434')).toBe(false);
expect(isAddressAllowed('host', ['host.docker.internal:11434'], '11434')).toBe(false);
});
it('ignores empty entries', () => {
expect(isAddressAllowed('localhost', ['', ' ', 'localhost'])).toBe(true);
expect(isAddressAllowed('localhost', ['', ' '])).toBe(false);
expect(isAddressAllowed('localhost', ['', ' ', 'localhost:11434'], '11434')).toBe(true);
expect(isAddressAllowed('localhost', ['', ' '], '11434')).toBe(false);
});
});
@ -1478,37 +1490,53 @@ describe('SSRF allowedAddresses exemption', () => {
describe('isSSRFTarget', () => {
it('exempts a hostname listed in allowedAddresses', () => {
expect(isSSRFTarget('localhost')).toBe(true);
expect(isSSRFTarget('localhost', ['localhost'])).toBe(false);
expect(isSSRFTarget('localhost', ['localhost:11434'], '11434')).toBe(false);
});
it('exempts a private IP listed in allowedAddresses', () => {
expect(isSSRFTarget('10.0.0.5')).toBe(true);
expect(isSSRFTarget('10.0.0.5', ['10.0.0.5'])).toBe(false);
expect(isSSRFTarget('10.0.0.5', ['10.0.0.5:11434'], '11434')).toBe(false);
});
it('does not exempt a different port on an allowed private target', () => {
expect(isSSRFTarget('localhost', ['localhost:11434'], '22')).toBe(true);
expect(isSSRFTarget('10.0.0.5', ['10.0.0.5:11434'], '22')).toBe(true);
});
it('does not exempt an unlisted private target', () => {
expect(isSSRFTarget('192.168.1.1', ['10.0.0.5'])).toBe(true);
expect(isSSRFTarget('192.168.1.1', ['10.0.0.5:11434'], '11434')).toBe(true);
});
it('leaves public destinations unaffected when allowedAddresses is set', () => {
expect(isSSRFTarget('api.openai.com', ['localhost'])).toBe(false);
expect(isSSRFTarget('api.openai.com', ['localhost:11434'], '11434')).toBe(false);
});
});
describe('resolveHostnameSSRF', () => {
it('exempts when the hostname itself matches allowedAddresses', async () => {
// No lookup mock needed: a hostname-literal match short-circuits before DNS.
expect(await resolveHostnameSSRF('ollama.internal', ['ollama.internal'])).toBe(false);
expect(await resolveHostnameSSRF('ollama.internal', ['ollama.internal:11434'], '11434')).toBe(
false,
);
});
it('exempts when a resolved IP matches allowedAddresses', async () => {
mockedLookup.mockResolvedValueOnce([{ address: '10.0.0.5', family: 4 }] as never);
expect(await resolveHostnameSSRF('private.example.com', ['10.0.0.5'])).toBe(false);
expect(await resolveHostnameSSRF('private.example.com', ['10.0.0.5:11434'], '11434')).toBe(
false,
);
});
it('blocks when the host matches but the port does not', async () => {
mockedLookup.mockResolvedValueOnce([{ address: '10.0.0.5', family: 4 }] as never);
expect(await resolveHostnameSSRF('private.example.com', ['10.0.0.5:11434'], '22')).toBe(true);
});
it('still blocks when no entry matches', async () => {
mockedLookup.mockResolvedValueOnce([{ address: '10.0.0.5', family: 4 }] as never);
expect(await resolveHostnameSSRF('private.example.com', ['127.0.0.1'])).toBe(true);
expect(await resolveHostnameSSRF('private.example.com', ['127.0.0.1:11434'], '11434')).toBe(
true,
);
});
it('blocks when one of multiple resolved IPs is private and unlisted', async () => {
@ -1516,7 +1544,9 @@ describe('SSRF allowedAddresses exemption', () => {
{ address: '8.8.8.8', family: 4 },
{ address: '10.0.0.5', family: 4 },
] as never);
expect(await resolveHostnameSSRF('mixed.example.com', ['127.0.0.1'])).toBe(true);
expect(await resolveHostnameSSRF('mixed.example.com', ['127.0.0.1:11434'], '11434')).toBe(
true,
);
});
it('passes when all resolved private IPs are listed', async () => {
@ -1524,80 +1554,108 @@ describe('SSRF allowedAddresses exemption', () => {
{ address: '10.0.0.5', family: 4 },
{ address: '10.0.0.6', family: 4 },
] as never);
expect(await resolveHostnameSSRF('cluster.example.com', ['10.0.0.5', '10.0.0.6'])).toBe(
false,
);
expect(
await resolveHostnameSSRF(
'cluster.example.com',
['10.0.0.5:11434', '10.0.0.6:11434'],
'11434',
),
).toBe(false);
});
});
describe('validateEndpointURL', () => {
it('passes a private-IP URL when its IP is in allowedAddresses', async () => {
await expect(
validateEndpointURL('http://10.0.0.5/v1', 'ollama', ['10.0.0.5']),
validateEndpointURL('http://10.0.0.5/v1', 'ollama', ['10.0.0.5:80']),
).resolves.toBeUndefined();
});
it('passes a localhost URL when localhost is in allowedAddresses', async () => {
await expect(
validateEndpointURL('http://localhost:11434/v1', 'ollama', ['localhost']),
validateEndpointURL('http://localhost:11434/v1', 'ollama', ['localhost:11434']),
).resolves.toBeUndefined();
});
it('passes a hostname URL when DNS resolves to an exempted IP', async () => {
mockedLookup.mockResolvedValueOnce([{ address: '10.0.0.5', family: 4 }] as never);
await expect(
validateEndpointURL('https://ollama.example.com/v1', 'ollama', ['10.0.0.5']),
validateEndpointURL('https://ollama.example.com/v1', 'ollama', ['10.0.0.5:443']),
).resolves.toBeUndefined();
});
it('rejects a different port on the same allowed private address', async () => {
await expect(
validateEndpointURL('http://localhost:22/v1', 'ollama', ['localhost:11434']),
).rejects.toThrow('targets a restricted address');
});
it('still rejects unlisted private IPs when allowedAddresses is set', async () => {
await expect(
validateEndpointURL('http://192.168.1.1/v1', 'test-ep', ['10.0.0.5']),
validateEndpointURL('http://192.168.1.1/v1', 'test-ep', ['10.0.0.5:80']),
).rejects.toThrow('targets a restricted address');
});
it('still rejects non-http schemes regardless of allowedAddresses', async () => {
await expect(
validateEndpointURL('file:///etc/passwd', 'test-ep', ['localhost']),
validateEndpointURL('file:///etc/passwd', 'test-ep', ['localhost:11434']),
).rejects.toThrow('only HTTP and HTTPS are permitted');
});
});
describe('isActionDomainAllowed', () => {
it('exempts a private IP when listed in allowedAddresses (no allowedDomains)', async () => {
expect(await isActionDomainAllowed('http://10.0.0.5:8080/api', null, ['10.0.0.5'])).toBe(
expect(await isActionDomainAllowed('http://10.0.0.5:8080/api', null, ['10.0.0.5:8080'])).toBe(
true,
);
});
it('does not exempt a different port on an allowed private IP', async () => {
expect(await isActionDomainAllowed('http://10.0.0.5:8081/api', null, ['10.0.0.5:8080'])).toBe(
false,
);
});
it('still blocks unlisted private IPs', async () => {
expect(await isActionDomainAllowed('http://192.168.1.1/api', null, ['10.0.0.5'])).toBe(false);
expect(await isActionDomainAllowed('http://192.168.1.1/api', null, ['10.0.0.5:80'])).toBe(
false,
);
});
});
describe('isMCPDomainAllowed', () => {
it('exempts a private-IP MCP server when its IP is in allowedAddresses', async () => {
expect(
await isMCPDomainAllowed({ url: 'https://10.0.0.5:8443/mcp' }, null, ['10.0.0.5']),
await isMCPDomainAllowed({ url: 'https://10.0.0.5:8443/mcp' }, null, ['10.0.0.5:8443']),
).toBe(true);
});
it('still blocks an unlisted private MCP target', async () => {
expect(await isMCPDomainAllowed({ url: 'https://192.168.1.1/mcp' }, null, ['10.0.0.5'])).toBe(
false,
);
expect(
await isMCPDomainAllowed({ url: 'https://192.168.1.1/mcp' }, null, ['10.0.0.5:443']),
).toBe(false);
});
});
describe('isOAuthUrlAllowed', () => {
it('returns true when the URL hostname is in allowedAddresses (no allowedDomains)', () => {
expect(isOAuthUrlAllowed('https://10.0.0.5/oauth', null, ['10.0.0.5'])).toBe(true);
it('returns true when the URL host:port is in allowedAddresses (no allowedDomains)', () => {
expect(isOAuthUrlAllowed('https://10.0.0.5/oauth', null, ['10.0.0.5:443'])).toBe(true);
});
it('returns false when the OAuth URL uses a different port than allowedAddresses', () => {
expect(isOAuthUrlAllowed('https://10.0.0.5:8443/oauth', null, ['10.0.0.5:443'])).toBe(false);
});
it('still requires allowedDomains match for non-exempted URLs', () => {
expect(isOAuthUrlAllowed('https://other.example.com/oauth', null, ['10.0.0.5'])).toBe(false);
expect(isOAuthUrlAllowed('https://other.example.com/oauth', null, ['10.0.0.5:443'])).toBe(
false,
);
expect(
isOAuthUrlAllowed('https://other.example.com/oauth', ['other.example.com'], ['10.0.0.5']),
isOAuthUrlAllowed(
'https://other.example.com/oauth',
['other.example.com'],
['10.0.0.5:443'],
),
).toBe(true);
});
@ -1606,22 +1664,22 @@ describe('SSRF allowedAddresses exemption', () => {
// hosts. An unrelated `allowedAddresses` entry (e.g. for self-hosted Ollama) must NOT
// broaden that bound — otherwise a malicious MCP server could advertise OAuth at any
// exempted private address and pass validation.
expect(isOAuthUrlAllowed('http://10.0.0.5/oauth', ['oauth.trusted.com'], ['10.0.0.5'])).toBe(
false,
);
expect(
isOAuthUrlAllowed('http://127.0.0.1/oauth', ['oauth.trusted.com'], ['127.0.0.1']),
isOAuthUrlAllowed('http://10.0.0.5/oauth', ['oauth.trusted.com'], ['10.0.0.5:80']),
).toBe(false);
expect(
isOAuthUrlAllowed('http://127.0.0.1/oauth', ['oauth.trusted.com'], ['127.0.0.1:80']),
).toBe(false);
});
it('rejects schemeless inputs even when the bare host is in allowedAddresses', () => {
it('rejects schemeless inputs even when the host:port is in allowedAddresses', () => {
// `parseDomainSpec` quietly prepends `https://` to schemeless inputs.
// Without a strict `new URL(url)` gate, a value like `10.0.0.5/oauth`
// would short-circuit the trust-bypass and skip `validateOAuthUrl`'s
// own parse-or-throw. The check must require an absolute URL.
expect(isOAuthUrlAllowed('10.0.0.5/oauth', null, ['10.0.0.5'])).toBe(false);
expect(isOAuthUrlAllowed('127.0.0.1', null, ['127.0.0.1'])).toBe(false);
expect(isOAuthUrlAllowed('//10.0.0.5/oauth', null, ['10.0.0.5'])).toBe(false);
expect(isOAuthUrlAllowed('10.0.0.5/oauth', null, ['10.0.0.5:443'])).toBe(false);
expect(isOAuthUrlAllowed('127.0.0.1', null, ['127.0.0.1:443'])).toBe(false);
expect(isOAuthUrlAllowed('//10.0.0.5/oauth', null, ['10.0.0.5:443'])).toBe(false);
});
});
});

View file

@ -1,5 +1,9 @@
import { lookup } from 'node:dns/promises';
import { normalizeAllowedAddressesSet, isAddressInAllowedSet } from './allowedAddresses';
import {
normalizePort,
isAddressInAllowedSet,
normalizeAllowedAddressesSet,
} from './allowedAddresses';
import { isPrivateIP } from './ip';
/** Re-exported here for backward compatibility; canonical location is `./ip`. */
@ -30,9 +34,10 @@ export function isEmailDomainAllowed(email: string, allowedDomains?: string[] |
}
/**
* Checks whether a hostname or IP literal appears in an admin-supplied
* exemption list. Match is case-insensitive and bracket-stripped, so
* `[::1]` matches `::1` and `LOCALHOST` matches `localhost`.
* Checks whether a hostname/IP literal and port appear in an admin-supplied
* exemption list. Address match is case-insensitive and bracket-stripped, so
* `[::1]` matches `::1` and `LOCALHOST` matches `localhost` when the port
* also matches.
*
* The normalization and scoping rules live in `./allowedAddresses` so the
* connect-time DNS lookup in `agent.ts` and this preflight helper share a
@ -41,9 +46,10 @@ export function isEmailDomainAllowed(email: string, allowedDomains?: string[] |
export function isAddressAllowed(
hostnameOrIP: string,
allowedAddresses?: string[] | null,
port?: string | number | null,
): boolean {
const set = normalizeAllowedAddressesSet(allowedAddresses);
return isAddressInAllowedSet(hostnameOrIP, set);
return isAddressInAllowedSet(hostnameOrIP, set, port);
}
/**
@ -52,17 +58,18 @@ export function isAddressAllowed(
* For hostnames, resolves via DNS and checks all returned addresses.
* Fails open on DNS errors (returns false), since the HTTP request would also fail.
*
* When `allowedAddresses` is provided, the hostname and any resolved IP are
* matched against the list a match short-circuits to `false` so admin-
* exempted private targets bypass the SSRF block.
* When `allowedAddresses` is provided, the hostname/port and any resolved
* IP/port are matched against the list a match short-circuits to `false`
* so admin-exempted private services bypass the SSRF block.
*/
export async function resolveHostnameSSRF(
hostname: string,
allowedAddresses?: string[] | null,
port?: string | number | null,
): Promise<boolean> {
const normalizedHost = hostname.toLowerCase().trim();
if (isAddressAllowed(normalizedHost, allowedAddresses)) {
if (isAddressAllowed(normalizedHost, allowedAddresses, port)) {
return false;
}
@ -78,7 +85,8 @@ export async function resolveHostnameSSRF(
try {
const addresses = await lookup(hostname, { all: true });
return addresses.some(
(entry) => isPrivateIP(entry.address) && !isAddressAllowed(entry.address, allowedAddresses),
(entry) =>
isPrivateIP(entry.address) && !isAddressAllowed(entry.address, allowedAddresses, port),
);
} catch {
return false;
@ -89,18 +97,23 @@ export async function resolveHostnameSSRF(
* SSRF Protection: Checks if a hostname/IP is a potentially dangerous internal target.
* Blocks private IPs, localhost, cloud metadata IPs, and common internal hostnames.
*
* When `allowedAddresses` is provided, a literal match against the input hostname
* exempts the target used by admins to permit known-good internal services
* (self-hosted Ollama, Docker host, etc.) without disabling SSRF protection.
* When `allowedAddresses` is provided, a literal host:port match exempts the
* target used by admins to permit known-good internal services (self-hosted
* Ollama, Docker host, etc.) without disabling SSRF protection for every port
* on the same host.
*
* @param hostname - The hostname or IP to check
* @param allowedAddresses - Optional admin exemption list
* @param allowedAddresses - Optional admin exemption list of host:port pairs
* @returns true if the target is blocked (SSRF risk), false if safe
*/
export function isSSRFTarget(hostname: string, allowedAddresses?: string[] | null): boolean {
export function isSSRFTarget(
hostname: string,
allowedAddresses?: string[] | null,
port?: string | number | null,
): boolean {
const normalizedHost = hostname.toLowerCase().trim();
if (isAddressAllowed(normalizedHost, allowedAddresses)) {
if (isAddressAllowed(normalizedHost, allowedAddresses, port)) {
return false;
}
@ -257,14 +270,27 @@ function hostnameMatches(inputHostname: string, allowedSpec: ParsedDomainSpec):
const HTTP_PROTOCOLS: SupportedProtocol[] = ['http:', 'https:'];
const MCP_PROTOCOLS: SupportedProtocol[] = ['http:', 'https:', 'ws:', 'wss:'];
function defaultPortForProtocol(protocol: SupportedProtocol | string | null): string {
if (protocol === 'http:' || protocol === 'ws:') return '80';
if (protocol === 'https:' || protocol === 'wss:') return '443';
return '';
}
function getEffectivePort(
protocol: SupportedProtocol | string | null,
port?: string | null,
): string {
return normalizePort(port ? port : defaultPortForProtocol(protocol));
}
/**
* Core domain validation logic with configurable protocol support.
* SECURITY: When no allowedDomains is configured, blocks SSRF-prone targets.
* @param domain - The domain to check (can include protocol/port)
* @param allowedDomains - List of allowed domain patterns
* @param supportedProtocols - Protocols to accept (others are rejected)
* @param allowedAddresses - Optional admin exemption list of hostnames/IPs that
* bypass the private-IP block when no allowedDomains whitelist is active
* @param allowedAddresses - Optional admin exemption list of host:port pairs
* that bypass the private-IP block when no allowedDomains whitelist is active
*/
async function isDomainAllowedCore(
domain: string,
@ -284,12 +310,13 @@ async function isDomainAllowedCore(
/** If no domain restrictions configured, block SSRF targets but allow all else */
if (!Array.isArray(allowedDomains) || !allowedDomains.length) {
const effectivePort = getEffectivePort(inputSpec.protocol, inputSpec.port);
/** SECURITY: Block SSRF-prone targets when no allowlist is configured */
if (isSSRFTarget(inputSpec.hostname, allowedAddresses)) {
if (isSSRFTarget(inputSpec.hostname, allowedAddresses, effectivePort)) {
return false;
}
/** SECURITY: Resolve hostname and block if it points to a private/reserved IP */
if (await resolveHostnameSSRF(inputSpec.hostname, allowedAddresses)) {
if (await resolveHostnameSSRF(inputSpec.hostname, allowedAddresses, effectivePort)) {
return false;
}
return true;
@ -340,7 +367,7 @@ async function isDomainAllowedCore(
* SECURITY: WebSocket protocols are NOT allowed per OpenAPI specification.
* @param domain - The domain to check (can include protocol/port)
* @param allowedDomains - List of allowed domain patterns
* @param allowedAddresses - Optional admin exemption list of hostnames/IPs
* @param allowedAddresses - Optional admin exemption list of host:port pairs
*/
export async function isActionDomainAllowed(
domain?: string | null,
@ -487,10 +514,14 @@ export function isOAuthUrlAllowed(
/**
* No `allowedDomains` configured: the address exemption may permit specific
* private hosts (matches the semantics of `validateOAuthUrl`'s downstream
* SSRF checks, which also consult `allowedAddresses`).
* private host:port pairs (matches the semantics of `validateOAuthUrl`'s
* downstream SSRF checks, which also consult `allowedAddresses`).
*/
return isAddressAllowed(inputSpec.hostname, allowedAddresses);
return isAddressAllowed(
inputSpec.hostname,
allowedAddresses,
getEffectivePort(inputSpec.protocol, inputSpec.port),
);
}
/** Matches ErrorTypes.INVALID_BASE_URL — string literal avoids build-time dependency on data-provider */
@ -505,10 +536,10 @@ function throwInvalidBaseURL(message: string): never {
* Throws if the URL is unparseable, uses a non-HTTP(S) scheme, targets a known SSRF hostname,
* or DNS-resolves to a private IP.
*
* When `allowedAddresses` is provided, the hostname and resolved IPs are matched against
* the list admin-exempted private targets bypass the SSRF block. This lets operators
* permit known-good private services (self-hosted Ollama, Docker host, etc.) without
* disabling protection for everything else.
* When `allowedAddresses` is provided, hostname/IP + port pairs are matched
* against the list admin-exempted private services bypass the SSRF block.
* This lets operators permit known-good private services (self-hosted Ollama,
* Docker host, etc.) without disabling protection for every port on the same host.
*
* @note DNS rebinding: validation performs a single DNS lookup. An adversary controlling
* DNS with TTL=0 could respond with a public IP at validation time and a private IP
@ -523,10 +554,12 @@ export async function validateEndpointURL(
): Promise<void> {
let hostname: string;
let protocol: string;
let port: string;
try {
const parsed = new URL(url);
hostname = parsed.hostname;
protocol = parsed.protocol;
port = getEffectivePort(protocol, parsed.port);
} catch {
throwInvalidBaseURL(`Invalid base URL for ${endpoint}: unable to parse URL.`);
}
@ -535,11 +568,11 @@ export async function validateEndpointURL(
throwInvalidBaseURL(`Invalid base URL for ${endpoint}: only HTTP and HTTPS are permitted.`);
}
if (isSSRFTarget(hostname, allowedAddresses)) {
if (isSSRFTarget(hostname, allowedAddresses, port)) {
throwInvalidBaseURL(`Base URL for ${endpoint} targets a restricted address.`);
}
if (await resolveHostnameSSRF(hostname, allowedAddresses)) {
if (await resolveHostnameSSRF(hostname, allowedAddresses, port)) {
throwInvalidBaseURL(`Base URL for ${endpoint} resolves to a restricted address.`);
}
}

View file

@ -687,7 +687,7 @@ describe('MCP SSRF protection 307/308 redirect to private IP', () => {
}
});
it('should keep using the original SSRF-safe dispatcher when protection is already on', async () => {
it('should switch to a no-exemption SSRF-safe dispatcher when protection is already on', async () => {
const capture = await createHeaderCaptureServer();
try {
const rebindUrl = new URL(capture.url);
@ -696,9 +696,13 @@ describe('MCP SSRF protection 307/308 redirect to private IP', () => {
mockedResolveHostnameSSRF.mockResolvedValueOnce(false);
mockedCreateSSRFSafeUndiciConnect.mockClear();
const lookup = createRebindBlockingLookup();
mockedCreateSSRFSafeUndiciConnect.mockReturnValueOnce({
lookup,
} as ReturnType<typeof createSSRFSafeUndiciConnect>);
mockedCreateSSRFSafeUndiciConnect
.mockReturnValueOnce({
lookup,
} as ReturnType<typeof createSSRFSafeUndiciConnect>)
.mockReturnValueOnce({
lookup,
} as ReturnType<typeof createSSRFSafeUndiciConnect>);
conn = new MCPConnection({
serverName: 'redirect-rebinding-block-protection-on',
@ -708,7 +712,8 @@ describe('MCP SSRF protection 307/308 redirect to private IP', () => {
await expectRebindSSRFRejection(conn.connect());
expect(server.redirectHit).toBe(true);
expect(mockedCreateSSRFSafeUndiciConnect).toHaveBeenCalledTimes(1);
expect(mockedCreateSSRFSafeUndiciConnect).toHaveBeenCalledTimes(2);
expect(mockedCreateSSRFSafeUndiciConnect.mock.calls[1]).toEqual([]);
expect(lookup).toHaveBeenCalled();
expect(lookup.mock.calls.some(([hostname]) => hostname === 'rebind.test')).toBe(true);
expect(capture.receivedHeaders).toHaveLength(0);
@ -1034,6 +1039,7 @@ describe('MCP SSRF protection WebSocket DNS resolution', () => {
expect(mockedResolveHostnameSSRF).toHaveBeenCalledWith(
expect.stringContaining('evil.example.com'),
null,
'8080',
);
});
@ -1050,6 +1056,7 @@ describe('MCP SSRF protection WebSocket DNS resolution', () => {
expect(mockedResolveHostnameSSRF).toHaveBeenCalledWith(
expect.stringContaining('allowlisted.example.com'),
null,
'8080',
);
});

View file

@ -188,6 +188,14 @@ function buildFetchInit(
};
}
function getUrlPort(url: URL | string): string {
const parsed = typeof url === 'string' ? new URL(url) : url;
if (parsed.port) return parsed.port;
if (parsed.protocol === 'http:' || parsed.protocol === 'ws:') return '80';
if (parsed.protocol === 'https:' || parsed.protocol === 'wss:') return '443';
return '';
}
/**
* Drops credential-bearing headers when a 307/308 redirect crosses an origin
* boundary. Removes the always-forbidden set plus any caller-supplied secret
@ -570,13 +578,14 @@ export class MCPConnection extends EventEmitter {
timeout?: number,
sseBodyTimeout?: number,
configuredSecretHeaderKeys?: ReadonlySet<string>,
baseUrl?: string,
): (input: UndiciRequestInfo, init?: UndiciRequestInit) => Promise<UndiciResponse> {
const basePort = baseUrl ? getUrlPort(baseUrl) : '';
const ssrfConnect = this.useSSRFProtection
? createSSRFSafeUndiciConnect(this.allowedAddresses)
? createSSRFSafeUndiciConnect(this.allowedAddresses, basePort)
: undefined;
const connectOpts = ssrfConnect != null ? { connect: ssrfConnect } : {};
/** Capture only the fields needed by the fetch closure; see factory note above. */
const useSSRFProtection = this.useSSRFProtection;
const agents = this.agents;
const effectiveTimeout = timeout || DEFAULT_TIMEOUT;
const postAgent = new Agent({
@ -705,12 +714,14 @@ export class MCPConnection extends EventEmitter {
};
}
if (!useSSRFProtection && isCrossOriginRedirect) {
if (isCrossOriginRedirect) {
/**
* Once a server-controlled cross-origin hop is seen, keep the safe
* dispatcher for the rest of this redirect chain. Restoring the
* original dispatcher on a later hop back to the original origin
* would re-open the allowlist-mode rebinding gap.
* would re-open the allowlist-mode rebinding gap. When the original
* dispatcher carries `allowedAddresses`, this also prevents a
* redirect from inheriting that port-scoped exemption.
*/
currentInit = {
...currentInit,
@ -773,8 +784,13 @@ export class MCPConnection extends EventEmitter {
* small TOCTOU window. This is an SDK limitation the transport accepts
* only a URL with no custom DNS lookup hook.
*/
const wsHostname = new URL(options.url).hostname;
const isSSRF = await resolveHostnameSSRF(wsHostname, this.allowedAddresses);
const wsUrl = new URL(options.url);
const wsHostname = wsUrl.hostname;
const isSSRF = await resolveHostnameSSRF(
wsHostname,
this.allowedAddresses,
getUrlPort(wsUrl),
);
if (isSSRF) {
throw new Error(
`SSRF protection: WebSocket host "${wsHostname}" resolved to a private/reserved IP address`,
@ -806,7 +822,7 @@ export class MCPConnection extends EventEmitter {
*/
const sseTimeout = this.timeout || SSE_CONNECT_TIMEOUT;
const ssrfConnect = this.useSSRFProtection
? createSSRFSafeUndiciConnect(this.allowedAddresses)
? createSSRFSafeUndiciConnect(this.allowedAddresses, getUrlPort(url))
: undefined;
const sseAgent = new Agent({
bodyTimeout: sseTimeout,
@ -844,6 +860,7 @@ export class MCPConnection extends EventEmitter {
sseTimeout,
undefined,
sseConfiguredSecretHeaderKeys,
options.url,
) as unknown as FetchLike,
});
@ -886,6 +903,7 @@ export class MCPConnection extends EventEmitter {
this.timeout,
this.sseReadTimeout || DEFAULT_SSE_READ_TIMEOUT,
httpConfiguredSecretHeaderKeys,
options.url,
) as unknown as FetchLike,
});

View file

@ -8,7 +8,7 @@
* `isOAuthUrlAllowed` returns false but `isSSRFTarget` / `resolveHostnameSSRF`
* are still called. Those calls used to forward `allowedAddresses`
* unconditionally, which let an unrelated `allowedAddresses` entry (e.g.
* `127.0.0.1` configured for a self-hosted LLM) silently broaden a strict
* `127.0.0.1:11434` configured for a self-hosted LLM) silently broaden a strict
* `allowedDomains` whitelist for OAuth.
*/
jest.mock('node:dns/promises', () => ({
@ -43,10 +43,10 @@ describe('MCPOAuthHandler.validateOAuthUrl — allowedAddresses scoping', () =>
});
describe('without allowedDomains configured', () => {
it('permits a private OAuth URL when its hostname is in allowedAddresses', async () => {
it('permits a private OAuth URL when its host:port is in allowedAddresses', async () => {
// No DNS lookup needed: the hostname-literal exemption short-circuits.
await expect(
validateOAuthUrl('http://127.0.0.1/oauth', 'token_endpoint', null, ['127.0.0.1']),
validateOAuthUrl('http://127.0.0.1/oauth', 'token_endpoint', null, ['127.0.0.1:80']),
).resolves.toBeUndefined();
});
@ -57,13 +57,21 @@ describe('MCPOAuthHandler.validateOAuthUrl — allowedAddresses scoping', () =>
// before DNS resolution happens. The exemption path is supposed to
// take effect after DNS resolves the host to a permitted private IP.
await expect(
validateOAuthUrl('https://ollama.example.com/oauth', 'token_endpoint', null, ['10.0.0.5']),
validateOAuthUrl('https://ollama.example.com/oauth', 'token_endpoint', null, [
'10.0.0.5:443',
]),
).resolves.toBeUndefined();
});
it('rejects a private OAuth URL on a different port of an allowed address', async () => {
await expect(
validateOAuthUrl('http://127.0.0.1:8080/oauth', 'token_endpoint', null, ['127.0.0.1:80']),
).rejects.toThrow('targets a blocked address');
});
it('rejects a private URL not present in allowedAddresses', async () => {
await expect(
validateOAuthUrl('http://192.168.1.1/oauth', 'token_endpoint', null, ['10.0.0.5']),
validateOAuthUrl('http://192.168.1.1/oauth', 'token_endpoint', null, ['10.0.0.5:80']),
).rejects.toThrow('targets a blocked address');
});
});
@ -83,7 +91,7 @@ describe('MCPOAuthHandler.validateOAuthUrl — allowedAddresses scoping', () =>
it('rejects a private URL even when allowedAddresses lists it (regression for bypass)', async () => {
// Admin set `allowedDomains: ['oauth.trusted.com']` to constrain OAuth
// endpoints. Independently, they also set `allowedAddresses: ['127.0.0.1']`
// endpoints. Independently, they also set `allowedAddresses: ['127.0.0.1:80']`
// to permit a self-hosted LLM. A malicious MCP server now advertises an
// OAuth token endpoint at `http://127.0.0.1/oauth`. The address
// exemption MUST NOT grant the URL trust beyond the strict OAuth
@ -93,7 +101,7 @@ describe('MCPOAuthHandler.validateOAuthUrl — allowedAddresses scoping', () =>
'http://127.0.0.1/oauth',
'token_endpoint',
['oauth.trusted.com'],
['127.0.0.1'],
['127.0.0.1:80'],
),
).rejects.toThrow();
});
@ -105,7 +113,7 @@ describe('MCPOAuthHandler.validateOAuthUrl — allowedAddresses scoping', () =>
'https://attacker.example.com/oauth',
'token_endpoint',
['oauth.trusted.com'],
['10.0.0.5'],
['10.0.0.5:443'],
),
).rejects.toThrow('resolves to a private IP address');
});
@ -129,13 +137,13 @@ describe('MCPOAuthHandler.validateOAuthUrl — allowedAddresses scoping', () =>
describe('schema-level rejections (defense in depth)', () => {
it('ignores a public IP listed in allowedAddresses', async () => {
// Even though `8.8.8.8` is in `allowedAddresses`, the runtime helper
// Even though `8.8.8.8:53` is in `allowedAddresses`, the runtime helper
// drops public-IP entries (mirrors the schema refinement). Public IPs
// are never SSRF targets, so this scenario is benign — but the test
// documents the scoping invariant.
mockedLookup.mockResolvedValueOnce([{ address: '93.184.216.34', family: 4 }] as never);
await expect(
validateOAuthUrl('https://other.public.com/oauth', 'token_endpoint', null, ['8.8.8.8']),
validateOAuthUrl('https://other.public.com/oauth', 'token_endpoint', null, ['8.8.8.8:53']),
).resolves.toBeUndefined();
});
@ -146,7 +154,7 @@ describe('MCPOAuthHandler.validateOAuthUrl — allowedAddresses scoping', () =>
// strict `new URL(url)` parse. The strict gate in `isOAuthUrlAllowed`
// ensures schemeless inputs fall through to the explicit error here.
await expect(
validateOAuthUrl('10.0.0.5/oauth', 'token_endpoint', null, ['10.0.0.5']),
validateOAuthUrl('10.0.0.5/oauth', 'token_endpoint', null, ['10.0.0.5:443']),
).rejects.toThrow(/Invalid OAuth/);
});
});

View file

@ -37,6 +37,13 @@ import { sanitizeUrlForLogging } from '~/mcp/utils';
/** Type for the OAuth metadata from the SDK */
type SDKOAuthMetadata = Parameters<typeof registerClient>[1]['metadata'];
function getOAuthUrlPort(url: URL): string {
if (url.port) return url.port;
if (url.protocol === 'http:') return '80';
if (url.protocol === 'https:') return '443';
return '';
}
export class MCPOAuthHandler {
private static readonly FLOW_TYPE = 'mcp_oauth';
private static readonly FLOW_TTL = 10 * 60 * 1000; // 10 minutes
@ -907,8 +914,11 @@ export class MCPOAuthHandler {
}
let hostname: string;
let port: string;
try {
hostname = new URL(url).hostname;
const parsedUrl = new URL(url);
hostname = parsedUrl.hostname;
port = getOAuthUrlPort(parsedUrl);
} catch {
throw new Error(`Invalid OAuth ${fieldName}: ${sanitizeUrlForLogging(url)}`);
}
@ -916,11 +926,11 @@ export class MCPOAuthHandler {
const allowedDomainsActive = Array.isArray(allowedDomains) && allowedDomains.length > 0;
const effectiveAddresses = allowedDomainsActive ? null : allowedAddresses;
if (isSSRFTarget(hostname, effectiveAddresses)) {
if (isSSRFTarget(hostname, effectiveAddresses, port)) {
throw new Error(`OAuth ${fieldName} targets a blocked address`);
}
if (await resolveHostnameSSRF(hostname, effectiveAddresses)) {
if (await resolveHostnameSSRF(hostname, effectiveAddresses, port)) {
throw new Error(`OAuth ${fieldName} resolves to a private IP address`);
}
}

View file

@ -180,7 +180,7 @@ export interface BasicConnectionOptions {
serverConfig: MCPOptions;
useSSRFProtection?: boolean;
allowedDomains?: string[] | null;
/** Admin exemption list of hostnames/IPs that bypass the SSRF private-IP block */
/** Admin exemption list of host:port pairs that bypass the SSRF private-IP block */
allowedAddresses?: string[] | null;
/** When true, only resolve customUserVars in processMCPEnv (for DB-stored servers) */
dbSourced?: boolean;

View file

@ -333,22 +333,21 @@ describe('any custom endpoint is document-supported regardless of name', () => {
describe('allowedAddressesSchema', () => {
describe('accepts valid entries', () => {
it.each([
['localhost', 'lowercase hostname'],
['LOCALHOST', 'uppercase hostname (preserved as-is by Zod)'],
['ollama.internal', 'private-tld hostname'],
['host.docker.internal', 'multi-segment hostname'],
['10.0.0.5', 'RFC 1918 10.x'],
['192.168.1.1', 'RFC 1918 192.168.x'],
['172.16.0.1', 'RFC 1918 172.16.x'],
['127.0.0.1', 'loopback IPv4'],
['169.254.169.254', 'link-local / cloud metadata'],
['192.0.0.1', 'RFC 5736 IETF protocol assignments'],
['100.64.0.1', 'CGNAT'],
['::1', 'IPv6 loopback'],
['[::1]', 'bracketed IPv6 loopback'],
['fc00::1', 'IPv6 unique-local'],
['fd00::1', 'IPv6 unique-local'],
['fe80::1', 'IPv6 link-local'],
['localhost:11434', 'lowercase hostname with port'],
['LOCALHOST:11434', 'uppercase hostname with port (preserved as-is by Zod)'],
['ollama.internal:11434', 'private-tld hostname with port'],
['host.docker.internal:11434', 'multi-segment hostname with port'],
['10.0.0.5:11434', 'RFC 1918 10.x with port'],
['192.168.1.1:8080', 'RFC 1918 192.168.x with port'],
['172.16.0.1:443', 'RFC 1918 172.16.x with port'],
['127.0.0.1:11434', 'loopback IPv4 with port'],
['169.254.169.254:80', 'link-local / cloud metadata with port'],
['192.0.0.1:80', 'RFC 5736 IETF protocol assignments with port'],
['100.64.0.1:8080', 'CGNAT with port'],
['[::1]:11434', 'bracketed IPv6 loopback with port'],
['[fc00::1]:8080', 'IPv6 unique-local with port'],
['[fd00::1]:8080', 'IPv6 unique-local with port'],
['[fe80::1]:8080', 'IPv6 link-local with port'],
])('accepts "%s" (%s)', (entry) => {
expect(allowedAddressesSchema.parse([entry])).toEqual([entry]);
});
@ -372,35 +371,39 @@ describe('allowedAddressesSchema', () => {
['10.0.0.0/24', 'CIDR range'],
['/path', 'leading slash / path'],
['10.0.0.5/api', 'embedded path'],
['localhost', 'bare hostname'],
['10.0.0.5', 'bare IPv4'],
['::1', 'bare IPv6'],
['[::1]', 'bracketed IPv6 without port'],
['localhost:0', 'port 0'],
['localhost:65536', 'port above range'],
['localhost:http', 'non-numeric port'],
[':11434', 'missing host'],
])('rejects "%s" (%s)', (entry) => {
expect(() => allowedAddressesSchema.parse([entry])).toThrow();
});
it.each([['localhost:8080'], ['10.0.0.5:11434'], ['ollama.internal:443'], ['[::1]:8080']])(
'rejects host:port shape "%s"',
'accepts host:port shape "%s"',
(entry) => {
const result = allowedAddressesSchema.safeParse([entry]);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toMatch(/must not include a port/);
}
expect(allowedAddressesSchema.parse([entry])).toEqual([entry]);
},
);
});
describe('private-IP scoping', () => {
it.each([
['8.8.8.8', 'public DNS'],
['1.1.1.1', 'public DNS'],
['93.184.216.34', 'public web (example.com)'],
['172.32.0.1', 'just outside RFC 1918'],
['172.15.255.255', 'just outside RFC 1918 lower'],
['169.253.255.255', 'just outside link-local'],
['100.63.255.255', 'just outside CGNAT'],
['100.128.0.1', 'just outside CGNAT upper'],
['198.20.0.1', 'just outside benchmarking range'],
['2001:4860:4860::8888', 'public IPv6 (Google DNS)'],
['2606:4700:4700::1111', 'public IPv6 (Cloudflare DNS)'],
['8.8.8.8:53', 'public DNS'],
['1.1.1.1:53', 'public DNS'],
['93.184.216.34:443', 'public web (example.com)'],
['172.32.0.1:8080', 'just outside RFC 1918'],
['172.15.255.255:8080', 'just outside RFC 1918 lower'],
['169.253.255.255:8080', 'just outside link-local'],
['100.63.255.255:8080', 'just outside CGNAT'],
['100.128.0.1:8080', 'just outside CGNAT upper'],
['198.20.0.1:8080', 'just outside benchmarking range'],
['[2001:4860:4860::8888]:443', 'public IPv6 (Google DNS)'],
['[2606:4700:4700::1111]:443', 'public IPv6 (Cloudflare DNS)'],
])('rejects public IP literal "%s" (%s)', (entry) => {
const result = allowedAddressesSchema.safeParse([entry]);
expect(result.success).toBe(false);
@ -414,7 +417,9 @@ describe('allowedAddressesSchema', () => {
it('accepts the field on endpoints', () => {
const result = configSchema.safeParse({
version: '1.0',
endpoints: { allowedAddresses: ['10.0.0.5', 'ollama.internal'] },
endpoints: {
allowedAddresses: ['10.0.0.5:11434', 'ollama.internal:11434'],
},
});
expect(result.success).toBe(true);
});
@ -422,7 +427,7 @@ describe('allowedAddressesSchema', () => {
it('accepts the field on mcpSettings', () => {
const result = configSchema.safeParse({
version: '1.0',
mcpSettings: { allowedAddresses: ['127.0.0.1'] },
mcpSettings: { allowedAddresses: ['127.0.0.1:8080'] },
});
expect(result.success).toBe(true);
});
@ -430,7 +435,7 @@ describe('allowedAddressesSchema', () => {
it('accepts the field on actions', () => {
const result = configSchema.safeParse({
version: '1.0',
actions: { allowedAddresses: ['host.docker.internal'] },
actions: { allowedAddresses: ['host.docker.internal:8080'] },
});
expect(result.success).toBe(true);
});
@ -438,7 +443,7 @@ describe('allowedAddressesSchema', () => {
it('rejects a public IP at the endpoints location', () => {
const result = configSchema.safeParse({
version: '1.0',
endpoints: { allowedAddresses: ['8.8.8.8'] },
endpoints: { allowedAddresses: ['8.8.8.8:53'] },
});
expect(result.success).toBe(false);
});
@ -451,10 +456,10 @@ describe('allowedAddressesSchema', () => {
expect(result.success).toBe(false);
});
it('rejects a host:port at the actions location', () => {
it('rejects a bare host at the actions location', () => {
const result = configSchema.safeParse({
version: '1.0',
actions: { allowedAddresses: ['localhost:8080'] },
actions: { allowedAddresses: ['localhost'] },
});
expect(result.success).toBe(false);
});

View file

@ -69,7 +69,9 @@ export const fileSourceSchema = z.nativeEnum(FileSources);
/**
* `allowedAddresses` is an SSRF exemption list scoped to private IP space.
* Validate at config-load time:
* - Reject URLs, paths, CIDR ranges, host:port forms, and whitespace.
* - Reject URLs, paths, CIDR ranges, bare host/IP forms, and whitespace.
* - Require `host:port` or `[ipv6]:port` entries so an exemption is scoped
* to one service port instead of every port on a private host.
* - Reject IPv4 literals that fall outside the private/loopback/link-local
* ranges. Public IPs are never SSRF targets, so listing one has no
* defensive purpose and must not silently grant trust.
@ -114,20 +116,28 @@ function isPrivateIPv6Literal(value: string): boolean {
}
/**
* Detects `host:port` and `[ipv6]:port` shapes both are invalid as
* allowedAddresses entries. Bare `::1`, `[::1]`, and other IPv6 literals
* with no port are intentionally not matched.
*
* Mirrors `looksLikeHostPort` in `@librechat/api`'s `auth/allowedAddresses`.
* Mirrors the allowedAddresses parser in `@librechat/api`'s auth helpers.
* Kept as a local copy because the data-provider package cannot import from
* `@librechat/api` without creating a circular dependency. Keep the two
* implementations in sync.
*/
function looksLikeHostPort(entry: string): boolean {
if (/^\[[^\]]+\]:\d+$/.test(entry)) return true;
const colonCount = (entry.match(/:/g) ?? []).length;
if (colonCount !== 1) return false;
return /^[^:]+:\d+$/.test(entry);
function normalizePort(port: unknown): string {
if (typeof port !== 'string' && typeof port !== 'number') return '';
const portString = String(port).trim();
if (!/^\d+$/.test(portString)) return '';
const parsed = Number(portString);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) return '';
return String(parsed);
}
function parseAllowedAddressEntry(entry: string): { address: string; port: string } | null {
const trimmed = entry.toLowerCase().trim();
const bracketedIPv6 = trimmed.match(/^\[([^\]]+)\]:(\d+)$/);
const hostPort = bracketedIPv6 ? null : trimmed.match(/^([^:]+):(\d+)$/);
const address = (bracketedIPv6?.[1] ?? hostPort?.[1] ?? '').replace(/^\[|\]$/g, '');
const port = normalizePort(bracketedIPv6?.[2] ?? hostPort?.[2] ?? '');
if (!address || !port) return null;
return { address, port };
}
const allowedAddressEntrySchema = z
@ -137,17 +147,17 @@ const allowedAddressEntrySchema = z
})
.refine((entry) => !entry.includes('://') && !entry.includes('/') && !/\s/.test(entry), {
message:
'allowedAddresses entries must be bare hostnames or IPs — no URLs, paths, CIDR ranges, or whitespace',
'allowedAddresses entries must be host:port pairs — no URLs, paths, CIDR ranges, or whitespace',
})
.refine((entry) => !looksLikeHostPort(entry), {
message: 'allowedAddresses entries must not include a port — list the bare hostname or IP only',
.refine((entry) => parseAllowedAddressEntry(entry) != null, {
message:
'allowedAddresses entries must include a port, for example localhost:11434 or [::1]:11434',
})
.refine(
(entry) => {
const stripped = entry
.toLowerCase()
.trim()
.replace(/^\[|\]$/g, '');
const parsed = parseAllowedAddressEntry(entry);
if (!parsed) return false;
const stripped = parsed.address;
const isIPv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.test(stripped);
const isIPv6 = !isIPv4 && stripped.includes(':');
if (!isIPv4 && !isIPv6) {
@ -157,7 +167,7 @@ const allowedAddressEntrySchema = z
},
{
message:
'allowedAddresses is scoped to private IP space — public IP literals are not permitted (use a hostname if it resolves to a private IP)',
'allowedAddresses is scoped to private IP space — public IP literals are not permitted (use hostname:port if it resolves to a private IP)',
},
);
@ -2105,7 +2115,7 @@ export enum Constants {
/** Key for the app's version. */
VERSION = 'v0.8.5',
/** Key for the Custom Config's version (librechat.yaml). */
CONFIG_VERSION = '1.3.9',
CONFIG_VERSION = '1.3.10',
/** Standard value for the first message's `parentMessageId` value, to indicate no parent exists. */
NO_PARENT = '00000000-0000-0000-0000-000000000000',
/** Standard value to use whatever the submission prelim. `responseMessageId` is */

View file

@ -101,7 +101,7 @@ export interface AppConfig {
/** Available tools */
availableTools?: Record<string, FunctionTool>;
endpoints?: {
/** Admin exemption list of hostnames/IPs that bypass the SSRF private-IP block */
/** Admin exemption list of host:port pairs that bypass the SSRF private-IP block */
allowedAddresses?: string[];
/** OpenAI endpoint configuration */
openAI?: Partial<TEndpoint>;