From e88a7e6a1cebe6ea3e52e92aa880906c11cc512c Mon Sep 17 00:00:00 2001 From: venti <1308199824@qq.com> Date: Tue, 2 Jun 2026 21:40:15 +0800 Subject: [PATCH 1/4] fix: derive OIDC ALLOWED_HOSTS from connection string hostname instead of hardcoded *.azure.com (fixes #639) --- .../auth/MicrosoftEntraIDAuthHandler.ts | 3 ++- src/documentdb/auth/oidcAllowedHosts.ts | 25 +++++++++++++++++++ src/documentdb/playground/playgroundWorker.ts | 3 ++- 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 src/documentdb/auth/oidcAllowedHosts.ts 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.ts b/src/documentdb/auth/oidcAllowedHosts.ts new file mode 100644 index 000000000..7ec5972dd --- /dev/null +++ b/src/documentdb/auth/oidcAllowedHosts.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Resolve the OIDC ALLOWED_HOSTS list from a MongoDB connection string. + * + * Previously both the auth handler and the playground worker hardcoded + * `['*.azure.com']`, which blocked sovereign clouds (*.azure.cn, *.azure.us), + * private endpoints, and custom domains. This helper extracts the actual + * target hostname so OIDC auth works against any valid endpoint. + */ +export function getOidcAllowedHosts(connectionString: string): string[] { + try { + const url = new URL(connectionString); + const hostname = url.hostname; + if (hostname) { + return [hostname]; + } + } catch { + // Connection string couldn't be parsed — keep the safe default. + } + return ['*.azure.com']; +} 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) => { From 96008c6c8fb48381165cafe9b3fce74ef822651e Mon Sep 17 00:00:00 2001 From: venti <1308199824@qq.com> Date: Wed, 3 Jun 2026 12:19:16 +0800 Subject: [PATCH 2/4] fix: use DocumentDBConnectionString for multi-host support in OIDC allowed hosts - Replace WHATWG URL parser with DocumentDBConnectionString to correctly handle multi-host connection strings and replica set seedlists - Extract '*.azure.com' fallback to named DEFAULT_ALLOWED_HOSTS constant - Return glob per host (*.hostname) for consistent OIDC matching --- src/documentdb/auth/oidcAllowedHosts.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/documentdb/auth/oidcAllowedHosts.ts b/src/documentdb/auth/oidcAllowedHosts.ts index 7ec5972dd..9765d3237 100644 --- a/src/documentdb/auth/oidcAllowedHosts.ts +++ b/src/documentdb/auth/oidcAllowedHosts.ts @@ -3,23 +3,30 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { DocumentDBConnectionString } from '../utils/DocumentDBConnectionString'; + +const DEFAULT_ALLOWED_HOSTS = ['*.azure.com']; + /** * Resolve the OIDC ALLOWED_HOSTS list from a MongoDB connection string. * * Previously both the auth handler and the playground worker hardcoded * `['*.azure.com']`, which blocked sovereign clouds (*.azure.cn, *.azure.us), * private endpoints, and custom domains. This helper extracts the actual - * target hostname so OIDC auth works against any valid endpoint. + * target hostname(s) so OIDC auth works against any valid endpoint. + * + * Multi-host connection strings (e.g. replica sets) are supported by + * returning a glob per host (`*.hostname`). */ export function getOidcAllowedHosts(connectionString: string): string[] { try { - const url = new URL(connectionString); - const hostname = url.hostname; - if (hostname) { - return [hostname]; + const parsed = new DocumentDBConnectionString(connectionString); + const hosts = parsed.hosts; + if (hosts && hosts.length > 0) { + return hosts.map((h) => `*.${h}`); } } catch { // Connection string couldn't be parsed — keep the safe default. } - return ['*.azure.com']; + return DEFAULT_ALLOWED_HOSTS; } From 8fe55d37754d64eab1e1e44d3d74416c95f057f4 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 19 Jun 2026 13:00:00 +0000 Subject: [PATCH 3/4] fix(oidc): restrict allowed hosts to the Azure-family suffix --- src/documentdb/auth/oidcAllowedHosts.test.ts | 44 +++++++++++ src/documentdb/auth/oidcAllowedHosts.ts | 78 +++++++++++++++++--- 2 files changed, 111 insertions(+), 11 deletions(-) create mode 100644 src/documentdb/auth/oidcAllowedHosts.test.ts diff --git a/src/documentdb/auth/oidcAllowedHosts.test.ts b/src/documentdb/auth/oidcAllowedHosts.test.ts new file mode 100644 index 000000000..5a10a0c48 --- /dev/null +++ b/src/documentdb/auth/oidcAllowedHosts.test.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * 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('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 index 9765d3237..c57b6e010 100644 --- a/src/documentdb/auth/oidcAllowedHosts.ts +++ b/src/documentdb/auth/oidcAllowedHosts.ts @@ -5,28 +5,84 @@ 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 MongoDB connection string. + * Resolve the OIDC ALLOWED_HOSTS list from a connection string. * - * Previously both the auth handler and the playground worker hardcoded - * `['*.azure.com']`, which blocked sovereign clouds (*.azure.cn, *.azure.us), - * private endpoints, and custom domains. This helper extracts the actual - * target hostname(s) so OIDC auth works against any valid endpoint. + * 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. * - * Multi-host connection strings (e.g. replica sets) are supported by - * returning a glob per host (`*.hostname`). + * 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); - const hosts = parsed.hosts; - if (hosts && hosts.length > 0) { - return hosts.map((h) => `*.${h}`); + + // 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 couldn't be parsed — keep the safe default. + // 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; +} From 96481dd59f40498a9036a7336eaa3c350d331b41 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 19 Jun 2026 15:11:27 +0000 Subject: [PATCH 4/4] test(oidc): cover mongodb+srv seedlist connection strings --- src/documentdb/auth/oidcAllowedHosts.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/documentdb/auth/oidcAllowedHosts.test.ts b/src/documentdb/auth/oidcAllowedHosts.test.ts index 5a10a0c48..dc1739259 100644 --- a/src/documentdb/auth/oidcAllowedHosts.test.ts +++ b/src/documentdb/auth/oidcAllowedHosts.test.ts @@ -18,6 +18,14 @@ describe('getOidcAllowedHosts', () => { 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.