Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/server/services/ActionService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
64 changes: 34 additions & 30 deletions librechat.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand All @@ -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:
Expand Down
74 changes: 58 additions & 16 deletions packages/api/src/auth/agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(((
Expand All @@ -36,6 +40,7 @@ function mockDnsError(err: NodeJS.ErrnoException): void {

describe('createSSRFSafeAgents', () => {
afterEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
});

Expand All @@ -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', () => {
Expand Down Expand Up @@ -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');
Expand All @@ -159,19 +203,18 @@ 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');
});

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.
Expand All @@ -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');
Expand Down
48 changes: 33 additions & 15 deletions packages/api/src/auth/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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}`),
Expand All @@ -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;
Expand All @@ -69,28 +83,32 @@ 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),
};
}

/**
* 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 };
}
Loading
Loading