Skip to content

Commit b7f507d

Browse files
authored
fix: derive OIDC ALLOWED_HOSTS from connection string hostname instead of hardcoded *.azure.com (fixes #639) (#721)
2 parents 7e5f5af + 96481dd commit b7f507d

4 files changed

Lines changed: 144 additions & 2 deletions

File tree

src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { type MongoClientOptions, type OIDCCallbackParams, type OIDCResponse } f
1010
import { type CachedClusterCredentials } from '../CredentialCache';
1111
import { DocumentDBConnectionString } from '../utils/DocumentDBConnectionString';
1212
import { type AuthHandler, type AuthHandlerResponse } from './AuthHandler';
13+
import { getOidcAllowedHosts } from './oidcAllowedHosts';
1314

1415
/**
1516
* Handler for Microsoft Entra ID authentication via OIDC
@@ -43,7 +44,7 @@ export class MicrosoftEntraIDAuthHandler implements AuthHandler {
4344
authMechanism: 'MONGODB-OIDC',
4445
tls: true,
4546
authMechanismProperties: {
46-
ALLOWED_HOSTS: ['*.azure.com'],
47+
ALLOWED_HOSTS: getOidcAllowedHosts(this.clusterCredentials.connectionString),
4748
OIDC_CALLBACK: (_params: OIDCCallbackParams): Promise<OIDCResponse> =>
4849
Promise.resolve({
4950
accessToken: session.accessToken,
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { getOidcAllowedHosts } from './oidcAllowedHosts';
7+
8+
describe('getOidcAllowedHosts', () => {
9+
it('allows only the Azure suffix for a public-cloud host, not the full host', () => {
10+
const result = getOidcAllowedHosts('mongodb://asdf.xyz.mongocluster.cosmos.azure.com:10255/?ssl=true');
11+
// The allowlist must stay broad enough to match the host (the driver
12+
// checks host.endsWith('.azure.com')) but must NOT echo the full host.
13+
expect(result).toEqual(['*.azure.com']);
14+
});
15+
16+
it('extends to sovereign-cloud TLDs', () => {
17+
expect(getOidcAllowedHosts('mongodb://cluster.mongocluster.cosmos.azure.us:10255/')).toEqual(['*.azure.us']);
18+
expect(getOidcAllowedHosts('mongodb://cluster.mongocluster.cosmos.azure.cn:10255/')).toEqual(['*.azure.cn']);
19+
});
20+
21+
it('handles mongodb+srv:// seedlist connection strings', () => {
22+
// SRV hosts carry no port and the resolved nodes stay under the same
23+
// Azure suffix, so *.azure.com still covers them.
24+
expect(getOidcAllowedHosts('mongodb+srv://cluster.mongocluster.cosmos.azure.com/?tls=true')).toEqual([
25+
'*.azure.com',
26+
]);
27+
});
28+
29+
it('does not widen the allowlist to an attacker-supplied host', () => {
30+
// A connection string the user was tricked into pasting must not be able
31+
// to authorize token delivery to a non-Azure host.
32+
expect(getOidcAllowedHosts('mongodb://evil.com:10255/')).toEqual(['*.azure.com']);
33+
// Lookalike where "azure" is not the registrable second level.
34+
expect(getOidcAllowedHosts('mongodb://node.azure.com.evil.com:10255/')).toEqual(['*.azure.com']);
35+
});
36+
37+
it('collapses multiple same-cloud hosts to a single suffix entry', () => {
38+
const result = getOidcAllowedHosts(
39+
'mongodb://a.mongocluster.cosmos.azure.com:10255,b.mongocluster.cosmos.azure.com:10255/?replicaSet=rs0',
40+
);
41+
expect(result).toEqual(['*.azure.com']);
42+
});
43+
44+
it('ignores ports when classifying the host', () => {
45+
expect(getOidcAllowedHosts('mongodb://cluster.azure.com:27017/')).toEqual(['*.azure.com']);
46+
});
47+
48+
it('falls back to the safe default when the connection string cannot be parsed', () => {
49+
expect(getOidcAllowedHosts('not a connection string')).toEqual(['*.azure.com']);
50+
expect(getOidcAllowedHosts('')).toEqual(['*.azure.com']);
51+
});
52+
});
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { DocumentDBConnectionString } from '../utils/DocumentDBConnectionString';
7+
8+
// Safe default when we cannot positively identify an Azure endpoint. This is the
9+
// historical behavior and covers the public cloud (including private endpoints,
10+
// which still resolve under *.azure.com).
11+
const DEFAULT_ALLOWED_HOSTS = ['*.azure.com'];
12+
13+
/**
14+
* Resolve the OIDC ALLOWED_HOSTS list from a connection string.
15+
*
16+
* ALLOWED_HOSTS is a security control: the driver only sends the OIDC token to a
17+
* server whose hostname matches one of these patterns. It must therefore stay a
18+
* curated allowlist. We intentionally do NOT echo the raw connection-string host
19+
* back into the allowlist: doing so would let an attacker-supplied host (e.g.
20+
* `evil.com`) widen its own allowlist to `*.evil.com`, defeating the control.
21+
*
22+
* Instead we recognize the Azure-family registrable suffix (`azure.<tld>`) and
23+
* allow only `*.azure.<tld>`. This keeps the public-cloud posture identical to
24+
* the previous hardcoded `*.azure.com` while transparently extending it to
25+
* sovereign clouds (`azure.us`, `azure.cn`, ...). Anything we cannot positively
26+
* classify as Azure falls back to the safe public-cloud default.
27+
*
28+
* Note: sovereign clouds also use a different Entra token endpoint; wiring that
29+
* up is tracked separately. This change only widens the host allowlist.
30+
*/
31+
export function getOidcAllowedHosts(connectionString: string): string[] {
32+
try {
33+
const parsed = new DocumentDBConnectionString(connectionString);
34+
35+
// Deduplicate so a replica-set connection string with several hosts in
36+
// the same cloud yields a single `*.azure.<tld>` entry.
37+
const suffixes = new Set<string>();
38+
for (const host of parsed.hosts ?? []) {
39+
const suffix = getAzureHostSuffix(host);
40+
if (suffix) {
41+
suffixes.add(suffix);
42+
}
43+
}
44+
45+
if (suffixes.size > 0) {
46+
return [...suffixes].map((suffix) => `*.${suffix}`);
47+
}
48+
} catch {
49+
// Connection string could not be parsed; fall back to the safe default.
50+
}
51+
52+
return DEFAULT_ALLOWED_HOSTS;
53+
}
54+
55+
/**
56+
* Returns the Azure-family registrable suffix (`azure.<tld>`) for a host, or
57+
* `undefined` if the host is not an Azure endpoint.
58+
*
59+
* The `azure` label must be the registrable second level (immediately before the
60+
* top-level label) so that lookalikes such as `azure.com.evil.com` are rejected.
61+
*/
62+
function getAzureHostSuffix(host: string): string | undefined {
63+
// `hosts` entries may carry a port (e.g. `cluster.azure.com:10255`) and an
64+
// IPv6 literal is wrapped in brackets; neither is part of the suffix.
65+
const hostname = host
66+
.replace(/^\[.*\]/, '') // drop bracketed IPv6 literals (never Azure FQDNs)
67+
.split(':')[0]
68+
?.trim()
69+
.toLowerCase();
70+
71+
if (!hostname) {
72+
return undefined;
73+
}
74+
75+
const labels = hostname.split('.');
76+
if (labels.length < 2) {
77+
return undefined;
78+
}
79+
80+
const topLevel = labels[labels.length - 1];
81+
const secondLevel = labels[labels.length - 2];
82+
83+
if (secondLevel === 'azure' && topLevel.length > 0) {
84+
return `azure.${topLevel}`;
85+
}
86+
87+
return undefined;
88+
}

src/documentdb/playground/playgroundWorker.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { DocumentDBShellRuntime } from '@documentdb-js/shell-runtime';
1818
import { randomUUID } from 'crypto';
1919
import { type MongoClientOptions, type MongoClient as MongoClientType } from 'mongodb';
2020
import { parentPort } from 'worker_threads';
21+
import { getOidcAllowedHosts } from '../auth/oidcAllowedHosts';
2122
import { type MainToWorkerMessage, type WorkerToMainMessage } from './workerTypes';
2223

2324
if (!parentPort) {
@@ -119,7 +120,7 @@ async function handleInit(msg: Extract<MainToWorkerMessage, { type: 'init' }>):
119120
options.authMechanism = 'MONGODB-OIDC';
120121
options.tls = true;
121122
options.authMechanismProperties = {
122-
ALLOWED_HOSTS: ['*.azure.com'],
123+
ALLOWED_HOSTS: getOidcAllowedHosts(msg.connectionString),
123124
OIDC_CALLBACK: async (): Promise<{ accessToken: string; expiresInSeconds: number }> => {
124125
const requestId = randomUUID();
125126
const tokenPromise = new Promise<string>((resolve, reject) => {

0 commit comments

Comments
 (0)