diff --git a/api/server/services/ActionService.js b/api/server/services/ActionService.js index b2373d8dfdf5..859496bf7c22 100644 --- a/api/server/services/ActionService.js +++ b/api/server/services/ActionService.js @@ -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 unknown}> } An object with `_call` method to execute the tool input. */ async function createActionTool({ diff --git a/librechat.example.yaml b/librechat.example.yaml index 16c0dbc0a4d8..ef6f846c82d8 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -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: diff --git a/packages/api/src/auth/agent.spec.ts b/packages/api/src/auth/agent.spec.ts index 07bdd7eb76dd..0764f7aa2f6b 100644 --- a/packages/api/src/auth/agent.spec.ts +++ b/packages/api/src/auth/agent.spec.ts @@ -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; +const httpAgentPrototype = http.Agent.prototype as unknown as { + createConnection: (options: Record) => 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, + ) => { + 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) => 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'); diff --git a/packages/api/src/auth/agent.ts b/packages/api/src/auth/agent.ts index 84de36412363..8337a7d071c2 100644 --- a/packages/api/src/auth/agent.ts +++ b/packages/api/src/auth/agent.ts @@ -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, oncreate?: unknown) => unknown; }; +function getConnectionPort(options: Record): string { + return normalizePort(options.port ?? options.defaultPort); +} + /** Patches an agent instance to inject SSRF-safe DNS lookup at connect time */ -function withSSRFProtection(agent: T, lookup: LookupFunction): T { +function withSSRFProtection(agent: T, allowedAddresses?: string[] | null): T { const internal = agent as unknown as AgentInternal; const origCreate = internal.createConnection.bind(agent); internal.createConnection = (options: Record, 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(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 }; } diff --git a/packages/api/src/auth/allowedAddresses.ts b/packages/api/src/auth/allowedAddresses.ts index 6694280c9554..01b8ff0e4b69 100644 --- a/packages/api/src/auth/allowedAddresses.ts +++ b/packages/api/src/auth/allowedAddresses.ts @@ -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` 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 | null): boolean { +export function isAddressInAllowedSet( + candidate: string, + set: Set | 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)); } diff --git a/packages/api/src/auth/domain.spec.ts b/packages/api/src/auth/domain.spec.ts index 471859969e98..0f0326175894 100644 --- a/packages/api/src/auth/domain.spec.ts +++ b/packages/api/src/auth/domain.spec.ts @@ -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); }); }); }); diff --git a/packages/api/src/auth/domain.ts b/packages/api/src/auth/domain.ts index 7de93e9d52cb..468900560bce 100644 --- a/packages/api/src/auth/domain.ts +++ b/packages/api/src/auth/domain.ts @@ -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 { 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 { 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.`); } } diff --git a/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts b/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts index 43411a5385f0..98f698c9cd44 100644 --- a/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts +++ b/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts @@ -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); + mockedCreateSSRFSafeUndiciConnect + .mockReturnValueOnce({ + lookup, + } as ReturnType) + .mockReturnValueOnce({ + lookup, + } as ReturnType); 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', ); }); diff --git a/packages/api/src/mcp/connection.ts b/packages/api/src/mcp/connection.ts index 8f089c9bf073..9f779d7e878c 100644 --- a/packages/api/src/mcp/connection.ts +++ b/packages/api/src/mcp/connection.ts @@ -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, + baseUrl?: string, ): (input: UndiciRequestInfo, init?: UndiciRequestInit) => Promise { + 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, }); diff --git a/packages/api/src/mcp/oauth/handler.allowedAddresses.test.ts b/packages/api/src/mcp/oauth/handler.allowedAddresses.test.ts index d7465b19fe09..00729e4dcb3a 100644 --- a/packages/api/src/mcp/oauth/handler.allowedAddresses.test.ts +++ b/packages/api/src/mcp/oauth/handler.allowedAddresses.test.ts @@ -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/); }); }); diff --git a/packages/api/src/mcp/oauth/handler.ts b/packages/api/src/mcp/oauth/handler.ts index d67199eee85d..519a9a20bf94 100644 --- a/packages/api/src/mcp/oauth/handler.ts +++ b/packages/api/src/mcp/oauth/handler.ts @@ -37,6 +37,13 @@ import { sanitizeUrlForLogging } from '~/mcp/utils'; /** Type for the OAuth metadata from the SDK */ type SDKOAuthMetadata = Parameters[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`); } } diff --git a/packages/api/src/mcp/types/index.ts b/packages/api/src/mcp/types/index.ts index 2d40ecf36d6e..6bc593c1eb95 100644 --- a/packages/api/src/mcp/types/index.ts +++ b/packages/api/src/mcp/types/index.ts @@ -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; diff --git a/packages/data-provider/src/config.spec.ts b/packages/data-provider/src/config.spec.ts index a6326b26359e..f5c107c58f9c 100644 --- a/packages/data-provider/src/config.spec.ts +++ b/packages/data-provider/src/config.spec.ts @@ -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); }); diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index c5c62be3fc9e..1f6e66fcce84 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -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 */ diff --git a/packages/data-schemas/src/types/app.ts b/packages/data-schemas/src/types/app.ts index 53e4496f07a1..4562e588eeb0 100644 --- a/packages/data-schemas/src/types/app.ts +++ b/packages/data-schemas/src/types/app.ts @@ -101,7 +101,7 @@ export interface AppConfig { /** Available tools */ availableTools?: Record; 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;