mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-03 22:32:42 +00:00
🛡️ refactor: Scope allowedAddresses By Port (#13022)
* fix: Scope allowedAddresses by port * test: Fix SSRF agent spec typing
This commit is contained in:
parent
b39bf837a7
commit
9441563b95
15 changed files with 507 additions and 252 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue