diff --git a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts index f52c25f4e..395734468 100644 --- a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts +++ b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts @@ -10,6 +10,7 @@ import { type MongoClientOptions, type OIDCCallbackParams, type OIDCResponse } f import { type CachedClusterCredentials } from '../CredentialCache'; import { DocumentDBConnectionString } from '../utils/DocumentDBConnectionString'; import { type AuthHandler, type AuthHandlerResponse } from './AuthHandler'; +import { getOidcAllowedHosts } from './oidcAllowedHosts'; /** * Handler for Microsoft Entra ID authentication via OIDC @@ -43,7 +44,7 @@ export class MicrosoftEntraIDAuthHandler implements AuthHandler { authMechanism: 'MONGODB-OIDC', tls: true, authMechanismProperties: { - ALLOWED_HOSTS: ['*.azure.com'], + ALLOWED_HOSTS: getOidcAllowedHosts(this.clusterCredentials.connectionString), OIDC_CALLBACK: (_params: OIDCCallbackParams): Promise => Promise.resolve({ accessToken: session.accessToken, diff --git a/src/documentdb/auth/oidcAllowedHosts.test.ts b/src/documentdb/auth/oidcAllowedHosts.test.ts new file mode 100644 index 000000000..dc1739259 --- /dev/null +++ b/src/documentdb/auth/oidcAllowedHosts.test.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { getOidcAllowedHosts } from './oidcAllowedHosts'; + +describe('getOidcAllowedHosts', () => { + it('allows only the Azure suffix for a public-cloud host, not the full host', () => { + const result = getOidcAllowedHosts('mongodb://asdf.xyz.mongocluster.cosmos.azure.com:10255/?ssl=true'); + // The allowlist must stay broad enough to match the host (the driver + // checks host.endsWith('.azure.com')) but must NOT echo the full host. + expect(result).toEqual(['*.azure.com']); + }); + + it('extends to sovereign-cloud TLDs', () => { + expect(getOidcAllowedHosts('mongodb://cluster.mongocluster.cosmos.azure.us:10255/')).toEqual(['*.azure.us']); + expect(getOidcAllowedHosts('mongodb://cluster.mongocluster.cosmos.azure.cn:10255/')).toEqual(['*.azure.cn']); + }); + + it('handles mongodb+srv:// seedlist connection strings', () => { + // SRV hosts carry no port and the resolved nodes stay under the same + // Azure suffix, so *.azure.com still covers them. + expect(getOidcAllowedHosts('mongodb+srv://cluster.mongocluster.cosmos.azure.com/?tls=true')).toEqual([ + '*.azure.com', + ]); + }); + + it('does not widen the allowlist to an attacker-supplied host', () => { + // A connection string the user was tricked into pasting must not be able + // to authorize token delivery to a non-Azure host. + expect(getOidcAllowedHosts('mongodb://evil.com:10255/')).toEqual(['*.azure.com']); + // Lookalike where "azure" is not the registrable second level. + expect(getOidcAllowedHosts('mongodb://node.azure.com.evil.com:10255/')).toEqual(['*.azure.com']); + }); + + it('collapses multiple same-cloud hosts to a single suffix entry', () => { + const result = getOidcAllowedHosts( + 'mongodb://a.mongocluster.cosmos.azure.com:10255,b.mongocluster.cosmos.azure.com:10255/?replicaSet=rs0', + ); + expect(result).toEqual(['*.azure.com']); + }); + + it('ignores ports when classifying the host', () => { + expect(getOidcAllowedHosts('mongodb://cluster.azure.com:27017/')).toEqual(['*.azure.com']); + }); + + it('falls back to the safe default when the connection string cannot be parsed', () => { + expect(getOidcAllowedHosts('not a connection string')).toEqual(['*.azure.com']); + expect(getOidcAllowedHosts('')).toEqual(['*.azure.com']); + }); +}); diff --git a/src/documentdb/auth/oidcAllowedHosts.ts b/src/documentdb/auth/oidcAllowedHosts.ts new file mode 100644 index 000000000..c57b6e010 --- /dev/null +++ b/src/documentdb/auth/oidcAllowedHosts.ts @@ -0,0 +1,88 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DocumentDBConnectionString } from '../utils/DocumentDBConnectionString'; + +// Safe default when we cannot positively identify an Azure endpoint. This is the +// historical behavior and covers the public cloud (including private endpoints, +// which still resolve under *.azure.com). +const DEFAULT_ALLOWED_HOSTS = ['*.azure.com']; + +/** + * Resolve the OIDC ALLOWED_HOSTS list from a connection string. + * + * ALLOWED_HOSTS is a security control: the driver only sends the OIDC token to a + * server whose hostname matches one of these patterns. It must therefore stay a + * curated allowlist. We intentionally do NOT echo the raw connection-string host + * back into the allowlist: doing so would let an attacker-supplied host (e.g. + * `evil.com`) widen its own allowlist to `*.evil.com`, defeating the control. + * + * Instead we recognize the Azure-family registrable suffix (`azure.`) and + * allow only `*.azure.`. This keeps the public-cloud posture identical to + * the previous hardcoded `*.azure.com` while transparently extending it to + * sovereign clouds (`azure.us`, `azure.cn`, ...). Anything we cannot positively + * classify as Azure falls back to the safe public-cloud default. + * + * Note: sovereign clouds also use a different Entra token endpoint; wiring that + * up is tracked separately. This change only widens the host allowlist. + */ +export function getOidcAllowedHosts(connectionString: string): string[] { + try { + const parsed = new DocumentDBConnectionString(connectionString); + + // Deduplicate so a replica-set connection string with several hosts in + // the same cloud yields a single `*.azure.` entry. + const suffixes = new Set(); + for (const host of parsed.hosts ?? []) { + const suffix = getAzureHostSuffix(host); + if (suffix) { + suffixes.add(suffix); + } + } + + if (suffixes.size > 0) { + return [...suffixes].map((suffix) => `*.${suffix}`); + } + } catch { + // Connection string could not be parsed; fall back to the safe default. + } + + return DEFAULT_ALLOWED_HOSTS; +} + +/** + * Returns the Azure-family registrable suffix (`azure.`) for a host, or + * `undefined` if the host is not an Azure endpoint. + * + * The `azure` label must be the registrable second level (immediately before the + * top-level label) so that lookalikes such as `azure.com.evil.com` are rejected. + */ +function getAzureHostSuffix(host: string): string | undefined { + // `hosts` entries may carry a port (e.g. `cluster.azure.com:10255`) and an + // IPv6 literal is wrapped in brackets; neither is part of the suffix. + const hostname = host + .replace(/^\[.*\]/, '') // drop bracketed IPv6 literals (never Azure FQDNs) + .split(':')[0] + ?.trim() + .toLowerCase(); + + if (!hostname) { + return undefined; + } + + const labels = hostname.split('.'); + if (labels.length < 2) { + return undefined; + } + + const topLevel = labels[labels.length - 1]; + const secondLevel = labels[labels.length - 2]; + + if (secondLevel === 'azure' && topLevel.length > 0) { + return `azure.${topLevel}`; + } + + return undefined; +} diff --git a/src/documentdb/playground/playgroundWorker.ts b/src/documentdb/playground/playgroundWorker.ts index b9fcc81f6..0075fcec3 100644 --- a/src/documentdb/playground/playgroundWorker.ts +++ b/src/documentdb/playground/playgroundWorker.ts @@ -18,6 +18,7 @@ import { DocumentDBShellRuntime } from '@documentdb-js/shell-runtime'; import { randomUUID } from 'crypto'; import { type MongoClientOptions, type MongoClient as MongoClientType } from 'mongodb'; import { parentPort } from 'worker_threads'; +import { getOidcAllowedHosts } from '../auth/oidcAllowedHosts'; import { type MainToWorkerMessage, type WorkerToMainMessage } from './workerTypes'; if (!parentPort) { @@ -119,7 +120,7 @@ async function handleInit(msg: Extract): options.authMechanism = 'MONGODB-OIDC'; options.tls = true; options.authMechanismProperties = { - ALLOWED_HOSTS: ['*.azure.com'], + ALLOWED_HOSTS: getOidcAllowedHosts(msg.connectionString), OIDC_CALLBACK: async (): Promise<{ accessToken: string; expiresInSeconds: number }> => { const requestId = randomUUID(); const tokenPromise = new Promise((resolve, reject) => {