mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 06:52:47 +00:00
🪟 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:
parent
85fa881e3c
commit
4cce88be42
45 changed files with 1377 additions and 222 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -929,6 +929,7 @@ describe('User parameter passing tests', () => {
|
|||
expect(mockIsMCPDomainAllowed).toHaveBeenCalledWith(
|
||||
{ url: 'https://disallowed-domain.com/sse' },
|
||||
['allowed-domain.com'],
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
92
packages/api/src/auth/allowedAddresses.ts
Normal file
92
packages/api/src/auth/allowedAddresses.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 0xfe80–0xfebf; 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
153
packages/api/src/auth/ip.ts
Normal 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 0xfe80–0xfebf; bitwise check required
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hasPrivateEmbeddedIPv4(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
|
@ -179,6 +179,7 @@ describe('initializeCustom – SSRF guard wiring', () => {
|
|||
expect(mockValidateEndpointURL).toHaveBeenCalledWith(
|
||||
'https://user-api.example.com/v1',
|
||||
'test-custom',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ describe('initializeOpenAI – SSRF guard wiring', () => {
|
|||
expect(mockValidateEndpointURL).toHaveBeenCalledWith(
|
||||
'https://user-proxy.example.com/v1',
|
||||
EModelEndpoint.openAI,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ export class ConnectionsRepository {
|
|||
dbSourced: isUserSourced(serverConfig as t.ParsedServerConfig),
|
||||
useSSRFProtection: registry.shouldEnableSSRFProtection(),
|
||||
allowedDomains: registry.getAllowedDomains(),
|
||||
allowedAddresses: registry.getAllowedAddresses(),
|
||||
},
|
||||
this.oauthOpts,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ export abstract class UserConnectionManager {
|
|||
dbSourced: isUserSourced(config),
|
||||
useSSRFProtection: registry.shouldEnableSSRFProtection(),
|
||||
allowedDomains: registry.getAllowedDomains(),
|
||||
allowedAddresses: registry.getAllowedAddresses(),
|
||||
},
|
||||
{
|
||||
useOAuth: true,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => ({
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
153
packages/api/src/mcp/oauth/handler.allowedAddresses.test.ts
Normal file
153
packages/api/src/mcp/oauth/handler.allowedAddresses.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 0xfe80–0xfebf
|
||||
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(),
|
||||
|
|
|
|||
|
|
@ -77,5 +77,9 @@ export const loadEndpoints = (
|
|||
loadedEndpoints.all = endpoints.all;
|
||||
}
|
||||
|
||||
if (endpoints?.allowedAddresses) {
|
||||
loadedEndpoints.allowedAddresses = endpoints.allowedAddresses;
|
||||
}
|
||||
|
||||
return loadedEndpoints;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue