🪟 feat: Add allowedAddresses Exemption List For SSRF-Guarded Targets (#12933)

* 🪟 feat: Add allowedAddresses Exemption List For SSRF-Guarded Targets

LibreChat already blocks SSRF-prone targets (private IPs, loopback,
link-local, .internal/.local TLDs) at every server-side fetch site
that consumes user-controllable URLs — custom-endpoint baseURLs, MCP
servers, OpenAPI Actions, and OAuth endpoints. The only existing
escape hatch is `allowedDomains`, but that flips the field into a
strict whitelist: adding `127.0.0.1` to permit a self-hosted Ollama
also blocks every public destination that isn't in the list.

Introduce `allowedAddresses` as the orthogonal primitive: a private-
IP-space exemption list. When a hostname or its resolved IP appears
in the list, the SSRF block is bypassed for that target. Public
destinations remain reachable. Operators can now run self-hosted
LLMs / MCP servers / Action endpoints on private addresses without
weakening the default-deny posture for everything else.

Schema additions in `packages/data-provider/src/config.ts`:
- `endpoints.allowedAddresses` (new — gates `validateEndpointURL`)
- `mcpSettings.allowedAddresses` (parallel to `allowedDomains`)
- `actions.allowedAddresses` (parallel to `allowedDomains`)

Core changes in `packages/api/src/auth/`:
- New `isAddressAllowed(hostnameOrIP, allowedAddresses)` — pure,
  case-insensitive, bracket-stripped literal match.
- Threaded the list through `isSSRFTarget`, `resolveHostnameSSRF`,
  `isDomainAllowedCore`, `isActionDomainAllowed`, `isMCPDomainAllowed`,
  `isOAuthUrlAllowed`, and `validateEndpointURL`.
- Extended `createSSRFSafeAgents` and `createSSRFSafeUndiciConnect`
  to accept the list, building an SSRF-safe DNS lookup that exempts
  matching hostnames/IPs at TCP connect time (TOCTOU-safe).

Wiring:
- Custom and OpenAI endpoint initialize sites pass
  `endpoints.allowedAddresses` to `validateEndpointURL`.
- `MCPServersRegistry` stores `allowedAddresses` and exposes it via
  `getAllowedAddresses()`. The factory, connection class, manager,
  `UserConnectionManager`, and `ConnectionsRepository` all thread
  it through to the SSRF utilities.
- `MCPOAuthHandler.initiateOAuthFlow`, `refreshOAuthTokens`, and
  `validateOAuthUrl` accept the list and consult it on every URL
  validation along the OAuth chain.
- `ToolService`, `ActionService`, and the assistants/agents action
  routes pass `actions.allowedAddresses` to `isActionDomainAllowed`
  and to `createSSRFSafeAgents` for runtime action calls.
- `initializeMCPs.js` reads `mcpSettings.allowedAddresses` from the
  app config and forwards it to the registry constructor.

Documentation:
- `librechat.example.yaml` shows the new field next to each existing
  `allowedDomains` block, with a note clarifying that
  `allowedAddresses` is an exemption list (not a whitelist).

Tests:
- Unit tests for `isAddressAllowed` covering literal IPs, hostnames,
  IPv6 brackets, case insensitivity, and partial-match rejection.
- Exemption tests for every entry point: `isSSRFTarget`,
  `resolveHostnameSSRF`, `validateEndpointURL`, `isActionDomainAllowed`,
  `isMCPDomainAllowed`, `isOAuthUrlAllowed`.
- Existing tests updated to reflect the new optional parameter.

Default behavior is unchanged: omitted = empty list = no exemptions.

* 🩹 fix: Plumb allowedAddresses Through AppConfig endpoints Type

The initial PR added `endpoints.allowedAddresses` to the
data-provider config schema and consumed it in the endpoint
initialize sites, but the runtime `AppConfig.endpoints` shape in
`@librechat/data-schemas` was a hand-maintained subset that didn't
include the new field — so `tsc` rejected `appConfig.endpoints.allowedAddresses`.

Add the field to `AppConfig['endpoints']` in
`packages/data-schemas/src/types/app.ts` and forward it from the
loaded config in `packages/data-schemas/src/app/endpoints.ts` so the
runtime config carries the value.

Update `initializeMCPs.spec.js` to expect the third positional
argument (`allowedAddresses`) on the `createMCPServersRegistry` call.

* 🩹 fix: Enforce allowedDomains Before allowedAddresses In isOAuthUrlAllowed

The initial implementation checked the address exemption first, so a
URL whose hostname appeared in `allowedAddresses` would return true
even when the admin had configured `allowedDomains` as a strict bound
on OAuth endpoints. A malicious MCP server could advertise OAuth
metadata, token, or revocation URLs at any address the admin had
permitted for an unrelated reason (a self-hosted LLM at `127.0.0.1`,
for example) and pass validation, expanding SSRF reach beyond the
configured domain whitelist.

Reorder: when `allowedDomains` is set, treat it as authoritative —
return true only if the URL matches a domain entry, otherwise fall
through to false. The address exemption only applies when no
`allowedDomains` is configured (mirrors how the downstream SSRF check
in `validateOAuthUrl` consults `allowedAddresses`).

Add a regression test asserting that an `allowedAddresses` entry does
not broaden a configured `allowedDomains` list.

Reported by chatgpt-codex-connector on PR #12933.

* 🩹 fix: Forward allowedAddresses To Remaining OAuth Callers

Two `MCPOAuthHandler` callers still used the pre-feature signatures and
were silently dropping the new `allowedAddresses` argument:

- `api/server/routes/mcp.js` invoked `initiateOAuthFlow` with the old
  5-argument shape, so OAuth flows initiated through the route handler
  ignored the registry's `getAllowedAddresses()` and would reject any
  metadata/authorization/token URL on a permitted private host.
- `api/server/controllers/UserController.js#maybeUninstallOAuthMCP`
  invoked `revokeOAuthToken` without the address exemption, so
  uninstalling an OAuth-backed MCP server on a permitted private host
  would fail at the revocation step even though the rest of the MCP
  connection path now permits it.

Both sites now read `allowedAddresses` from the registry alongside
`allowedDomains` and forward it. Reported by Copilot on PR #12933.

* 🩹 fix: Update Test Mocks And Assertions For OAuth allowedAddresses

The previous commit started passing `allowedAddresses` to
`MCPOAuthHandler.initiateOAuthFlow` from `api/server/routes/mcp.js`
and to `MCPOAuthHandler.revokeOAuthToken` from
`api/server/controllers/UserController.js`, but the corresponding
test files mocked the registry without `getAllowedAddresses` (causing
`TypeError`s) and asserted the old positional shape on
`toHaveBeenCalledWith`.

Update the mocks and assertions to match the new arity:

- `api/server/routes/__tests__/mcp.spec.js`: add
  `getAllowedDomains`/`getAllowedAddresses` to the registry mock and
  expect the additional positional args on `initiateOAuthFlow`.
- `api/server/controllers/__tests__/maybeUninstallOAuthMCP.spec.js`:
  add a `getAllowedAddresses` mock alongside the existing
  `getAllowedDomains` and seed it in `setupOAuthServerFound`.
- `api/server/controllers/__tests__/UserController.mcpOAuth.spec.js`:
  add `getAllowedAddresses` to the registry mock and expect the
  trailing `null` arg on the three `revokeOAuthToken` assertions.

* 🛡️ fix: Address Comprehensive Review — Scope allowedAddresses To Private IP Space

Major findings from the comprehensive PR review (severity → fix):

**CRITICAL — `validateOAuthUrl` SSRF fallback bypass.** When `allowedDomains`
is configured and a URL fails the whitelist, the SSRF fallback in
`validateOAuthUrl` was still passing `allowedAddresses` to `isSSRFTarget` /
`resolveHostnameSSRF`, letting a malicious MCP server advertise OAuth
endpoints at any address the admin had permitted for an unrelated reason.
Suppress `allowedAddresses` in the fallback when `allowedDomains` is active —
the address exemption is opt-in for the no-whitelist mode only.

**MAJOR — WebSocket transport SSRF check ignored exemptions.** The
`constructTransport` WebSocket branch called `resolveHostnameSSRF(wsHostname)`
without `this.allowedAddresses`, so a permitted private MCP server would
pass `isMCPDomainAllowed` but be blocked at transport creation. Forward
the exemption.

**Scope `allowedAddresses` to private IP space only (operator directive).**
The exemption list is for permitting private/internal targets; it must not
be a back-door to broaden trust to public destinations.
- Schema (`packages/data-provider/src/config.ts`): new
  `allowedAddressesSchema` rejects URLs (`://`), paths/CIDR (`/`),
  whitespace, and public IPv4/IPv6 literals at config-load time. Wired
  into `endpoints`, `mcpSettings`, and `actions`.
- Runtime (`packages/api/src/auth/domain.ts`): `isAddressAllowed` now
  drops public-IP candidates and public-IP entries on the match path —
  defense in depth so a misconfigured runtime list never grants exemption.
- Hot path (`packages/api/src/auth/agent.ts`): `buildSSRFSafeLookup`
  pre-normalizes the list into a `Set<string>` once at construction and
  applies the same scoping filter, so the connect-time DNS lookup is an
  O(1) Set membership check instead of a full re-iterate-and-normalize on
  every outbound request.

**Test coverage for the connect-time and OAuth-fallback paths.**
- `agent.spec.ts`: new describe block exercising `buildSSRFSafeLookup` and
  `createSSRFSafe*` with `allowedAddresses` — hostname-literal exemption,
  resolved-IP exemption, public-IP scoping, URL/CIDR/whitespace rejection,
  and the default no-list block.
- `handler.allowedAddresses.test.ts` (new): integration tests for
  `validateOAuthUrl` — covers both the no-domains-set "permit private"
  path and the strict-bound regression where `allowedAddresses` must NOT
  bypass `allowedDomains`.

**Documentation & cleanup.**
- `connection.ts` redirect SSRF check: explicit comment that
  `allowedAddresses` is intentionally NOT consulted for redirect targets
  (server-controlled, must not inherit the admin's exemption).
- `MCPConnectionFactory.test.ts`: replaced an `eslint-disable` with a
  proper `import { getTenantId } from '@librechat/data-schemas'`. The
  disable was added to make a pre-existing `require()` quiet — the cleaner
  fix is to use the existing top-level import.

Updated `MCPConnectionSSRF.test.ts` WebSocket SSRF assertions to match the
new two-argument call shape (`hostname, allowedAddresses`).

* 🩹 fix: Require Absolute URL Before allowedAddresses Trust Bypass In isOAuthUrlAllowed

`parseDomainSpec` is lenient — it silently prepends `https://` to
schemeless inputs so it can match patterns like bare `example.com`.
That leniency leaked into `isOAuthUrlAllowed`'s new
`allowedAddresses` short-circuit: a value like `10.0.0.5/oauth` (no
scheme) would parse successfully via the prepended default, hit the
address-exemption path, return `true`, and skip `validateOAuthUrl`'s
strict `new URL(url)` parse-or-throw — only to fail later in OAuth
discovery with a less clear runtime error.

Add a strict `new URL(url)` gate at the top of `isOAuthUrlAllowed`.
Schemeless inputs now fall through to `validateOAuthUrl`'s explicit
"Invalid OAuth <field>" rejection. Tests added in both
`auth/domain.spec.ts` (unit) and the OAuth handler integration spec
(end-to-end).

Reported by chatgpt-codex-connector (P2) on PR #12933.

* 🛡️ fix: Address Follow-Up Comprehensive Review — Schema Tests, Shared Normalization, host:port

Auditing the second comprehensive review:

**F1 MAJOR — schema validation untested.** `allowedAddressesSchema` had
zero coverage, so a regression in the three refinement stages or the
three wiring locations (`endpoints` / `mcpSettings` / `actions`) would
silently let invalid entries reach the runtime. Added a dedicated
`describe('allowedAddressesSchema')` block in `config.spec.ts` covering:
valid private IPs (v4 + v6, including the previously-missed 192.0.0.0/24
range), accepted hostnames, all rejection categories (URLs, CIDR, paths,
whitespace tabs/newlines, host:port, public IP literals), and full
`configSchema.parse()` integration at each of the three nesting points.

**F2 MINOR — `isPrivateIPv4Literal` divergence.** The schema reimpl in
`packages/data-provider` was discarding the `c` octet, so the
`192.0.0.0/24` (RFC 5736 IETF protocol assignments) range that the
authoritative `isPrivateIPv4` accepts was being rejected with a
misleading "public IP" error. Destructure `c` and add the missing range
check; covered by the new schema tests.

**F3 MINOR — DRY violation across `domain.ts` and `agent.ts`.** Both
files had independent normalization implementations with a subtle
whitespace-check divergence (`/\s/` vs `.includes(' ')`). Extracted the
shared logic into a new `packages/api/src/auth/allowedAddresses.ts`
module that both consumers import:
  - `normalizeAddressEntry(entry)` — single-entry shape check
  - `looksLikeHostPort(entry)` — host:port detector (used by F4)
  - `normalizeAllowedAddressesSet(list)` — pre-normalized Set for the
    connect-time hot path
  - `isAddressInAllowedSet(candidate, set)` — membership check that
    enforces private-IP scoping on the candidate

Both `isAddressAllowed` (preflight) and `buildSSRFSafeLookup` (connect)
now go through the same primitives; the whitespace divergence is gone.

To break the import cycle (`allowedAddresses` needs `isPrivateIP`,
`domain` previously owned it), extracted IP private-range detection
into a leaf `auth/ip.ts` module. `domain.ts` re-exports `isPrivateIP`
for backward compatibility with existing call sites.

**F4 MINOR — `host:port` silently misclassified.** Entries like
`localhost:8080` previously slipped through the URL/path guard, were
mis-detected as IPv6, failed `isPrivateIP`, and were silently dropped
with a misleading "public IP" schema error. Added an explicit
`looksLikeHostPort` check with a clear error: "allowedAddresses
entries must not include a port — list the bare hostname or IP only."
Bare `::1`, `[::1]`, and other valid IPv6 literals are intentionally
not matched (regex distinguishes by colon count and the bracketed
`[ipv6]:port` form).

**F5 MINOR — hostname-trust documentation gap.** Hostname entries
short-circuit `resolveHostnameSSRF` before any DNS lookup — that's a
deliberate design (admin trusts the name) but it means the exemption
follows whatever the name resolves to at runtime. Added an explicit
note in `librechat.example.yaml` for both `mcpSettings.allowedAddresses`
and `endpoints.allowedAddresses`: "a hostname entry trusts whatever IP
that name resolves to. Only list hostnames whose DNS you control.
Prefer literal IPs when you can."

**F6** (8 positional params) is flagged for follow-up; refactor to an
options object is a breaking-API change deferred to a separate PR.
**F7** (redirect/WebSocket asymmetry, NIT, conf 40) — skipping; the
existing inline comment is sufficient.

* 🧹 chore: Address Follow-Up NITs — Import Order And Mirror-Function Naming

Three NITs from the latest comprehensive review:

**NIT #1 (conf 85) — local import order.** AGENTS.md requires local
imports sorted longest-to-shortest. Both `domain.ts` and `agent.ts`
had `./ip` (shorter) before `./allowedAddresses` (longer). Swapped.

**NIT #2 (conf 60) — missing cross-reference.** The schema-side
`isHostPortShape` in `packages/data-provider/src/config.ts` had no
note pointing at the canonical runtime mirror. Added a JSDoc paragraph
explaining the mirror relationship and why a local copy exists (the
data-provider package can't import from `@librechat/api` without
creating a circular dependency).

**NIT #3 (conf 50) — naming inconsistency.** Renamed
`isHostPortShape` → `looksLikeHostPort` so the schema mirror matches
the runtime helper exactly. Kept as a separate function (not a shared
import) for the same circular-dependency reason; the matching name
makes it obvious they should stay in lockstep.
This commit is contained in:
Danny Avila 2026-05-03 21:43:59 -04:00 committed by GitHub
parent 85fa881e3c
commit 4cce88be42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 1377 additions and 222 deletions

View file

@ -480,7 +480,9 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
serverConfig.oauth?.revocation_endpoint_auth_methods_supported ??
clientMetadata.revocation_endpoint_auth_methods_supported;
const oauthHeaders = serverConfig.oauth_headers ?? {};
const allowedDomains = getMCPServersRegistry().getAllowedDomains();
const registry = getMCPServersRegistry();
const allowedDomains = registry.getAllowedDomains();
const allowedAddresses = registry.getAllowedAddresses();
if (tokens?.access_token) {
try {
@ -497,6 +499,7 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
},
oauthHeaders,
allowedDomains,
allowedAddresses,
);
} catch (error) {
logger.error(
@ -521,6 +524,7 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
},
oauthHeaders,
allowedDomains,
allowedAddresses,
);
} catch (error) {
logger.error(

View file

@ -121,6 +121,7 @@ function setupMCPMocks() {
}),
getOAuthServers: jest.fn().mockResolvedValue(new Set(['test-server'])),
getAllowedDomains: jest.fn().mockReturnValue([]),
getAllowedAddresses: jest.fn().mockReturnValue(null),
};
mockGetAppConfig.mockResolvedValue({});
@ -333,6 +334,7 @@ describe('updateUserPluginsController MCP OAuth cleanup', () => {
},
{},
[],
null,
);
expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledWith(
'test-server',
@ -347,6 +349,7 @@ describe('updateUserPluginsController MCP OAuth cleanup', () => {
},
{},
[],
null,
);
expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
userId: 'user-1',
@ -378,6 +381,7 @@ describe('updateUserPluginsController MCP OAuth cleanup', () => {
expect.objectContaining({ clientId: 'client-1' }),
{},
[],
null,
);
expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
userId: 'user-1',
@ -409,6 +413,7 @@ describe('updateUserPluginsController MCP OAuth cleanup', () => {
expect.objectContaining({ clientId: 'client-1' }),
{},
[],
null,
);
expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
userId: 'user-1',

View file

@ -5,6 +5,7 @@ const mockRevokeOAuthToken = jest.fn();
const mockGetServerConfig = jest.fn();
const mockGetOAuthServers = jest.fn();
const mockGetAllowedDomains = jest.fn();
const mockGetAllowedAddresses = jest.fn();
const mockDeleteFlow = jest.fn();
const mockGetLogStores = jest.fn();
const mockFindToken = jest.fn();
@ -53,6 +54,7 @@ jest.mock('~/config', () => ({
getServerConfig: (...args) => mockGetServerConfig(...args),
getOAuthServers: (...args) => mockGetOAuthServers(...args),
getAllowedDomains: (...args) => mockGetAllowedDomains(...args),
getAllowedAddresses: (...args) => mockGetAllowedAddresses(...args),
})),
}));
@ -142,6 +144,7 @@ function setupOAuthServerFound() {
mockGetServerConfig.mockResolvedValue(serverConfig);
mockGetOAuthServers.mockResolvedValue(new Set([serverName]));
mockGetAllowedDomains.mockReturnValue(['https://acme.example.com']);
mockGetAllowedAddresses.mockReturnValue(null);
mockGetClientInfoAndMetadata.mockResolvedValue({ clientInfo, clientMetadata });
}

View file

@ -22,6 +22,8 @@ const mockRegistryInstance = {
addServer: jest.fn(),
updateServer: jest.fn(),
removeServer: jest.fn(),
getAllowedDomains: jest.fn().mockReturnValue(null),
getAllowedAddresses: jest.fn().mockReturnValue(null),
};
jest.mock('@librechat/api', () => {
@ -210,6 +212,9 @@ describe('MCP Routes', () => {
'test-user-id',
{},
{ clientId: 'test-client-id' },
null,
undefined,
null,
);
});

View file

@ -118,6 +118,7 @@ router.post(
const isDomainAllowed = await isActionDomainAllowed(
metadata.domain,
appConfig?.actions?.allowedDomains,
appConfig?.actions?.allowedAddresses,
);
if (!isDomainAllowed) {
return res.status(400).json({ message: 'Domain not allowed' });

View file

@ -37,6 +37,7 @@ router.post('/:assistant_id', async (req, res) => {
const isDomainAllowed = await isActionDomainAllowed(
metadata.domain,
appConfig?.actions?.allowedDomains,
appConfig?.actions?.allowedAddresses,
);
if (!isDomainAllowed) {
return res.status(400).json({ message: 'Domain not allowed' });

View file

@ -107,6 +107,9 @@ router.get('/:serverName/oauth/initiate', requireJwtAuth, setOAuthSession, async
const configServers = await resolveConfigServers(req);
const oauthHeaders = await getOAuthHeaders(serverName, userId, configServers);
const registry = getMCPServersRegistry();
const allowedDomains = registry.getAllowedDomains();
const allowedAddresses = registry.getAllowedAddresses();
const {
authorizationUrl,
flowId: oauthFlowId,
@ -117,6 +120,9 @@ router.get('/:serverName/oauth/initiate', requireJwtAuth, setOAuthSession, async
userId,
oauthHeaders,
oauthConfig,
allowedDomains,
undefined,
allowedAddresses,
);
logger.debug('[MCP OAuth] OAuth flow initiated', { oauthFlowId, authorizationUrl });

View file

@ -175,6 +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.
* @returns { Promise<typeof tool | { _call: (toolInput: Object | string) => unknown}> } An object with `_call` method to execute the tool input.
*/
async function createActionTool({
@ -188,8 +189,9 @@ async function createActionTool({
encrypted,
streamId = null,
useSSRFProtection = false,
allowedAddresses,
}) {
const ssrfAgents = useSSRFProtection ? createSSRFSafeAgents() : undefined;
const ssrfAgents = useSSRFProtection ? createSSRFSafeAgents(allowedAddresses) : undefined;
/** @type {(toolInput: Object | string, config: GraphRunnableConfig) => Promise<unknown>} */
const _call = async (toolInput, config) => {
try {

View file

@ -417,7 +417,12 @@ async function createMCPTools({
if (serverConfig?.url) {
const appConfig = await getAppConfig({ role: user?.role, tenantId: user?.tenantId });
const allowedDomains = appConfig?.mcpSettings?.allowedDomains;
const isDomainAllowed = await isMCPDomainAllowed(serverConfig, allowedDomains);
const allowedAddresses = appConfig?.mcpSettings?.allowedAddresses;
const isDomainAllowed = await isMCPDomainAllowed(
serverConfig,
allowedDomains,
allowedAddresses,
);
if (!isDomainAllowed) {
logger.warn(`[MCP][${serverName}] Domain not allowed, skipping all tools`);
return [];
@ -500,7 +505,12 @@ async function createMCPTool({
if (serverConfig?.url) {
const appConfig = await getAppConfig({ role: user?.role, tenantId: user?.tenantId });
const allowedDomains = appConfig?.mcpSettings?.allowedDomains;
const isDomainAllowed = await isMCPDomainAllowed(serverConfig, allowedDomains);
const allowedAddresses = appConfig?.mcpSettings?.allowedAddresses;
const isDomainAllowed = await isMCPDomainAllowed(
serverConfig,
allowedDomains,
allowedAddresses,
);
if (!isDomainAllowed) {
logger.warn(`[MCP][${serverName}] Domain no longer allowed, skipping tool: ${toolName}`);
return undefined;

View file

@ -929,6 +929,7 @@ describe('User parameter passing tests', () => {
expect(mockIsMCPDomainAllowed).toHaveBeenCalledWith(
{ url: 'https://disallowed-domain.com/sse' },
['allowed-domain.com'],
undefined,
);
});

View file

@ -360,6 +360,7 @@ async function processRequiredActions(client, requiredActions) {
const isDomainAllowed = await isActionDomainAllowed(
action.metadata.domain,
appConfig?.actions?.allowedDomains,
appConfig?.actions?.allowedAddresses,
);
if (!isDomainAllowed) {
continue;
@ -428,6 +429,7 @@ async function processRequiredActions(client, requiredActions) {
// We've already decrypted the metadata, so we can pass it directly
const _allowedDomains = appConfig?.actions?.allowedDomains;
const _allowedAddresses = appConfig?.actions?.allowedAddresses;
tool = await createActionTool({
userId: client.req.user.id,
res: client.res,
@ -436,6 +438,7 @@ async function processRequiredActions(client, requiredActions) {
// Note: intentionally not passing zodSchema, name, and description for assistants API
encrypted, // Pass the encrypted values for OAuth flow
useSSRFProtection: !Array.isArray(_allowedDomains) || _allowedDomains.length === 0,
allowedAddresses: _allowedAddresses,
});
if (!tool) {
logger.warn(
@ -659,6 +662,7 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
const definitions = [];
const allowedDomains = appConfig?.actions?.allowedDomains;
const allowedAddresses = appConfig?.actions?.allowedAddresses;
const normalizedToolNames = new Set(
actionToolNames.map((n) => n.replace(domainSeparatorRegex, '_')),
);
@ -670,7 +674,11 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
const legacyDomain = legacyDomainEncode(action.metadata.domain);
const legacyNormalized = legacyDomain.replace(domainSeparatorRegex, '_');
const isDomainAllowed = await isActionDomainAllowed(action.metadata.domain, allowedDomains);
const isDomainAllowed = await isActionDomainAllowed(
action.metadata.domain,
allowedDomains,
allowedAddresses,
);
if (!isDomainAllowed) {
logger.warn(
`[Actions] Domain "${action.metadata.domain}" not in allowedDomains. ` +
@ -1094,6 +1102,7 @@ async function loadAgentTools({
const isDomainAllowed = await isActionDomainAllowed(
action.metadata.domain,
appConfig?.actions?.allowedDomains,
appConfig?.actions?.allowedAddresses,
);
if (!isDomainAllowed) {
continue;
@ -1165,6 +1174,7 @@ async function loadAgentTools({
const { action, encrypted, zodSchema, requestBuilder, functionSignature } = entry;
const _allowedDomains = appConfig?.actions?.allowedDomains;
const _allowedAddresses = appConfig?.actions?.allowedAddresses;
const tool = await createActionTool({
userId: req.user.id,
res,
@ -1176,6 +1186,7 @@ async function loadAgentTools({
description: functionSignature.description,
streamId,
useSSRFProtection: !Array.isArray(_allowedDomains) || _allowedDomains.length === 0,
allowedAddresses: _allowedAddresses,
});
if (!tool) {
@ -1406,6 +1417,7 @@ async function loadActionToolsForExecution({
// See registerActionTools for the key-shape rationale.
const toolToAction = new Map();
const allowedDomains = appConfig?.actions?.allowedDomains;
const allowedAddresses = appConfig?.actions?.allowedAddresses;
for (const action of actionSets) {
const domain = await domainParser(action.metadata.domain, true);
@ -1413,7 +1425,11 @@ async function loadActionToolsForExecution({
const legacyDomain = legacyDomainEncode(action.metadata.domain);
const legacyNormalized = legacyDomain.replace(domainSeparatorRegex, '_');
const isDomainAllowed = await isActionDomainAllowed(action.metadata.domain, allowedDomains);
const isDomainAllowed = await isActionDomainAllowed(
action.metadata.domain,
allowedDomains,
allowedAddresses,
);
if (!isDomainAllowed) {
logger.warn(
`[Actions] Domain "${action.metadata.domain}" not in allowedDomains. ` +
@ -1487,6 +1503,7 @@ async function loadActionToolsForExecution({
name: toolName,
description: functionSignature.description,
useSSRFProtection: !Array.isArray(allowedDomains) || allowedDomains.length === 0,
allowedAddresses,
});
if (!tool) {

View file

@ -11,7 +11,11 @@ async function initializeMCPs() {
const mcpServers = appConfig.mcpConfig;
try {
createMCPServersRegistry(mongoose, appConfig?.mcpSettings?.allowedDomains);
createMCPServersRegistry(
mongoose,
appConfig?.mcpSettings?.allowedDomains,
appConfig?.mcpSettings?.allowedAddresses,
);
} catch (error) {
logger.error('[MCP] Failed to initialize MCPServersRegistry:', error);
throw error;

View file

@ -81,6 +81,7 @@ describe('initializeMCPs', () => {
expect(mockCreateMCPServersRegistry).toHaveBeenCalledWith(
expect.anything(), // mongoose
['localhost'],
undefined,
);
});
@ -93,7 +94,11 @@ describe('initializeMCPs', () => {
await initializeMCPs();
expect(mockCreateMCPServersRegistry).toHaveBeenCalledWith(expect.anything(), allowedDomains);
expect(mockCreateMCPServersRegistry).toHaveBeenCalledWith(
expect.anything(),
allowedDomains,
undefined,
);
});
it('should handle undefined mcpSettings gracefully', async () => {
@ -104,7 +109,11 @@ describe('initializeMCPs', () => {
await initializeMCPs();
expect(mockCreateMCPServersRegistry).toHaveBeenCalledWith(expect.anything(), undefined);
expect(mockCreateMCPServersRegistry).toHaveBeenCalledWith(
expect.anything(),
undefined,
undefined,
);
});
it('should throw and log error if MCPServersRegistry initialization fails', async () => {

View file

@ -191,7 +191,9 @@ registration:
# Agent Actions domain restrictions (OpenAPI spec validation)
# SECURITY: If not configured, SSRF targets are blocked (localhost, private IPs, .internal/.local TLDs).
# To allow internal targets, you MUST explicitly add them to allowedDomains.
# Prefer `allowedAddresses` for permitting internal targets — adding a private IP to
# `allowedDomains` switches the field into strict-whitelist mode and blocks every
# public destination not also listed.
# Supports wildcards: '*.example.com' and protocol/port restrictions: 'https://api.example.com:8443'
actions:
allowedDomains:
@ -199,10 +201,45 @@ actions:
- 'librechat.ai'
- '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.
#
# 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).
#
# 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.
# allowedAddresses:
# - 'host.docker.internal'
# - '127.0.0.1'
# - '10.0.0.5'
# 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.
#
# 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.
# endpoints:
# allowedAddresses:
# - 'localhost'
# - '127.0.0.1'
# - 'ollama'
# - '10.0.0.5'
# MCP Server domain restrictions for remote transports (SSE, WebSocket, HTTP)
# SECURITY: If not configured, SSRF targets are blocked (localhost, private IPs, .internal/.local TLDs).
# To allow internal targets like host.docker.internal, you MUST explicitly add them to allowedDomains.
# Prefer `allowedAddresses` for permitting internal targets — adding a private host to
# `allowedDomains` switches the field into strict-whitelist mode and blocks every
# public destination not also listed.
# Supports wildcards: '*.example.com' matches 'api.example.com', 'staging.example.com', etc.
# Supports protocol/port restrictions: 'https://api.example.com:8443' restricts to specific protocol/port.
# mcpSettings:
@ -212,6 +249,15 @@ actions:
# - '*.example.com' # Wildcard subdomain
# - '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.
# allowedAddresses:
# - 'host.docker.internal'
# - '127.0.0.1'
# Example MCP Servers Object Structure
# mcpServers:

View file

@ -7,6 +7,7 @@ jest.mock('node:dns', () => {
});
import dns from 'node:dns';
import type { LookupFunction } from 'node:net';
import { createSSRFSafeAgents, createSSRFSafeUndiciConnect } from './agent';
type LookupCallback = (err: NodeJS.ErrnoException | null, address: string, family: number) => void;
@ -111,3 +112,98 @@ describe('createSSRFSafeUndiciConnect', () => {
expect(result.err!.code).toBe('ENOTFOUND');
});
});
/**
* Connect-time exemption is the TOCTOU-safe layer: pre-flight validation can
* be bypassed by DNS rebinding, but the agent's `lookup` runs on every TCP
* connect and must honor `allowedAddresses` consistently. These tests cover
* the runtime path that the domain.ts spec doesn't reach.
*/
describe('SSRF agents — allowedAddresses exemption', () => {
afterEach(() => {
jest.clearAllMocks();
});
function runLookup(lookup: LookupFunction, hostname: string) {
return new Promise<{ err: NodeJS.ErrnoException | null; address: string }>((resolve) => {
(lookup as (h: string, o: object, cb: LookupCallback) => void)(hostname, {}, (err, address) =>
resolve({ err, address: address as string }),
);
});
}
it('exempts a hostname literal that the admin permitted', async () => {
mockDnsResult('10.0.0.5', 4);
const { lookup } = createSSRFSafeUndiciConnect(['ollama.internal']);
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 () => {
mockDnsResult('10.0.0.5', 4);
const { lookup } = createSSRFSafeUndiciConnect(['10.0.0.5']);
const result = await runLookup(lookup, 'private.example.com');
expect(result.err).toBeNull();
expect(result.address).toBe('10.0.0.5');
});
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 result = await runLookup(lookup, 'other.private.example.com');
expect(result.err).toBeTruthy();
expect(result.err!.code).toBe('ESSRF');
});
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 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 ',
]);
// 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.
const result = await runLookup(lookup, 'private.example.com');
expect(result.err).toBeTruthy();
expect(result.err!.code).toBe('ESSRF');
});
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.
mockDnsResult('10.0.0.5', 4);
const agents = createSSRFSafeAgents(['10.0.0.5']);
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 result = await runLookup(lookup, 'private.example.com');
expect(result.err).toBeNull();
expect(result.address).toBe('10.0.0.5');
});
it('default lookup (no exemption list) blocks private IPs', async () => {
mockDnsResult('10.0.0.5', 4);
const { lookup } = createSSRFSafeUndiciConnect();
const result = await runLookup(lookup, 'private.example.com');
expect(result.err).toBeTruthy();
expect(result.err!.code).toBe('ESSRF');
});
});

View file

@ -2,26 +2,50 @@ import dns from 'node:dns';
import http from 'node:http';
import https from 'node:https';
import type { LookupFunction } from 'node:net';
import { isPrivateIP } from './domain';
import { normalizeAllowedAddressesSet, isAddressInAllowedSet } from './allowedAddresses';
import { isPrivateIP } from './ip';
/** DNS lookup wrapper that blocks resolution to private/reserved IP addresses */
const ssrfSafeLookup: LookupFunction = (hostname, options, callback) => {
dns.lookup(hostname, options, (err, address, family) => {
if (err) {
callback(err, '', 0);
return;
}
if (typeof address === 'string' && isPrivateIP(address)) {
const ssrfError = Object.assign(
new Error(`SSRF protection: ${hostname} resolved to blocked address ${address}`),
{ code: 'ESSRF' },
) as NodeJS.ErrnoException;
callback(ssrfError, address, family as number);
return;
}
callback(null, address as string, family as number);
});
};
/**
* Builds a DNS lookup wrapper that blocks resolution to private/reserved IP
* addresses. When `allowedAddresses` is provided, hostnames or resolved IPs
* 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.
*
* 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 {
const exemptSet = normalizeAllowedAddressesSet(allowedAddresses);
return (hostname, options, callback) => {
const hostnameAllowed = isAddressInAllowedSet(hostname, exemptSet);
dns.lookup(hostname, options, (err, address, family) => {
if (err) {
callback(err, '', 0);
return;
}
if (
!hostnameAllowed &&
typeof address === 'string' &&
isPrivateIP(address) &&
!isAddressInAllowedSet(address, exemptSet)
) {
const ssrfError = Object.assign(
new Error(`SSRF protection: ${hostname} resolved to blocked address ${address}`),
{ code: 'ESSRF' },
) as NodeJS.ErrnoException;
callback(ssrfError, address, family as number);
return;
}
callback(null, address as string, family as number);
});
};
}
/** Default lookup with no exemptions. Kept for callers that don't need allowedAddresses. */
const ssrfSafeLookup: LookupFunction = buildSSRFSafeLookup();
/** Internal agent shape exposing createConnection (exists at runtime but not in TS types) */
type AgentInternal = {
@ -29,11 +53,11 @@ type AgentInternal = {
};
/** Patches an agent instance to inject SSRF-safe DNS lookup at connect time */
function withSSRFProtection<T extends http.Agent>(agent: T): T {
function withSSRFProtection<T extends http.Agent>(agent: T, lookup: LookupFunction): T {
const internal = agent as unknown as AgentInternal;
const origCreate = internal.createConnection.bind(agent);
internal.createConnection = (options: Record<string, unknown>, oncreate?: unknown) => {
options.lookup = ssrfSafeLookup;
options.lookup = lookup;
return origCreate(options, oncreate);
};
return agent;
@ -44,18 +68,29 @@ function withSSRFProtection<T extends http.Agent>(agent: T): T {
* Provides TOCTOU-safe SSRF protection by validating the resolved IP at connect time,
* 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.
*/
export function createSSRFSafeAgents(): { httpAgent: http.Agent; httpsAgent: https.Agent } {
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()),
httpsAgent: withSSRFProtection(new https.Agent()),
httpAgent: withSSRFProtection(new http.Agent(), lookup),
httpsAgent: withSSRFProtection(new https.Agent(), lookup),
};
}
/**
* 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.
*/
export function createSSRFSafeUndiciConnect(): { lookup: LookupFunction } {
return { lookup: ssrfSafeLookup };
export function createSSRFSafeUndiciConnect(allowedAddresses?: string[] | null): {
lookup: LookupFunction;
} {
const lookup = allowedAddresses?.length ? buildSSRFSafeLookup(allowedAddresses) : ssrfSafeLookup;
return { lookup };
}

View file

@ -0,0 +1,92 @@
/**
* Shared normalization for `allowedAddresses` entries used by both the
* preflight SSRF helpers in `domain.ts` and the connect-time DNS lookup in
* `agent.ts`. Keeping this in one module avoids subtle divergence between
* the two paths (e.g. one rejecting tabs but not the other), which would
* weaken defense-in-depth.
*
* 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.
* - 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
* separately by callers (e.g. `resolveHostnameSSRF`).
*/
import { isPrivateIP } from './ip';
/** 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)) {
return true;
}
return normalized.includes(':');
}
/**
* Detects `host:port` and `[ipv6]:port` shapes, which are admin-input
* mistakes. Bare `::1`, `[::1]`, and IPv6 literals with no port are not
* matched.
*/
export 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);
}
/**
* 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
.toLowerCase()
.trim()
.replace(/^\[|\]$/g, '');
if (!normalized) return '';
if (isIPLiteral(normalized) && !isPrivateIP(normalized)) return '';
return normalized;
}
/**
* 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
* silently dropped here; the Zod schema reports them at config load.
*/
export function normalizeAllowedAddressesSet(
allowedAddresses?: string[] | null,
): Set<string> | null {
if (!Array.isArray(allowedAddresses) || allowedAddresses.length === 0) {
return null;
}
const set = new Set<string>();
for (const entry of allowedAddresses) {
const normalized = normalizeAddressEntry(entry);
if (normalized) set.add(normalized);
}
return set.size > 0 ? set : null;
}
/**
* 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.
*/
export function isAddressInAllowedSet(candidate: string, set: Set<string> | 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);
}

View file

@ -7,6 +7,7 @@ import { lookup } from 'node:dns/promises';
import {
extractMCPServerDomain,
isActionDomainAllowed,
isAddressAllowed,
isEmailDomainAllowed,
isOAuthUrlAllowed,
isMCPDomainAllowed,
@ -1433,3 +1434,194 @@ describe('validateEndpointURL', () => {
expect(parsed.message).toContain('targets a restricted address');
});
});
describe('isAddressAllowed', () => {
it('returns false when no allowedAddresses configured', () => {
expect(isAddressAllowed('127.0.0.1')).toBe(false);
expect(isAddressAllowed('127.0.0.1', null)).toBe(false);
expect(isAddressAllowed('127.0.0.1', [])).toBe(false);
});
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);
});
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);
});
it('strips IPv6 brackets when matching', () => {
expect(isAddressAllowed('[::1]', ['::1'])).toBe(true);
expect(isAddressAllowed('::1', ['[::1]'])).toBe(true);
});
it('does not partial-match hostnames', () => {
expect(isAddressAllowed('evil.localhost', ['localhost'])).toBe(false);
expect(isAddressAllowed('host', ['host.docker.internal'])).toBe(false);
});
it('ignores empty entries', () => {
expect(isAddressAllowed('localhost', ['', ' ', 'localhost'])).toBe(true);
expect(isAddressAllowed('localhost', ['', ' '])).toBe(false);
});
});
describe('SSRF allowedAddresses exemption', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe('isSSRFTarget', () => {
it('exempts a hostname listed in allowedAddresses', () => {
expect(isSSRFTarget('localhost')).toBe(true);
expect(isSSRFTarget('localhost', ['localhost'])).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);
});
it('does not exempt an unlisted private target', () => {
expect(isSSRFTarget('192.168.1.1', ['10.0.0.5'])).toBe(true);
});
it('leaves public destinations unaffected when allowedAddresses is set', () => {
expect(isSSRFTarget('api.openai.com', ['localhost'])).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);
});
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);
});
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);
});
it('blocks when one of multiple resolved IPs is private and unlisted', async () => {
mockedLookup.mockResolvedValueOnce([
{ 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);
});
it('passes when all resolved private IPs are listed', async () => {
mockedLookup.mockResolvedValueOnce([
{ 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,
);
});
});
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']),
).resolves.toBeUndefined();
});
it('passes a localhost URL when localhost is in allowedAddresses', async () => {
await expect(
validateEndpointURL('http://localhost:11434/v1', 'ollama', ['localhost']),
).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']),
).resolves.toBeUndefined();
});
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']),
).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']),
).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(
true,
);
});
it('still blocks unlisted private IPs', async () => {
expect(await isActionDomainAllowed('http://192.168.1.1/api', null, ['10.0.0.5'])).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']),
).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,
);
});
});
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('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', ['other.example.com'], ['10.0.0.5']),
).toBe(true);
});
it('does not let allowedAddresses bypass a configured allowedDomains list', () => {
// Admin set `mcpSettings.allowedDomains` to constrain OAuth metadata/token/revocation
// 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']),
).toBe(false);
});
it('rejects schemeless inputs even when the bare host 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);
});
});
});

View file

@ -1,4 +1,9 @@
import { lookup } from 'node:dns/promises';
import { normalizeAllowedAddressesSet, isAddressInAllowedSet } from './allowedAddresses';
import { isPrivateIP } from './ip';
/** Re-exported here for backward compatibility; canonical location is `./ip`. */
export { isPrivateIP };
/**
* @param email
@ -24,140 +29,21 @@ export function isEmailDomainAllowed(email: string, allowedDomains?: string[] |
return allowedDomains.some((allowedDomain) => allowedDomain?.toLowerCase() === domain);
}
/** Checks if IPv4 octets fall within private, reserved, or non-routable ranges */
function isPrivateIPv4(a: number, b: number, c: number): boolean {
if (a === 0) {
return true;
}
if (a === 10) {
return true;
}
if (a === 127) {
return true;
}
if (a === 100 && b >= 64 && b <= 127) {
return true;
}
if (a === 169 && b === 254) {
return true;
}
if (a === 172 && b >= 16 && b <= 31) {
return true;
}
if (a === 192 && b === 168) {
return true;
}
if (a === 192 && b === 0 && c === 0) {
return true;
}
if (a === 198 && (b === 18 || b === 19)) {
return true;
}
if (a >= 224) {
return true;
}
return false;
}
/** Checks if a pre-normalized (lowercase, bracket-stripped) IPv6 address falls within fe80::/10 */
function isIPv6LinkLocal(ipv6: string): boolean {
if (!ipv6.includes(':')) {
return false;
}
const firstHextet = ipv6.split(':', 1)[0];
if (!firstHextet || !/^[0-9a-f]{1,4}$/.test(firstHextet)) {
return false;
}
const hextet = parseInt(firstHextet, 16);
// /10 mask (0xffc0) preserves top 10 bits: fe80 = 1111_1110_10xx_xxxx
return (hextet & 0xffc0) === 0xfe80;
}
/** Checks if an IPv6 address embeds a private IPv4 via 6to4, NAT64, or Teredo */
function hasPrivateEmbeddedIPv4(ipv6: string): boolean {
if (!ipv6.startsWith('2002:') && !ipv6.startsWith('64:ff9b::') && !ipv6.startsWith('2001::')) {
return false;
}
const segments = ipv6.split(':').filter((s) => s !== '');
if (ipv6.startsWith('2002:') && segments.length >= 3) {
const hi = parseInt(segments[1], 16);
const lo = parseInt(segments[2], 16);
if (!isNaN(hi) && !isNaN(lo)) {
return isPrivateIPv4((hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff);
}
}
if (ipv6.startsWith('64:ff9b::')) {
const lastTwo = segments.slice(-2);
if (lastTwo.length === 2) {
const hi = parseInt(lastTwo[0], 16);
const lo = parseInt(lastTwo[1], 16);
if (!isNaN(hi) && !isNaN(lo)) {
return isPrivateIPv4((hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff);
}
}
}
// RFC 4380: Teredo stores external IPv4 as bitwise complement in last 32 bits
if (ipv6.startsWith('2001::')) {
const lastTwo = segments.slice(-2);
if (lastTwo.length === 2) {
const hi = parseInt(lastTwo[0], 16);
const lo = parseInt(lastTwo[1], 16);
if (!isNaN(hi) && !isNaN(lo)) {
return isPrivateIPv4((~hi >> 8) & 0xff, ~hi & 0xff, (~lo >> 8) & 0xff);
}
}
}
return false;
}
/**
* Checks if an IP address belongs to a private, reserved, or link-local range.
* Handles IPv4, IPv6, and IPv4-mapped IPv6 addresses (::ffff:A.B.C.D).
* 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`.
*
* The normalization and scoping rules live in `./allowedAddresses` so the
* connect-time DNS lookup in `agent.ts` and this preflight helper share a
* single implementation. See that module for the security invariants.
*/
export function isPrivateIP(ip: string): boolean {
const normalized = ip
.toLowerCase()
.trim()
.replace(/^\[|\]$/g, '');
const mappedMatch = normalized.match(/^::ffff:(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (mappedMatch) {
const [, a, b, c] = mappedMatch.map(Number);
return isPrivateIPv4(a, b, c);
}
const hexMappedMatch = normalized.match(/^(?:::ffff:|::)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
if (hexMappedMatch) {
const hi = parseInt(hexMappedMatch[1], 16);
const lo = parseInt(hexMappedMatch[2], 16);
return isPrivateIPv4((hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff);
}
const ipv4Match = normalized.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (ipv4Match) {
const [, a, b, c] = ipv4Match.map(Number);
return isPrivateIPv4(a, b, c);
}
if (
normalized === '::1' ||
normalized === '::' ||
normalized.startsWith('fc') || // fc00::/7 — exactly prefixes 'fc' and 'fd'
normalized.startsWith('fd') ||
isIPv6LinkLocal(normalized) // fe80::/10 — spans 0xfe800xfebf; bitwise check required
) {
return true;
}
if (hasPrivateEmbeddedIPv4(normalized)) {
return true;
}
return false;
export function isAddressAllowed(
hostnameOrIP: string,
allowedAddresses?: string[] | null,
): boolean {
const set = normalizeAllowedAddressesSet(allowedAddresses);
return isAddressInAllowedSet(hostnameOrIP, set);
}
/**
@ -165,10 +51,21 @@ export function isPrivateIP(ip: string): boolean {
* Directly validates literal IPv4 and IPv6 addresses without DNS lookup.
* 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.
*/
export async function resolveHostnameSSRF(hostname: string): Promise<boolean> {
export async function resolveHostnameSSRF(
hostname: string,
allowedAddresses?: string[] | null,
): Promise<boolean> {
const normalizedHost = hostname.toLowerCase().trim();
if (isAddressAllowed(normalizedHost, allowedAddresses)) {
return false;
}
if (/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.test(normalizedHost)) {
return isPrivateIP(normalizedHost);
}
@ -180,7 +77,9 @@ export async function resolveHostnameSSRF(hostname: string): Promise<boolean> {
try {
const addresses = await lookup(hostname, { all: true });
return addresses.some((entry) => isPrivateIP(entry.address));
return addresses.some(
(entry) => isPrivateIP(entry.address) && !isAddressAllowed(entry.address, allowedAddresses),
);
} catch {
return false;
}
@ -189,12 +88,22 @@ export async function resolveHostnameSSRF(hostname: string): Promise<boolean> {
/**
* 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.
*
* @param hostname - The hostname or IP to check
* @param allowedAddresses - Optional admin exemption list
* @returns true if the target is blocked (SSRF risk), false if safe
*/
export function isSSRFTarget(hostname: string): boolean {
export function isSSRFTarget(hostname: string, allowedAddresses?: string[] | null): boolean {
const normalizedHost = hostname.toLowerCase().trim();
if (isAddressAllowed(normalizedHost, allowedAddresses)) {
return false;
}
if (
normalizedHost === 'localhost' ||
normalizedHost === 'localhost.localdomain' ||
@ -354,11 +263,14 @@ const MCP_PROTOCOLS: SupportedProtocol[] = ['http:', 'https:', 'ws:', 'wss:'];
* @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
*/
async function isDomainAllowedCore(
domain: string,
allowedDomains: string[] | null | undefined,
supportedProtocols: SupportedProtocol[],
allowedAddresses?: string[] | null,
): Promise<boolean> {
const inputSpec = parseDomainSpec(domain);
if (!inputSpec) {
@ -373,11 +285,11 @@ async function isDomainAllowedCore(
/** If no domain restrictions configured, block SSRF targets but allow all else */
if (!Array.isArray(allowedDomains) || !allowedDomains.length) {
/** SECURITY: Block SSRF-prone targets when no allowlist is configured */
if (isSSRFTarget(inputSpec.hostname)) {
if (isSSRFTarget(inputSpec.hostname, allowedAddresses)) {
return false;
}
/** SECURITY: Resolve hostname and block if it points to a private/reserved IP */
if (await resolveHostnameSSRF(inputSpec.hostname)) {
if (await resolveHostnameSSRF(inputSpec.hostname, allowedAddresses)) {
return false;
}
return true;
@ -428,15 +340,17 @@ 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
*/
export async function isActionDomainAllowed(
domain?: string | null,
allowedDomains?: string[] | null,
allowedAddresses?: string[] | null,
): Promise<boolean> {
if (!domain || typeof domain !== 'string') {
return false;
}
return isDomainAllowedCore(domain, allowedDomains, HTTP_PROTOCOLS);
return isDomainAllowedCore(domain, allowedDomains, HTTP_PROTOCOLS, allowedAddresses);
}
/**
@ -480,6 +394,7 @@ export function extractMCPServerDomain(config: Record<string, unknown>): string
export async function isMCPDomainAllowed(
config: Record<string, unknown>,
allowedDomains?: string[] | null,
allowedAddresses?: string[] | null,
): Promise<boolean> {
const domain = extractMCPServerDomain(config);
const hasAllowlist = Array.isArray(allowedDomains) && allowedDomains.length > 0;
@ -499,7 +414,7 @@ export async function isMCPDomainAllowed(
}
// Use MCP_PROTOCOLS (HTTP/HTTPS/WS/WSS) for MCP server validation
return isDomainAllowedCore(domain, allowedDomains, MCP_PROTOCOLS);
return isDomainAllowedCore(domain, allowedDomains, MCP_PROTOCOLS, allowedAddresses);
}
/**
@ -514,8 +429,22 @@ export async function isMCPDomainAllowed(
* @remarks `parseDomainSpec` normalizes `www.` prefixes, so both the input URL
* and allowedDomains entries starting with `www.` are matched without that prefix.
*/
export function isOAuthUrlAllowed(url: string, allowedDomains?: string[] | null): boolean {
if (!Array.isArray(allowedDomains) || allowedDomains.length === 0) {
export function isOAuthUrlAllowed(
url: string,
allowedDomains?: string[] | null,
allowedAddresses?: string[] | null,
): boolean {
/**
* Require an absolute URL with an explicit scheme. `parseDomainSpec` is
* lenient: it prepends `https://` to schemeless inputs so a value like
* `10.0.0.5/oauth` would otherwise short-circuit the trust-bypass via
* `allowedAddresses` and skip `validateOAuthUrl`'s strict `new URL(url)`
* parse-or-throw check, only to fail later in OAuth discovery with a less
* clear error. Falling through here lets the caller's strict parse run.
*/
try {
new URL(url);
} catch {
return false;
}
@ -524,28 +453,44 @@ export function isOAuthUrlAllowed(url: string, allowedDomains?: string[] | null)
return false;
}
for (const allowedDomain of allowedDomains) {
const allowedSpec = parseDomainSpec(allowedDomain);
if (!allowedSpec) {
continue;
}
if (!hostnameMatches(inputSpec.hostname, allowedSpec)) {
continue;
}
if (allowedSpec.protocol !== null) {
if (inputSpec.protocol === null || inputSpec.protocol !== allowedSpec.protocol) {
/**
* When `allowedDomains` is configured, treat it as the authoritative bound
* on which OAuth URLs may bypass the SSRF/DNS check. `allowedAddresses` is
* an SSRF-private-IP exemption, not a domain allowlist letting it
* short-circuit here would broaden a strict admin-configured OAuth scope
* (e.g. an MCP server could advertise OAuth endpoints at any address the
* admin permitted for an unrelated reason like a self-hosted LLM).
*/
if (Array.isArray(allowedDomains) && allowedDomains.length > 0) {
for (const allowedDomain of allowedDomains) {
const allowedSpec = parseDomainSpec(allowedDomain);
if (!allowedSpec) {
continue;
}
}
if (allowedSpec.explicitPort) {
if (!inputSpec.explicitPort || inputSpec.port !== allowedSpec.port) {
if (!hostnameMatches(inputSpec.hostname, allowedSpec)) {
continue;
}
if (allowedSpec.protocol !== null) {
if (inputSpec.protocol === null || inputSpec.protocol !== allowedSpec.protocol) {
continue;
}
}
if (allowedSpec.explicitPort) {
if (!inputSpec.explicitPort || inputSpec.port !== allowedSpec.port) {
continue;
}
}
return true;
}
return true;
return false;
}
return false;
/**
* No `allowedDomains` configured: the address exemption may permit specific
* private hosts (matches the semantics of `validateOAuthUrl`'s downstream
* SSRF checks, which also consult `allowedAddresses`).
*/
return isAddressAllowed(inputSpec.hostname, allowedAddresses);
}
/** Matches ErrorTypes.INVALID_BASE_URL — string literal avoids build-time dependency on data-provider */
@ -560,13 +505,22 @@ 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.
*
* @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
* at request time. This is an accepted limitation of point-in-time DNS checks.
* @note Fail-open on DNS errors: a resolution failure here implies a failure at request
* time as well, matching {@link resolveHostnameSSRF} semantics.
*/
export async function validateEndpointURL(url: string, endpoint: string): Promise<void> {
export async function validateEndpointURL(
url: string,
endpoint: string,
allowedAddresses?: string[] | null,
): Promise<void> {
let hostname: string;
let protocol: string;
try {
@ -581,11 +535,11 @@ export async function validateEndpointURL(url: string, endpoint: string): Promis
throwInvalidBaseURL(`Invalid base URL for ${endpoint}: only HTTP and HTTPS are permitted.`);
}
if (isSSRFTarget(hostname)) {
if (isSSRFTarget(hostname, allowedAddresses)) {
throwInvalidBaseURL(`Base URL for ${endpoint} targets a restricted address.`);
}
if (await resolveHostnameSSRF(hostname)) {
if (await resolveHostnameSSRF(hostname, allowedAddresses)) {
throwInvalidBaseURL(`Base URL for ${endpoint} resolves to a restricted address.`);
}
}

153
packages/api/src/auth/ip.ts Normal file
View file

@ -0,0 +1,153 @@
/**
* IPv4/IPv6 private-range detection.
*
* Lifted out of `domain.ts` so leaf modules like `allowedAddresses.ts` can
* depend on `isPrivateIP` without forming a cycle (`domain` re-exports the
* `isPrivateIP` symbol below for backward compatibility with existing
* callers, but this is the canonical location).
*
* Coverage:
* - IPv4: 0.0.0.0/8, 10/8, 100.64/10 (CGNAT), 127/8, 169.254/16,
* 172.16/12, 192.0.0/24 (RFC 5736), 192.168/16, 198.18/15 (benchmarking),
* 224/3 (multicast/reserved).
* - IPv6: ::1, ::, fc00::/7 (unique-local), fe80::/10 (link-local).
* - 4-in-6 mappings: ::ffff:A.B.C.D and the hex form ::ffff:HHHH:HHHH.
* - Embedded private IPv4 in 6to4 (2002::/16), NAT64 (64:ff9b::/96), and
* Teredo (2001::/32) addresses.
*/
/** Checks if IPv4 octets fall within private, reserved, or non-routable ranges */
export function isPrivateIPv4(a: number, b: number, c: number): boolean {
if (a === 0) {
return true;
}
if (a === 10) {
return true;
}
if (a === 127) {
return true;
}
if (a === 100 && b >= 64 && b <= 127) {
return true;
}
if (a === 169 && b === 254) {
return true;
}
if (a === 172 && b >= 16 && b <= 31) {
return true;
}
if (a === 192 && b === 168) {
return true;
}
if (a === 192 && b === 0 && c === 0) {
return true;
}
if (a === 198 && (b === 18 || b === 19)) {
return true;
}
if (a >= 224) {
return true;
}
return false;
}
/** Checks if a pre-normalized (lowercase, bracket-stripped) IPv6 address falls within fe80::/10 */
function isIPv6LinkLocal(ipv6: string): boolean {
if (!ipv6.includes(':')) {
return false;
}
const firstHextet = ipv6.split(':', 1)[0];
if (!firstHextet || !/^[0-9a-f]{1,4}$/.test(firstHextet)) {
return false;
}
const hextet = parseInt(firstHextet, 16);
// /10 mask (0xffc0) preserves top 10 bits: fe80 = 1111_1110_10xx_xxxx
return (hextet & 0xffc0) === 0xfe80;
}
/** Checks if an IPv6 address embeds a private IPv4 via 6to4, NAT64, or Teredo */
function hasPrivateEmbeddedIPv4(ipv6: string): boolean {
if (!ipv6.startsWith('2002:') && !ipv6.startsWith('64:ff9b::') && !ipv6.startsWith('2001::')) {
return false;
}
const segments = ipv6.split(':').filter((s) => s !== '');
if (ipv6.startsWith('2002:') && segments.length >= 3) {
const hi = parseInt(segments[1], 16);
const lo = parseInt(segments[2], 16);
if (!isNaN(hi) && !isNaN(lo)) {
return isPrivateIPv4((hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff);
}
}
if (ipv6.startsWith('64:ff9b::')) {
const lastTwo = segments.slice(-2);
if (lastTwo.length === 2) {
const hi = parseInt(lastTwo[0], 16);
const lo = parseInt(lastTwo[1], 16);
if (!isNaN(hi) && !isNaN(lo)) {
return isPrivateIPv4((hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff);
}
}
}
// RFC 4380: Teredo stores external IPv4 as bitwise complement in last 32 bits
if (ipv6.startsWith('2001::')) {
const lastTwo = segments.slice(-2);
if (lastTwo.length === 2) {
const hi = parseInt(lastTwo[0], 16);
const lo = parseInt(lastTwo[1], 16);
if (!isNaN(hi) && !isNaN(lo)) {
return isPrivateIPv4((~hi >> 8) & 0xff, ~hi & 0xff, (~lo >> 8) & 0xff);
}
}
}
return false;
}
/**
* Checks if an IP address belongs to a private, reserved, or link-local range.
* Handles IPv4, IPv6, and IPv4-mapped IPv6 addresses (::ffff:A.B.C.D).
*/
export function isPrivateIP(ip: string): boolean {
const normalized = ip
.toLowerCase()
.trim()
.replace(/^\[|\]$/g, '');
const mappedMatch = normalized.match(/^::ffff:(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (mappedMatch) {
const [, a, b, c] = mappedMatch.map(Number);
return isPrivateIPv4(a, b, c);
}
const hexMappedMatch = normalized.match(/^(?:::ffff:|::)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
if (hexMappedMatch) {
const hi = parseInt(hexMappedMatch[1], 16);
const lo = parseInt(hexMappedMatch[2], 16);
return isPrivateIPv4((hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff);
}
const ipv4Match = normalized.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (ipv4Match) {
const [, a, b, c] = ipv4Match.map(Number);
return isPrivateIPv4(a, b, c);
}
if (
normalized === '::1' ||
normalized === '::' ||
normalized.startsWith('fc') || // fc00::/7 — exactly prefixes 'fc' and 'fd'
normalized.startsWith('fd') ||
isIPv6LinkLocal(normalized) // fe80::/10 — spans 0xfe800xfebf; bitwise check required
) {
return true;
}
if (hasPrivateEmbeddedIPv4(normalized)) {
return true;
}
return false;
}

View file

@ -179,6 +179,7 @@ describe('initializeCustom SSRF guard wiring', () => {
expect(mockValidateEndpointURL).toHaveBeenCalledWith(
'https://user-api.example.com/v1',
'test-custom',
undefined,
);
});

View file

@ -128,7 +128,7 @@ export async function initializeCustom({
}
if (userProvidesURL) {
await validateEndpointURL(baseURL, endpoint);
await validateEndpointURL(baseURL, endpoint, appConfig?.endpoints?.allowedAddresses);
}
let endpointTokenConfig: EndpointTokenConfig | undefined;

View file

@ -82,6 +82,7 @@ describe('initializeOpenAI SSRF guard wiring', () => {
expect(mockValidateEndpointURL).toHaveBeenCalledWith(
'https://user-proxy.example.com/v1',
EModelEndpoint.openAI,
undefined,
);
});

View file

@ -57,7 +57,7 @@ export async function initializeOpenAI({
: baseURLOptions[endpoint as keyof typeof baseURLOptions];
if (userProvidesURL && baseURL) {
await validateEndpointURL(baseURL, endpoint);
await validateEndpointURL(baseURL, endpoint, appConfig?.endpoints?.allowedAddresses);
}
const clientOptions: OpenAIConfigOptions = {

View file

@ -85,6 +85,7 @@ export class ConnectionsRepository {
dbSourced: isUserSourced(serverConfig as t.ParsedServerConfig),
useSSRFProtection: registry.shouldEnableSSRFProtection(),
allowedDomains: registry.getAllowedDomains(),
allowedAddresses: registry.getAllowedAddresses(),
},
this.oauthOpts,
);

View file

@ -31,6 +31,7 @@ export class MCPConnectionFactory {
protected readonly useOAuth: boolean;
protected readonly useSSRFProtection: boolean;
protected readonly allowedDomains?: string[] | null;
protected readonly allowedAddresses?: string[] | null;
// OAuth-related properties (only set when useOAuth is true)
protected readonly userId?: string;
@ -79,6 +80,7 @@ export class MCPConnectionFactory {
userId: this.userId,
oauthTokens,
useSSRFProtection: this.useSSRFProtection,
allowedAddresses: this.allowedAddresses,
});
const oauthHandler = () => {
@ -151,6 +153,7 @@ export class MCPConnectionFactory {
userId: this.userId,
oauthTokens: null,
useSSRFProtection: this.useSSRFProtection,
allowedAddresses: this.allowedAddresses,
});
unauthConnection.on('oauthRequired', () => {
@ -199,6 +202,7 @@ export class MCPConnectionFactory {
this.serverName = basic.serverName;
this.useSSRFProtection = basic.useSSRFProtection === true;
this.allowedDomains = basic.allowedDomains;
this.allowedAddresses = basic.allowedAddresses;
this.connectionTimeout = options?.connectionTimeout;
this.logPrefix = options?.user
? `[MCP][${basic.serverName}][${options.user.id}]`
@ -227,6 +231,7 @@ export class MCPConnectionFactory {
userId: this.userId,
oauthTokens,
useSSRFProtection: this.useSSRFProtection,
allowedAddresses: this.allowedAddresses,
});
let cleanupOAuthHandlers: (() => void) | null = null;
@ -308,6 +313,7 @@ export class MCPConnectionFactory {
this.serverConfig.oauth_headers ?? {},
this.serverConfig.oauth,
this.allowedDomains,
this.allowedAddresses,
);
};
}
@ -354,6 +360,7 @@ export class MCPConnectionFactory {
this.allowedDomains,
// Only reuse stored client when deleteTokens is available for stale-client cleanup
this.tokenMethods?.deleteTokens ? this.tokenMethods.findToken : undefined,
this.allowedAddresses,
);
if (existingFlow) {
@ -665,6 +672,7 @@ export class MCPConnectionFactory {
this.serverConfig.oauth,
this.allowedDomains,
this.tokenMethods?.deleteTokens ? this.tokenMethods.findToken : undefined,
this.allowedAddresses,
);
reusedStoredClient = flowMetadata.reusedStoredClient === true;

View file

@ -107,6 +107,7 @@ export class MCPManager extends UserConnectionManager {
const registry = MCPServersRegistry.getInstance();
const useSSRFProtection = registry.shouldEnableSSRFProtection();
const allowedDomains = registry.getAllowedDomains();
const allowedAddresses = registry.getAllowedAddresses();
const dbSourced = isUserSourced(serverConfig);
const basic: t.BasicConnectionOptions = {
dbSourced,
@ -114,6 +115,7 @@ export class MCPManager extends UserConnectionManager {
serverConfig,
useSSRFProtection,
allowedDomains,
allowedAddresses,
};
if (!useOAuth) {

View file

@ -168,6 +168,7 @@ export abstract class UserConnectionManager {
dbSourced: isUserSourced(config),
useSSRFProtection: registry.shouldEnableSSRFProtection(),
allowedDomains: registry.getAllowedDomains(),
allowedAddresses: registry.getAllowedAddresses(),
},
{
useOAuth: true,

View file

@ -26,6 +26,7 @@ const mockRegistryInstance = {
getAllServerConfigs: jest.fn(),
shouldEnableSSRFProtection: jest.fn().mockReturnValue(false),
getAllowedDomains: jest.fn().mockReturnValue(null),
getAllowedAddresses: jest.fn().mockReturnValue(null),
};
jest.mock('../registry/MCPServersRegistry', () => ({
@ -112,6 +113,7 @@ describe('ConnectionsRepository', () => {
serverConfig: mockServerConfigs.server1,
useSSRFProtection: false,
allowedDomains: null,
allowedAddresses: null,
dbSourced: false,
},
undefined,
@ -136,6 +138,7 @@ describe('ConnectionsRepository', () => {
serverConfig: mockServerConfigs.server1,
useSSRFProtection: false,
allowedDomains: null,
allowedAddresses: null,
dbSourced: false,
},
undefined,
@ -177,6 +180,7 @@ describe('ConnectionsRepository', () => {
serverConfig: configWithCachedAt,
useSSRFProtection: false,
allowedDomains: null,
allowedAddresses: null,
dbSourced: false,
},
undefined,

View file

@ -1,4 +1,4 @@
import { logger } from '@librechat/data-schemas';
import { logger, getTenantId } from '@librechat/data-schemas';
import type { TokenMethods, IUser } from '@librechat/data-schemas';
import type { FlowStateManager } from '~/flow/manager';
import type { MCPOAuthTokens } from '~/mcp/oauth';
@ -242,7 +242,6 @@ describe('MCPConnectionFactory', () => {
},
};
const { getTenantId } = require('@librechat/data-schemas');
(getTenantId as jest.Mock).mockReturnValue('test-tenant');
mockMCPOAuthHandler.initiateOAuthFlow.mockResolvedValue(mockFlowData);
@ -276,6 +275,7 @@ describe('MCPConnectionFactory', () => {
undefined,
undefined,
oauthOptions.tokenMethods.findToken,
undefined,
);
// initFlow must be awaited BEFORE the redirect to guarantee state is stored

View file

@ -1033,6 +1033,7 @@ describe('MCP SSRF protection WebSocket DNS resolution', () => {
await expect(conn.connect()).rejects.toThrow(/SSRF protection/);
expect(mockedResolveHostnameSSRF).toHaveBeenCalledWith(
expect.stringContaining('evil.example.com'),
null,
);
});
@ -1048,6 +1049,7 @@ describe('MCP SSRF protection WebSocket DNS resolution', () => {
await expect(conn.connect()).rejects.toThrow(/SSRF protection/);
expect(mockedResolveHostnameSSRF).toHaveBeenCalledWith(
expect.stringContaining('allowlisted.example.com'),
null,
);
});

View file

@ -35,6 +35,7 @@ const mockRegistryInstance = {
getOAuthServers: jest.fn(),
shouldEnableSSRFProtection: jest.fn().mockReturnValue(false),
getAllowedDomains: jest.fn().mockReturnValue(null),
getAllowedAddresses: jest.fn().mockReturnValue(null),
};
jest.mock('~/mcp/registry/MCPServersRegistry', () => ({

View file

@ -85,6 +85,7 @@ describe('MCP OAuth Race Condition Fixes', () => {
getServerConfig: jest.fn().mockResolvedValue(mockConfig),
shouldEnableSSRFProtection: jest.fn().mockReturnValue(false),
getAllowedDomains: jest.fn().mockReturnValue(null),
getAllowedAddresses: jest.fn().mockReturnValue(null),
});
const { MCPConnectionFactory } = await import('~/mcp/MCPConnectionFactory');
@ -151,6 +152,7 @@ describe('MCP OAuth Race Condition Fixes', () => {
getServerConfig: jest.fn().mockResolvedValue(mockConfig),
shouldEnableSSRFProtection: jest.fn().mockReturnValue(false),
getAllowedDomains: jest.fn().mockReturnValue(null),
getAllowedAddresses: jest.fn().mockReturnValue(null),
});
const { MCPConnectionFactory } = await import('~/mcp/MCPConnectionFactory');

View file

@ -390,6 +390,7 @@ interface MCPConnectionParams {
userId?: string;
oauthTokens?: MCPOAuthTokens | null;
useSSRFProtection?: boolean;
allowedAddresses?: string[] | null;
}
export class MCPConnection extends EventEmitter {
@ -413,6 +414,7 @@ export class MCPConnection extends EventEmitter {
private oauthRequired = false;
private oauthRecovery = false;
private readonly useSSRFProtection: boolean;
private readonly allowedAddresses?: string[] | null;
iconPath?: string;
timeout?: number;
sseReadTimeout?: number;
@ -527,6 +529,7 @@ export class MCPConnection extends EventEmitter {
this.serverName = params.serverName;
this.userId = params.userId;
this.useSSRFProtection = params.useSSRFProtection === true;
this.allowedAddresses = params.allowedAddresses ?? null;
this.iconPath = params.serverConfig.iconPath;
this.timeout = params.serverConfig.timeout;
this.sseReadTimeout = params.serverConfig.sseReadTimeout;
@ -568,7 +571,9 @@ export class MCPConnection extends EventEmitter {
sseBodyTimeout?: number,
configuredSecretHeaderKeys?: ReadonlySet<string>,
): (input: UndiciRequestInfo, init?: UndiciRequestInit) => Promise<UndiciResponse> {
const ssrfConnect = this.useSSRFProtection ? createSSRFSafeUndiciConnect() : undefined;
const ssrfConnect = this.useSSRFProtection
? createSSRFSafeUndiciConnect(this.allowedAddresses)
: undefined;
const connectOpts = ssrfConnect != null ? { connect: ssrfConnect } : {};
/** Capture only the fields needed by the fetch closure; see factory note above. */
const useSSRFProtection = this.useSSRFProtection;
@ -672,6 +677,14 @@ export class MCPConnection extends EventEmitter {
* Cross-origin allowlist redirects also switch to a connect-time
* SSRF-safe dispatcher below so DNS rebinding cannot change the
* address between this check and the socket connection.
*
* `allowedAddresses` is intentionally NOT consulted on either layer:
* redirect targets are server-controlled (the MCP server's response
* chooses where to send us), so they must not inherit the admin's
* exemption for the originally-configured URL. A legitimate self-
* redirect from a permitted private host is still blocked here, by
* design letting redirect targets inherit the exemption would open
* an SSRF amplification primitive.
*/
if (await resolveHostnameSSRF(targetUrl.hostname)) {
logger.warn(
@ -761,7 +774,7 @@ export class MCPConnection extends EventEmitter {
* only a URL with no custom DNS lookup hook.
*/
const wsHostname = new URL(options.url).hostname;
const isSSRF = await resolveHostnameSSRF(wsHostname);
const isSSRF = await resolveHostnameSSRF(wsHostname, this.allowedAddresses);
if (isSSRF) {
throw new Error(
`SSRF protection: WebSocket host "${wsHostname}" resolved to a private/reserved IP address`,
@ -792,7 +805,9 @@ export class MCPConnection extends EventEmitter {
* The connect timeout is extended because proxies may delay initial response.
*/
const sseTimeout = this.timeout || SSE_CONNECT_TIMEOUT;
const ssrfConnect = this.useSSRFProtection ? createSSRFSafeUndiciConnect() : undefined;
const ssrfConnect = this.useSSRFProtection
? createSSRFSafeUndiciConnect(this.allowedAddresses)
: undefined;
const sseAgent = new Agent({
bodyTimeout: sseTimeout,
headersTimeout: sseTimeout,

View file

@ -0,0 +1,153 @@
/**
* Integration tests for MCPOAuthHandler.validateOAuthUrl interaction with the
* `allowedAddresses` exemption list.
*
* The unit tests in `auth/domain.spec.ts` cover `isOAuthUrlAllowed` (the
* trust-bypass shortcut) and the individual SSRF helpers, but they do NOT
* cover the SSRF fallback inside `validateOAuthUrl` the path taken when
* `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
* `allowedDomains` whitelist for OAuth.
*/
jest.mock('node:dns/promises', () => ({
lookup: jest.fn(),
}));
import { lookup } from 'node:dns/promises';
import { MCPOAuthHandler } from './handler';
const mockedLookup = lookup as jest.MockedFunction<typeof lookup>;
type ValidateOAuthUrl = (
url: string,
fieldName: string,
allowedDomains?: string[] | null,
allowedAddresses?: string[] | null,
) => Promise<void>;
// `validateOAuthUrl` is `private static`. TypeScript private is compile-time
// only; at runtime we reach the method directly to exercise its behavior in
// isolation, which is the only place the SSRF fallback bypass would surface.
const validateOAuthUrl = (
MCPOAuthHandler as unknown as { validateOAuthUrl: ValidateOAuthUrl }
).validateOAuthUrl.bind(MCPOAuthHandler);
describe('MCPOAuthHandler.validateOAuthUrl — allowedAddresses scoping', () => {
afterEach(() => {
// resetAllMocks (not clearAllMocks) flushes mockResolvedValueOnce queues
// so leftover values from a test that short-circuited before DNS don't
// pollute the next test.
jest.resetAllMocks();
});
describe('without allowedDomains configured', () => {
it('permits a private OAuth URL when its hostname 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']),
).resolves.toBeUndefined();
});
it('permits a hostname URL whose DNS resolves to an allowedAddresses IP', async () => {
mockedLookup.mockResolvedValueOnce([{ address: '10.0.0.5', family: 4 }] as never);
// Use a hostname that isn't on the magic-internal-hostnames list and
// doesn't end with .internal/.local — those are blocked by isSSRFTarget
// 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']),
).resolves.toBeUndefined();
});
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']),
).rejects.toThrow('targets a blocked address');
});
});
describe('with allowedDomains configured (strict bound)', () => {
it('permits a URL that matches the allowedDomains whitelist', async () => {
mockedLookup.mockResolvedValueOnce([{ address: '93.184.216.34', family: 4 }] as never);
await expect(
validateOAuthUrl(
'https://oauth.trusted.com/token',
'token_endpoint',
['oauth.trusted.com'],
null,
),
).resolves.toBeUndefined();
});
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']`
// 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
// whitelist.
await expect(
validateOAuthUrl(
'http://127.0.0.1/oauth',
'token_endpoint',
['oauth.trusted.com'],
['127.0.0.1'],
),
).rejects.toThrow();
});
it('rejects a hostname URL whose DNS resolves to an allowedAddresses IP when allowedDomains is set', async () => {
mockedLookup.mockResolvedValueOnce([{ address: '10.0.0.5', family: 4 }] as never);
await expect(
validateOAuthUrl(
'https://attacker.example.com/oauth',
'token_endpoint',
['oauth.trusted.com'],
['10.0.0.5'],
),
).rejects.toThrow('resolves to a private IP address');
});
it('rejects a non-matching public URL with no SSRF concern', async () => {
mockedLookup.mockResolvedValueOnce([{ address: '93.184.216.34', family: 4 }] as never);
// A public domain that isn't in the whitelist still passes the SSRF
// fallback (it's not a private target). This documents the existing
// "allowedDomains as trust-bypass" semantics — the bypass-prevention
// test above is the security-critical one.
await expect(
validateOAuthUrl(
'https://other.public.com/oauth',
'token_endpoint',
['oauth.trusted.com'],
null,
),
).resolves.toBeUndefined();
});
});
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
// 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']),
).resolves.toBeUndefined();
});
it('rejects schemeless OAuth URLs with the strict parse-or-throw error', async () => {
// `parseDomainSpec` accepts schemeless inputs by prepending `https://`,
// so a malformed value like `10.0.0.5/oauth` could otherwise short-
// circuit the address-exemption path and skip `validateOAuthUrl`'s
// 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']),
).rejects.toThrow(/Invalid OAuth/);
});
});
});

View file

@ -131,6 +131,7 @@ export class MCPOAuthHandler {
serverUrl: string,
oauthHeaders: Record<string, string>,
allowedDomains?: string[] | null,
allowedAddresses?: string[] | null,
): Promise<{
metadata: OAuthMetadata;
resourceMetadata?: OAuthProtectedResourceMetadata;
@ -171,6 +172,7 @@ export class MCPOAuthHandler {
hint.resourceMetadataUrl.toString(),
'resource_metadata',
allowedDomains,
allowedAddresses,
);
hintUrl = hint.resourceMetadataUrl;
logger.debug(
@ -213,7 +215,12 @@ export class MCPOAuthHandler {
if (resourceMetadata.authorization_servers?.length) {
const discoveredAuthServer = resourceMetadata.authorization_servers[0];
await this.validateOAuthUrl(discoveredAuthServer, 'authorization_server', allowedDomains);
await this.validateOAuthUrl(
discoveredAuthServer,
'authorization_server',
allowedDomains,
allowedAddresses,
);
authServerUrl = new URL(discoveredAuthServer);
logger.debug(
`[MCPOAuth] Found authorization server from resource metadata: ${authServerUrl}`,
@ -273,12 +280,18 @@ export class MCPOAuthHandler {
metadata.registration_endpoint,
'registration_endpoint',
allowedDomains,
allowedAddresses,
),
);
}
if (metadata.token_endpoint) {
endpointChecks.push(
this.validateOAuthUrl(metadata.token_endpoint, 'token_endpoint', allowedDomains),
this.validateOAuthUrl(
metadata.token_endpoint,
'token_endpoint',
allowedDomains,
allowedAddresses,
),
);
}
if (endpointChecks.length > 0) {
@ -431,6 +444,7 @@ export class MCPOAuthHandler {
config?: MCPOptions['oauth'],
allowedDomains?: string[] | null,
findToken?: TokenMethods['findToken'],
allowedAddresses?: string[] | null,
): Promise<{ authorizationUrl: string; flowId: string; flowMetadata: MCPOAuthFlowMetadata }> {
logger.debug(
`[MCPOAuth] initiateOAuthFlow called for ${serverName} with URL: ${sanitizeUrlForLogging(serverUrl)}`,
@ -446,8 +460,13 @@ export class MCPOAuthHandler {
logger.debug(`[MCPOAuth] Using pre-configured OAuth settings for ${serverName}`);
await Promise.all([
this.validateOAuthUrl(config.authorization_url, 'authorization_url', allowedDomains),
this.validateOAuthUrl(config.token_url, 'token_url', allowedDomains),
this.validateOAuthUrl(
config.authorization_url,
'authorization_url',
allowedDomains,
allowedAddresses,
),
this.validateOAuthUrl(config.token_url, 'token_url', allowedDomains, allowedAddresses),
]);
const skipCodeChallengeCheck =
@ -550,6 +569,7 @@ export class MCPOAuthHandler {
serverUrl,
oauthHeaders,
allowedDomains,
allowedAddresses,
);
logger.debug(
@ -866,13 +886,23 @@ export class MCPOAuthHandler {
* Validates an OAuth URL is not targeting a private/internal address.
* Skipped when the full URL (hostname + protocol + port) matches an admin-trusted
* allowedDomains entry, honoring protocol/port constraints when the admin specifies them.
*
* SECURITY: when `allowedDomains` is configured and the URL did NOT match it
* (i.e., we are in the SSRF fallback path because the admin's strict bound
* rejected this URL), the address exemption must NOT broaden that bound a
* malicious MCP server could otherwise advertise OAuth metadata at any
* private address the admin permitted for an unrelated purpose (e.g. a
* self-hosted LLM). When `allowedDomains` is empty, `allowedAddresses` is
* the only opt-in mechanism for permitting private OAuth endpoints, so it
* is consulted normally.
*/
private static async validateOAuthUrl(
url: string,
fieldName: string,
allowedDomains?: string[] | null,
allowedAddresses?: string[] | null,
): Promise<void> {
if (isOAuthUrlAllowed(url, allowedDomains)) {
if (isOAuthUrlAllowed(url, allowedDomains, allowedAddresses)) {
return;
}
@ -883,11 +913,14 @@ export class MCPOAuthHandler {
throw new Error(`Invalid OAuth ${fieldName}: ${sanitizeUrlForLogging(url)}`);
}
if (isSSRFTarget(hostname)) {
const allowedDomainsActive = Array.isArray(allowedDomains) && allowedDomains.length > 0;
const effectiveAddresses = allowedDomainsActive ? null : allowedAddresses;
if (isSSRFTarget(hostname, effectiveAddresses)) {
throw new Error(`OAuth ${fieldName} targets a blocked address`);
}
if (await resolveHostnameSSRF(hostname)) {
if (await resolveHostnameSSRF(hostname, effectiveAddresses)) {
throw new Error(`OAuth ${fieldName} resolves to a private IP address`);
}
}
@ -988,6 +1021,7 @@ export class MCPOAuthHandler {
oauthHeaders: Record<string, string>,
config?: MCPOptions['oauth'],
allowedDomains?: string[] | null,
allowedAddresses?: string[] | null,
): Promise<MCPOAuthTokens> {
logger.debug(`[MCPOAuth] Refreshing tokens for ${metadata.serverName}`);
@ -1013,7 +1047,12 @@ export class MCPOAuthHandler {
let tokenUrl: string;
let authMethods: string[] | undefined;
if (config?.token_url) {
await this.validateOAuthUrl(config.token_url, 'token_url', allowedDomains);
await this.validateOAuthUrl(
config.token_url,
'token_url',
allowedDomains,
allowedAddresses,
);
tokenUrl = config.token_url;
authMethods = config.token_endpoint_auth_methods_supported;
} else if (!metadata.serverUrl) {
@ -1043,7 +1082,7 @@ export class MCPOAuthHandler {
tokenUrl = oauthMetadata.token_endpoint;
authMethods = oauthMetadata.token_endpoint_auth_methods_supported;
}
await this.validateOAuthUrl(tokenUrl, 'token_url', allowedDomains);
await this.validateOAuthUrl(tokenUrl, 'token_url', allowedDomains, allowedAddresses);
}
const body = new URLSearchParams({
@ -1120,7 +1159,12 @@ export class MCPOAuthHandler {
if (config?.token_url && config?.client_id) {
logger.debug(`[MCPOAuth] Using pre-configured OAuth settings for token refresh`);
await this.validateOAuthUrl(config.token_url, 'token_url', allowedDomains);
await this.validateOAuthUrl(
config.token_url,
'token_url',
allowedDomains,
allowedAddresses,
);
const tokenUrl = new URL(config.token_url);
const body = new URLSearchParams({
@ -1219,7 +1263,7 @@ export class MCPOAuthHandler {
} else {
tokenUrl = new URL(oauthMetadata.token_endpoint);
}
await this.validateOAuthUrl(tokenUrl.href, 'token_url', allowedDomains);
await this.validateOAuthUrl(tokenUrl.href, 'token_url', allowedDomains, allowedAddresses);
const body = new URLSearchParams({
grant_type: 'refresh_token',
@ -1269,12 +1313,18 @@ export class MCPOAuthHandler {
},
oauthHeaders: Record<string, string> = {},
allowedDomains?: string[] | null,
allowedAddresses?: string[] | null,
): Promise<void> {
const revokeUrl: URL =
metadata.revocationEndpoint != null
? new URL(metadata.revocationEndpoint)
: new URL('/revoke', metadata.serverUrl);
await this.validateOAuthUrl(revokeUrl.href, 'revocation_endpoint', allowedDomains);
await this.validateOAuthUrl(
revokeUrl.href,
'revocation_endpoint',
allowedDomains,
allowedAddresses,
);
const authMethods = metadata.revocationEndpointAuthMethodsSupported ?? ['client_secret_basic'];
const authMethod = resolveTokenEndpointAuthMethod({ tokenAuthMethods: authMethods });

View file

@ -21,6 +21,7 @@ export class MCPServerInspector {
private connection: MCPConnection | undefined,
private readonly useSSRFProtection: boolean = false,
private readonly allowedDomains?: string[] | null,
private readonly allowedAddresses?: string[] | null,
) {}
/**
@ -37,9 +38,10 @@ export class MCPServerInspector {
rawConfig: t.MCPOptions,
connection?: MCPConnection,
allowedDomains?: string[] | null,
allowedAddresses?: string[] | null,
): Promise<t.ParsedServerConfig> {
// Validate domain against allowlist BEFORE attempting connection
const isDomainAllowed = await isMCPDomainAllowed(rawConfig, allowedDomains);
const isDomainAllowed = await isMCPDomainAllowed(rawConfig, allowedDomains, allowedAddresses);
if (!isDomainAllowed) {
const domain = extractMCPServerDomain(rawConfig);
throw new MCPDomainNotAllowedError(domain ?? 'unknown');
@ -53,6 +55,7 @@ export class MCPServerInspector {
connection,
useSSRFProtection,
allowedDomains,
allowedAddresses,
);
await inspector.inspectServer();
inspector.config.initDuration = Date.now() - start;
@ -76,6 +79,7 @@ export class MCPServerInspector {
dbSourced: isUserSourced(this.config),
useSSRFProtection: this.useSSRFProtection,
allowedDomains: this.allowedDomains,
allowedAddresses: this.allowedAddresses,
});
}

View file

@ -44,6 +44,7 @@ export class MCPServersRegistry {
private readonly cacheConfigsRepo: IServerConfigsRepositoryInterface;
private readonly configCacheRepo: IServerConfigsRepositoryInterface;
private readonly allowedDomains?: string[] | null;
private readonly allowedAddresses?: string[] | null;
private readonly readThroughCache: Keyv<t.ParsedServerConfig>;
private readonly readThroughCacheAll: Keyv<Record<string, t.ParsedServerConfig>>;
private readonly pendingGetAllPromises = new Map<
@ -61,11 +62,16 @@ export class MCPServersRegistry {
private yamlServerNames: Set<string> | null = null;
private yamlServerNamesPromise: Promise<Set<string>> | null = null;
constructor(mongoose: typeof import('mongoose'), allowedDomains?: string[] | null) {
constructor(
mongoose: typeof import('mongoose'),
allowedDomains?: string[] | null,
allowedAddresses?: string[] | null,
) {
this.dbConfigsRepo = new ServerConfigsDB(mongoose);
this.cacheConfigsRepo = ServerConfigsCacheFactory.create(APP_CACHE_NAMESPACE, false);
this.configCacheRepo = ServerConfigsCacheFactory.create(CONFIG_CACHE_NAMESPACE, false);
this.allowedDomains = allowedDomains;
this.allowedAddresses = allowedAddresses;
const ttl = cacheConfig.MCP_REGISTRY_CACHE_TTL;
@ -84,6 +90,7 @@ export class MCPServersRegistry {
public static createInstance(
mongoose: typeof import('mongoose'),
allowedDomains?: string[] | null,
allowedAddresses?: string[] | null,
): MCPServersRegistry {
if (!mongoose) {
throw new Error(
@ -96,7 +103,11 @@ export class MCPServersRegistry {
return MCPServersRegistry.instance;
}
logger.info('[MCPServersRegistry] Creating new instance');
MCPServersRegistry.instance = new MCPServersRegistry(mongoose, allowedDomains);
MCPServersRegistry.instance = new MCPServersRegistry(
mongoose,
allowedDomains,
allowedAddresses,
);
return MCPServersRegistry.instance;
}
@ -112,6 +123,10 @@ export class MCPServersRegistry {
return this.allowedDomains;
}
public getAllowedAddresses(): string[] | null | undefined {
return this.allowedAddresses;
}
/** Returns true when no explicit allowedDomains allowlist is configured, enabling SSRF TOCTOU protection */
public shouldEnableSSRFProtection(): boolean {
return !Array.isArray(this.allowedDomains) || this.allowedDomains.length === 0;
@ -238,6 +253,7 @@ export class MCPServersRegistry {
config,
undefined,
this.allowedDomains,
this.allowedAddresses,
);
} catch (error) {
logger.error(`[MCPServersRegistry] Failed to inspect server "${serverName}":`, error);
@ -281,6 +297,7 @@ export class MCPServersRegistry {
configForInspection,
undefined,
this.allowedDomains,
this.allowedAddresses,
);
} catch (error) {
logger.error(`[MCPServersRegistry] Reinspection failed for server "${serverName}":`, error);
@ -329,6 +346,7 @@ export class MCPServersRegistry {
configForInspection,
undefined,
this.allowedDomains,
this.allowedAddresses,
);
} catch (error) {
logger.error(`[MCPServersRegistry] Failed to inspect server "${serverName}":`, error);
@ -435,7 +453,13 @@ export class MCPServersRegistry {
try {
const inspected = await withTimeout(
MCPServerInspector.inspect(serverName, rawConfig, undefined, this.allowedDomains),
MCPServerInspector.inspect(
serverName,
rawConfig,
undefined,
this.allowedDomains,
this.allowedAddresses,
),
CONFIG_SERVER_INIT_TIMEOUT_MS,
`${prefix} Server initialization timed out`,
);

View file

@ -225,37 +225,42 @@ describe('MCPServersInitializer', () => {
await MCPServersInitializer.initialize(testConfigs);
// Verify all configs were processed by inspector
// Signature: inspect(serverName, rawConfig, connection?, allowedDomains?)
// Signature: inspect(serverName, rawConfig, connection?, allowedDomains?, allowedAddresses?)
expect(mockInspect).toHaveBeenCalledTimes(5);
expect(mockInspect).toHaveBeenCalledWith(
'disabled_server',
testConfigs.disabled_server,
undefined,
undefined,
undefined,
);
expect(mockInspect).toHaveBeenCalledWith(
'oauth_server',
testConfigs.oauth_server,
undefined,
undefined,
undefined,
);
expect(mockInspect).toHaveBeenCalledWith(
'file_tools_server',
testConfigs.file_tools_server,
undefined,
undefined,
undefined,
);
expect(mockInspect).toHaveBeenCalledWith(
'search_tools_server',
testConfigs.search_tools_server,
undefined,
undefined,
undefined,
);
expect(mockInspect).toHaveBeenCalledWith(
'remote_no_oauth_server',
testConfigs.remote_no_oauth_server,
undefined,
undefined,
undefined,
);
});

View file

@ -123,7 +123,13 @@ describe('MCPServersRegistry — ensureConfigServers', () => {
expect(result).toHaveProperty('my_server');
expect(result.my_server.source).toBe('config');
expect(inspectSpy).toHaveBeenCalledTimes(1);
expect(inspectSpy).toHaveBeenCalledWith('my_server', sseConfig, undefined, undefined);
expect(inspectSpy).toHaveBeenCalledWith(
'my_server',
sseConfig,
undefined,
undefined,
undefined,
);
});
it('should return cached result on second call without re-inspecting', async () => {

View file

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

View file

@ -1,7 +1,7 @@
import type { TEndpointsConfig } from './types';
import { EModelEndpoint, isDocumentSupportedProvider } from './schemas';
import { getEndpointFileConfig, mergeFileConfig } from './file-config';
import { resolveEndpointType, excludedKeys } from './config';
import { allowedAddressesSchema, configSchema, resolveEndpointType, excludedKeys } from './config';
const endpointsConfig: TEndpointsConfig = {
[EModelEndpoint.openAI]: { userProvide: false, order: 0 },
@ -323,3 +323,134 @@ 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'],
])('accepts "%s" (%s)', (entry) => {
expect(allowedAddressesSchema.parse([entry])).toEqual([entry]);
});
it('accepts an empty / omitted list', () => {
expect(allowedAddressesSchema.parse(undefined)).toBeUndefined();
expect(allowedAddressesSchema.parse([])).toEqual([]);
});
});
describe('rejects invalid shapes', () => {
it.each([
['', 'empty string'],
[' ', 'whitespace-only'],
['10.0.0.5\t', 'embedded tab'],
['10.0.0.5\n', 'embedded newline'],
['10.0.0.5 ', 'trailing space'],
['http://10.0.0.5', 'http URL'],
['https://internal.example', 'https URL'],
['ws://10.0.0.5', 'ws URL'],
['10.0.0.0/24', 'CIDR range'],
['/path', 'leading slash / path'],
['10.0.0.5/api', 'embedded path'],
])('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"',
(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/);
}
},
);
});
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)'],
])('rejects public IP literal "%s" (%s)', (entry) => {
const result = allowedAddressesSchema.safeParse([entry]);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toMatch(/scoped to private IP space/);
}
});
});
describe('integration with configSchema', () => {
it('accepts the field on endpoints', () => {
const result = configSchema.safeParse({
version: '1.0',
endpoints: { allowedAddresses: ['10.0.0.5', 'ollama.internal'] },
});
expect(result.success).toBe(true);
});
it('accepts the field on mcpSettings', () => {
const result = configSchema.safeParse({
version: '1.0',
mcpSettings: { allowedAddresses: ['127.0.0.1'] },
});
expect(result.success).toBe(true);
});
it('accepts the field on actions', () => {
const result = configSchema.safeParse({
version: '1.0',
actions: { allowedAddresses: ['host.docker.internal'] },
});
expect(result.success).toBe(true);
});
it('rejects a public IP at the endpoints location', () => {
const result = configSchema.safeParse({
version: '1.0',
endpoints: { allowedAddresses: ['8.8.8.8'] },
});
expect(result.success).toBe(false);
});
it('rejects a CIDR range at the mcpSettings location', () => {
const result = configSchema.safeParse({
version: '1.0',
mcpSettings: { allowedAddresses: ['10.0.0.0/24'] },
});
expect(result.success).toBe(false);
});
it('rejects a host:port at the actions location', () => {
const result = configSchema.safeParse({
version: '1.0',
actions: { allowedAddresses: ['localhost:8080'] },
});
expect(result.success).toBe(false);
});
});
});

View file

@ -65,6 +65,103 @@ export enum SettingsViews {
/** Validates any FileSources value — use for file metadata, DB records, and upload routing. */
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 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.
* - Hostnames pass through; their resolved IP is checked at runtime by
* `resolveHostnameSSRF` and only a private resolved IP is meaningful.
*
* Mirrors a minimal subset of `isPrivateIP` from `@librechat/api` to avoid a
* circular package dependency. The runtime helper is the authoritative check;
* this refinement is a UX guardrail.
*/
function isPrivateIPv4Literal(value: string): boolean {
const match = value.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (!match) {
return false;
}
const [a, b, c] = match.slice(1).map(Number) as [number, number, number, number];
if (a === 0 || a === 10 || a === 127) return true;
if (a === 169 && b === 254) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 192 && b === 0 && c === 0) return true; // RFC 5736 IETF protocol assignments
if (a === 100 && b >= 64 && b <= 127) return true;
if (a === 198 && (b === 18 || b === 19)) return true;
if (a >= 224) return true; // multicast/reserved
return false;
}
function isPrivateIPv6Literal(value: string): boolean {
if (!value.includes(':')) return false;
if (value === '::1' || value === '::') return true;
if (value.startsWith('fc') || value.startsWith('fd')) return true; // fc00::/7
// fe80::/10 — first hextet 0xfe800xfebf
const firstHextet = value.split(':', 1)[0];
if (/^[0-9a-f]{1,4}$/.test(firstHextet ?? '')) {
const hextet = parseInt(firstHextet, 16);
if ((hextet & 0xffc0) === 0xfe80) return true;
}
// 4-in-6: ::ffff:A.B.C.D
const mappedMatch = value.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
if (mappedMatch) return isPrivateIPv4Literal(mappedMatch[1]);
return false;
}
/**
* 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`.
* 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);
}
const allowedAddressEntrySchema = z
.string()
.refine((entry) => entry.length > 0 && entry.trim().length > 0, {
message: 'allowedAddresses entries must be non-empty',
})
.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',
})
.refine((entry) => !looksLikeHostPort(entry), {
message: 'allowedAddresses entries must not include a port — list the bare hostname or IP only',
})
.refine(
(entry) => {
const stripped = entry
.toLowerCase()
.trim()
.replace(/^\[|\]$/g, '');
const isIPv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.test(stripped);
const isIPv6 = !isIPv4 && stripped.includes(':');
if (!isIPv4 && !isIPv6) {
return true; // hostname — checked at runtime via DNS
}
return isIPv4 ? isPrivateIPv4Literal(stripped) : isPrivateIPv6Literal(stripped);
},
{
message:
'allowedAddresses is scoped to private IP space — public IP literals are not permitted (use a hostname if it resolves to a private IP)',
},
);
export const allowedAddressesSchema = z.array(allowedAddressEntrySchema).optional();
/** Storage backend strategies only — use for config fields that set where files are stored. */
const FILE_STORAGE_BACKENDS = [
FileSources.local,
@ -1102,6 +1199,7 @@ export const configSchema = z.object({
mcpSettings: z
.object({
allowedDomains: z.array(z.string()).optional(),
allowedAddresses: allowedAddressesSchema,
})
.optional(),
interface: interfaceSchema,
@ -1111,6 +1209,7 @@ export const configSchema = z.object({
actions: z
.object({
allowedDomains: z.array(z.string()).optional(),
allowedAddresses: allowedAddressesSchema,
})
.optional(),
registration: z
@ -1133,6 +1232,7 @@ export const configSchema = z.object({
modelSpecs: specsConfigSchema.optional(),
endpoints: z
.object({
allowedAddresses: allowedAddressesSchema,
all: baseEndpointSchema.omit({ baseURL: true }).optional(),
[EModelEndpoint.openAI]: baseEndpointSchema.optional(),
[EModelEndpoint.google]: baseEndpointSchema.optional(),

View file

@ -77,5 +77,9 @@ export const loadEndpoints = (
loadedEndpoints.all = endpoints.all;
}
if (endpoints?.allowedAddresses) {
loadedEndpoints.allowedAddresses = endpoints.allowedAddresses;
}
return loadedEndpoints;
};

View file

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