Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<OIDCResponse> =>
Promise.resolve({
accessToken: session.accessToken,
Expand Down
52 changes: 52 additions & 0 deletions src/documentdb/auth/oidcAllowedHosts.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
88 changes: 88 additions & 0 deletions src/documentdb/auth/oidcAllowedHosts.ts
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
tnaum-ms marked this conversation as resolved.
*
* Instead we recognize the Azure-family registrable suffix (`azure.<tld>`) and
* allow only `*.azure.<tld>`. 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[] {
Comment thread
hanhan761 marked this conversation as resolved.
try {
const parsed = new DocumentDBConnectionString(connectionString);

// Deduplicate so a replica-set connection string with several hosts in
// the same cloud yields a single `*.azure.<tld>` entry.
const suffixes = new Set<string>();
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;
}
Comment thread
hanhan761 marked this conversation as resolved.

/**
* Returns the Azure-family registrable suffix (`azure.<tld>`) 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;
}
3 changes: 2 additions & 1 deletion src/documentdb/playground/playgroundWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -119,7 +120,7 @@ async function handleInit(msg: Extract<MainToWorkerMessage, { type: 'init' }>):
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<string>((resolve, reject) => {
Expand Down