🗝️ fix: Exempt Admin-Trusted Domains from MCP OAuth Validation (#12255)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run

* fix: exempt allowedDomains from MCP OAuth SSRF checks (#12254)

The SSRF guard in validateOAuthUrl was context-blind — it blocked
private/internal OAuth endpoints even for admin-trusted MCP servers
listed in mcpSettings.allowedDomains. Add isHostnameAllowed() to
domain.ts and skip SSRF checks in validateOAuthUrl when the OAuth
endpoint hostname matches an allowed domain.

* refactor: thread allowedDomains through MCP connection stack

Pass allowedDomains from MCPServersRegistry through BasicConnectionOptions,
MCPConnectionFactory, and into MCPOAuthHandler method calls so the OAuth
layer can exempt admin-trusted domains from SSRF validation.

* test: add allowedDomains bypass tests and fix registry mocks

Add isHostnameAllowed unit tests (exact, wildcard, case-insensitive,
private IPs). Add MCPOAuthSecurity tests covering the allowedDomains
bypass for initiateOAuthFlow, refreshOAuthTokens, and revokeOAuthToken.
Update registry mocks to include getAllowedDomains.

* fix: enforce protocol/port constraints in OAuth allowedDomains bypass

Replace isHostnameAllowed (hostname-only check) with isOAuthUrlAllowed
which parses the full OAuth URL and matches against allowedDomains
entries including protocol and explicit port constraints — mirroring
isDomainAllowedCore's allowlist logic. Prevents a port-scoped entry
like 'https://auth.internal:8443' from also exempting other ports.

* test: cover auto-discovery and branch-3 refresh paths with allowedDomains

Add three new integration tests using a real OAuth test server:
- auto-discovered OAuth endpoints allowed when server IP is in allowedDomains
- auto-discovered endpoints rejected when allowedDomains doesn't match
- refreshOAuthTokens branch 3 (no clientInfo/config) with allowedDomains bypass

Also rename describe block from ephemeral issue number to durable name.

* docs: explain intentional absence of allowedDomains in completeOAuthFlow

Prevents future contributors from assuming a missing parameter during
security audits — URLs are pre-validated during initiateOAuthFlow.

* test: update initiateOAuthFlow assertion for allowedDomains parameter

* perf: avoid redundant URL parse for admin-trusted OAuth endpoints

Move isOAuthUrlAllowed check before the hostname extraction so
admin-trusted URLs short-circuit with a single URL parse instead
of two. The hostname extraction (new URL) is now deferred to the
SSRF-check path where it's actually needed.
This commit is contained in:
Danny Avila 2026-03-15 23:03:12 -04:00 committed by GitHub
parent 8e8fb01d18
commit acd07e8085
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 432 additions and 18 deletions

View file

@ -30,6 +30,7 @@ export class MCPConnectionFactory {
protected readonly logPrefix: string;
protected readonly useOAuth: boolean;
protected readonly useSSRFProtection: boolean;
protected readonly allowedDomains?: string[] | null;
// OAuth-related properties (only set when useOAuth is true)
protected readonly userId?: string;
@ -197,6 +198,7 @@ export class MCPConnectionFactory {
this.serverName = basic.serverName;
this.useOAuth = !!oauth?.useOAuth;
this.useSSRFProtection = basic.useSSRFProtection === true;
this.allowedDomains = basic.allowedDomains;
this.connectionTimeout = oauth?.connectionTimeout;
this.logPrefix = oauth?.user
? `[MCP][${basic.serverName}][${oauth.user.id}]`
@ -297,6 +299,7 @@ export class MCPConnectionFactory {
},
this.serverConfig.oauth_headers ?? {},
this.serverConfig.oauth,
this.allowedDomains,
);
};
}
@ -340,6 +343,7 @@ export class MCPConnectionFactory {
this.userId!,
config?.oauth_headers ?? {},
config?.oauth,
this.allowedDomains,
);
if (existingFlow) {
@ -603,6 +607,7 @@ export class MCPConnectionFactory {
this.userId!,
this.serverConfig.oauth_headers ?? {},
this.serverConfig.oauth,
this.allowedDomains,
);
// Store flow state BEFORE redirecting so the callback can find it