Skip to content

Commit 3d5bd51

Browse files
authoredOct 15, 2024··
feat(NODE-6289): allow valid srv hostnames with less than 3 parts (#4197)
1 parent 7fde8dd commit 3d5bd51

File tree

5 files changed

+406
-50
lines changed

5 files changed

+406
-50
lines changed
 

‎src/connection_string.ts

+2-9
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,11 @@ import { ReadPreference, type ReadPreferenceMode } from './read_preference';
3434
import { ServerMonitoringMode } from './sdam/monitor';
3535
import type { TagSet } from './sdam/server_description';
3636
import {
37+
checkParentDomainMatch,
3738
DEFAULT_PK_FACTORY,
3839
emitWarning,
3940
HostAddress,
4041
isRecord,
41-
matchesParentDomain,
4242
parseInteger,
4343
setDifference,
4444
squashError
@@ -64,11 +64,6 @@ export async function resolveSRVRecord(options: MongoOptions): Promise<HostAddre
6464
throw new MongoAPIError('Option "srvHost" must not be empty');
6565
}
6666

67-
if (options.srvHost.split('.').length < 3) {
68-
// TODO(NODE-3484): Replace with MongoConnectionStringError
69-
throw new MongoAPIError('URI must include hostname, domain name, and tld');
70-
}
71-
7267
// Asynchronously start TXT resolution so that we do not have to wait until
7368
// the SRV record is resolved before starting a second DNS query.
7469
const lookupAddress = options.srvHost;
@@ -86,9 +81,7 @@ export async function resolveSRVRecord(options: MongoOptions): Promise<HostAddre
8681
}
8782

8883
for (const { name } of addresses) {
89-
if (!matchesParentDomain(name, lookupAddress)) {
90-
throw new MongoAPIError('Server record does not share hostname with parent URI');
91-
}
84+
checkParentDomainMatch(name, lookupAddress);
9285
}
9386

9487
const hostAddresses = addresses.map(r => HostAddress.fromString(`${r.name}:${r.port ?? 27017}`));

‎src/sdam/srv_polling.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { clearTimeout, setTimeout } from 'timers';
33

44
import { MongoRuntimeError } from '../error';
55
import { TypedEventEmitter } from '../mongo_types';
6-
import { HostAddress, matchesParentDomain, squashError } from '../utils';
6+
import { checkParentDomainMatch, HostAddress, squashError } from '../utils';
77

88
/**
99
* @internal
@@ -127,8 +127,11 @@ export class SrvPoller extends TypedEventEmitter<SrvPollerEvents> {
127127

128128
const finalAddresses: dns.SrvRecord[] = [];
129129
for (const record of srvRecords) {
130-
if (matchesParentDomain(record.name, this.srvHost)) {
130+
try {
131+
checkParentDomainMatch(record.name, this.srvHost);
131132
finalAddresses.push(record);
133+
} catch (error) {
134+
squashError(error);
132135
}
133136
}
134137

‎src/utils.ts

+24-5
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { FindCursor } from './cursor/find_cursor';
1818
import type { Db } from './db';
1919
import {
2020
type AnyError,
21+
MongoAPIError,
2122
MongoCompatibilityError,
2223
MongoInvalidArgumentError,
2324
MongoNetworkTimeoutError,
@@ -1142,29 +1143,47 @@ export function parseUnsignedInteger(value: unknown): number | null {
11421143
}
11431144

11441145
/**
1145-
* Determines whether a provided address matches the provided parent domain.
1146+
* This function throws a MongoAPIError in the event that either of the following is true:
1147+
* * If the provided address domain does not match the provided parent domain
1148+
* * If the parent domain contains less than three `.` separated parts and the provided address does not contain at least one more domain level than its parent
11461149
*
11471150
* If a DNS server were to become compromised SRV records would still need to
11481151
* advertise addresses that are under the same domain as the srvHost.
11491152
*
11501153
* @param address - The address to check against a domain
11511154
* @param srvHost - The domain to check the provided address against
1152-
* @returns Whether the provided address matches the parent domain
1155+
* @returns void
11531156
*/
1154-
export function matchesParentDomain(address: string, srvHost: string): boolean {
1157+
export function checkParentDomainMatch(address: string, srvHost: string): void {
11551158
// Remove trailing dot if exists on either the resolved address or the srv hostname
11561159
const normalizedAddress = address.endsWith('.') ? address.slice(0, address.length - 1) : address;
11571160
const normalizedSrvHost = srvHost.endsWith('.') ? srvHost.slice(0, srvHost.length - 1) : srvHost;
11581161

11591162
const allCharacterBeforeFirstDot = /^.*?\./;
1163+
const srvIsLessThanThreeParts = normalizedSrvHost.split('.').length < 3;
11601164
// Remove all characters before first dot
11611165
// Add leading dot back to string so
11621166
// an srvHostDomain = '.trusted.site'
11631167
// will not satisfy an addressDomain that endsWith '.fake-trusted.site'
11641168
const addressDomain = `.${normalizedAddress.replace(allCharacterBeforeFirstDot, '')}`;
1165-
const srvHostDomain = `.${normalizedSrvHost.replace(allCharacterBeforeFirstDot, '')}`;
1169+
let srvHostDomain = srvIsLessThanThreeParts
1170+
? normalizedSrvHost
1171+
: `.${normalizedSrvHost.replace(allCharacterBeforeFirstDot, '')}`;
11661172

1167-
return addressDomain.endsWith(srvHostDomain);
1173+
if (!srvHostDomain.startsWith('.')) {
1174+
srvHostDomain = '.' + srvHostDomain;
1175+
}
1176+
if (
1177+
srvIsLessThanThreeParts &&
1178+
normalizedAddress.split('.').length <= normalizedSrvHost.split('.').length
1179+
) {
1180+
throw new MongoAPIError(
1181+
'Server record does not have at least one more domain level than parent URI'
1182+
);
1183+
}
1184+
if (!addressDomain.endsWith(srvHostDomain)) {
1185+
throw new MongoAPIError('Server record does not share hostname with parent URI');
1186+
}
11681187
}
11691188

11701189
interface RequestOptions {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
import { expect } from 'chai';
2+
import * as dns from 'dns';
3+
import * as sinon from 'sinon';
4+
5+
import { MongoAPIError, Server, ServerDescription, Topology } from '../../mongodb';
6+
import { topologyWithPlaceholderClient } from '../../tools/utils';
7+
8+
describe('Initial DNS Seedlist Discovery (Prose Tests)', () => {
9+
describe('1. Allow SRVs with fewer than 3 . separated parts', function () {
10+
context('when running validation on an SRV string before DNS resolution', function () {
11+
/**
12+
* When running validation on an SRV string before DNS resolution, do not throw a error due to number of SRV parts.
13+
* - mongodb+srv://localhost
14+
* - mongodb+srv://mongo.localhost
15+
*/
16+
17+
let client;
18+
19+
beforeEach(async function () {
20+
// this fn stubs DNS resolution to always pass - so we are only checking pre-DNS validation
21+
22+
sinon.stub(dns.promises, 'resolveSrv').callsFake(async () => {
23+
return [
24+
{
25+
name: 'resolved.mongo.localhost',
26+
port: 27017,
27+
weight: 0,
28+
priority: 0
29+
}
30+
];
31+
});
32+
33+
sinon.stub(dns.promises, 'resolveTxt').callsFake(async () => {
34+
throw { code: 'ENODATA' };
35+
});
36+
37+
sinon.stub(Topology.prototype, 'selectServer').callsFake(async () => {
38+
return new Server(
39+
topologyWithPlaceholderClient([], {} as any),
40+
new ServerDescription('a:1'),
41+
{} as any
42+
);
43+
});
44+
});
45+
46+
afterEach(async function () {
47+
sinon.restore();
48+
await client.close();
49+
});
50+
51+
it('does not error on an SRV because it has one domain level', async function () {
52+
client = await this.configuration.newClient('mongodb+srv://localhost', {});
53+
await client.connect();
54+
});
55+
56+
it('does not error on an SRV because it has two domain levels', async function () {
57+
client = await this.configuration.newClient('mongodb+srv://mongo.localhost', {});
58+
await client.connect();
59+
});
60+
});
61+
});
62+
63+
describe('2. Throw when return address does not end with SRV domain', function () {
64+
context(
65+
'when given a host from DNS resolution that does NOT end with the original SRVs domain name',
66+
function () {
67+
/**
68+
* When given a returned address that does NOT end with the original SRV's domain name, throw a runtime error.
69+
* For this test, run each of the following cases:
70+
* - the SRV mongodb+srv://localhost resolving to localhost.mongodb
71+
* - the SRV mongodb+srv://mongo.local resolving to test_1.evil.local
72+
* - the SRV mongodb+srv://blogs.mongodb.com resolving to blogs.evil.com
73+
* Remember, the domain of an SRV with one or two . separated parts is the SRVs entire hostname.
74+
*/
75+
76+
beforeEach(async function () {
77+
sinon.stub(dns.promises, 'resolveTxt').callsFake(async () => {
78+
throw { code: 'ENODATA' };
79+
});
80+
});
81+
82+
afterEach(async function () {
83+
sinon.restore();
84+
});
85+
86+
it('an SRV with one domain level causes a runtime error', async function () {
87+
sinon.stub(dns.promises, 'resolveSrv').callsFake(async () => {
88+
return [
89+
{
90+
name: 'localhost.mongodb',
91+
port: 27017,
92+
weight: 0,
93+
priority: 0
94+
}
95+
];
96+
});
97+
const err = await this.configuration
98+
.newClient('mongodb+srv://localhost', {})
99+
.connect()
100+
.catch((e: any) => e);
101+
expect(err).to.be.instanceOf(MongoAPIError);
102+
expect(err.message).to.equal('Server record does not share hostname with parent URI');
103+
});
104+
105+
it('an SRV with two domain levels causes a runtime error', async function () {
106+
sinon.stub(dns.promises, 'resolveSrv').callsFake(async () => {
107+
return [
108+
{
109+
name: 'test_1.evil.local', // this string only ends with part of the domain, not all of it!
110+
port: 27017,
111+
weight: 0,
112+
priority: 0
113+
}
114+
];
115+
});
116+
const err = await this.configuration
117+
.newClient('mongodb+srv://mongo.local', {})
118+
.connect()
119+
.catch(e => e);
120+
expect(err).to.be.instanceOf(MongoAPIError);
121+
expect(err.message).to.equal('Server record does not share hostname with parent URI');
122+
});
123+
124+
it('an SRV with three or more domain levels causes a runtime error', async function () {
125+
sinon.stub(dns.promises, 'resolveSrv').callsFake(async () => {
126+
return [
127+
{
128+
name: 'blogs.evil.com',
129+
port: 27017,
130+
weight: 0,
131+
priority: 0
132+
}
133+
];
134+
});
135+
const err = await this.configuration
136+
.newClient('mongodb+srv://blogs.mongodb.com', {})
137+
.connect()
138+
.catch(e => e);
139+
expect(err).to.be.instanceOf(MongoAPIError);
140+
expect(err.message).to.equal('Server record does not share hostname with parent URI');
141+
});
142+
}
143+
);
144+
});
145+
146+
describe('3. Throw when return address is identical to SRV hostname', function () {
147+
/**
148+
* When given a returned address that is identical to the SRV hostname and the SRV hostname has fewer than three . separated parts, throw a runtime error.
149+
* For this test, run each of the following cases:
150+
* - the SRV mongodb+srv://localhost resolving to localhost
151+
* - the SRV mongodb+srv://mongo.local resolving to mongo.local
152+
*/
153+
154+
context(
155+
'when given a host from DNS resolution that is identical to the original SRVs hostname',
156+
function () {
157+
beforeEach(async function () {
158+
sinon.stub(dns.promises, 'resolveTxt').callsFake(async () => {
159+
throw { code: 'ENODATA' };
160+
});
161+
});
162+
163+
afterEach(async function () {
164+
sinon.restore();
165+
});
166+
167+
it('an SRV with one domain level causes a runtime error', async function () {
168+
sinon.stub(dns.promises, 'resolveSrv').callsFake(async () => {
169+
return [
170+
{
171+
name: 'localhost',
172+
port: 27017,
173+
weight: 0,
174+
priority: 0
175+
}
176+
];
177+
});
178+
const err = await this.configuration
179+
.newClient('mongodb+srv://localhost', {})
180+
.connect()
181+
.catch(e => e);
182+
expect(err).to.be.instanceOf(MongoAPIError);
183+
expect(err.message).to.equal(
184+
'Server record does not have at least one more domain level than parent URI'
185+
);
186+
});
187+
188+
it('an SRV with two domain levels causes a runtime error', async function () {
189+
sinon.stub(dns.promises, 'resolveSrv').callsFake(async () => {
190+
return [
191+
{
192+
name: 'mongo.local',
193+
port: 27017,
194+
weight: 0,
195+
priority: 0
196+
}
197+
];
198+
});
199+
const err = await this.configuration
200+
.newClient('mongodb+srv://mongo.local', {})
201+
.connect()
202+
.catch(e => e);
203+
expect(err).to.be.instanceOf(MongoAPIError);
204+
expect(err.message).to.equal(
205+
'Server record does not have at least one more domain level than parent URI'
206+
);
207+
});
208+
}
209+
);
210+
});
211+
212+
describe('4. Throw when return address does not contain . separating shared part of domain', function () {
213+
/**
214+
* When given a returned address that does NOT share the domain name of the SRV record because it's missing a ., throw a runtime error.
215+
* For this test, run each of the following cases:
216+
* - the SRV mongodb+srv://localhost resolving to test_1.cluster_1localhost
217+
* - the SRV mongodb+srv://mongo.local resolving to test_1.my_hostmongo.local
218+
* - the SRV mongodb+srv://blogs.mongodb.com resolving to cluster.testmongodb.com
219+
*/
220+
221+
context(
222+
'when given a returned address that does NOT share the domain name of the SRV record because its missing a `.`',
223+
function () {
224+
beforeEach(async function () {
225+
sinon.stub(dns.promises, 'resolveTxt').callsFake(async () => {
226+
throw { code: 'ENODATA' };
227+
});
228+
});
229+
230+
afterEach(async function () {
231+
sinon.restore();
232+
});
233+
234+
it('an SRV with one domain level causes a runtime error', async function () {
235+
sinon.stub(dns.promises, 'resolveSrv').callsFake(async () => {
236+
return [
237+
{
238+
name: 'test_1.cluster_1localhost',
239+
port: 27017,
240+
weight: 0,
241+
priority: 0
242+
}
243+
];
244+
});
245+
const err = await this.configuration
246+
.newClient('mongodb+srv://localhost', {})
247+
.connect()
248+
.catch(e => e);
249+
expect(err).to.be.instanceOf(MongoAPIError);
250+
expect(err.message).to.equal('Server record does not share hostname with parent URI');
251+
});
252+
253+
it('an SRV with two domain levels causes a runtime error', async function () {
254+
sinon.stub(dns.promises, 'resolveSrv').callsFake(async () => {
255+
return [
256+
{
257+
name: 'test_1.my_hostmongo.local',
258+
port: 27017,
259+
weight: 0,
260+
priority: 0
261+
}
262+
];
263+
});
264+
const err = await this.configuration
265+
.newClient('mongodb+srv://mongo.local', {})
266+
.connect()
267+
.catch(e => e);
268+
expect(err).to.be.instanceOf(MongoAPIError);
269+
expect(err.message).to.equal('Server record does not share hostname with parent URI');
270+
});
271+
272+
it('an SRV with three domain levels causes a runtime error', async function () {
273+
sinon.stub(dns.promises, 'resolveSrv').callsFake(async () => {
274+
return [
275+
{
276+
name: 'cluster.testmongodb.com',
277+
port: 27017,
278+
weight: 0,
279+
priority: 0
280+
}
281+
];
282+
});
283+
const err = await this.configuration
284+
.newClient('mongodb+srv://blogs.mongodb.com', {})
285+
.connect()
286+
.catch(e => e);
287+
expect(err).to.be.instanceOf(MongoAPIError);
288+
expect(err.message).to.equal('Server record does not share hostname with parent URI');
289+
});
290+
}
291+
);
292+
});
293+
});

‎test/unit/utils.test.ts

+82-34
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { expect } from 'chai';
33
import {
44
BufferPool,
55
ByteUtils,
6+
checkParentDomainMatch,
67
compareObjectId,
78
decorateWithExplain,
89
Explain,
@@ -12,7 +13,6 @@ import {
1213
isUint8Array,
1314
LEGACY_HELLO_COMMAND,
1415
List,
15-
matchesParentDomain,
1616
MongoDBCollectionNamespace,
1717
MongoDBNamespace,
1818
MongoRuntimeError,
@@ -939,46 +939,94 @@ describe('driver utils', function () {
939939
});
940940
});
941941

942-
describe('matchesParentDomain()', () => {
943-
const exampleSrvName = 'i-love-javascript.mongodb.io';
944-
const exampleSrvNameWithDot = 'i-love-javascript.mongodb.io.';
945-
const exampleHostNameWithoutDot = 'i-love-javascript-00.mongodb.io';
946-
const exampleHostNamesWithDot = exampleHostNameWithoutDot + '.';
947-
const exampleHostNamThatDoNotMatchParent = 'i-love-javascript-00.evil-mongodb.io';
948-
const exampleHostNamThatDoNotMatchParentWithDot = 'i-love-javascript-00.evil-mongodb.io.';
942+
describe('checkParentDomainMatch()', () => {
943+
const exampleSrvName = ['i-love-js', 'i-love-js.mongodb', 'i-love-javascript.mongodb.io'];
944+
const exampleSrvNameWithDot = [
945+
'i-love-js.',
946+
'i-love-js.mongodb.',
947+
'i-love-javascript.mongodb.io.'
948+
];
949+
const exampleHostNameWithoutDot = [
950+
'js-00.i-love-js',
951+
'js-00.i-love-js.mongodb',
952+
'i-love-javascript-00.mongodb.io'
953+
];
954+
const exampleHostNamesWithDot = [
955+
'js-00.i-love-js.',
956+
'js-00.i-love-js.mongodb.',
957+
'i-love-javascript-00.mongodb.io.'
958+
];
959+
const exampleHostNameThatDoNotMatchParent = [
960+
'js-00.i-love-js-a-little',
961+
'js-00.i-love-js-a-little.mongodb',
962+
'i-love-javascript-00.evil-mongodb.io'
963+
];
964+
const exampleHostNameThatDoNotMatchParentWithDot = [
965+
'i-love-js',
966+
'',
967+
'i-love-javascript-00.evil-mongodb.io.'
968+
];
949969

950-
context('when address does not match parent domain', () => {
951-
it('without a trailing dot returns false', () => {
952-
expect(matchesParentDomain(exampleHostNamThatDoNotMatchParent, exampleSrvName)).to.be.false;
953-
});
970+
for (let num = 0; num < 3; num += 1) {
971+
context(`when srvName has ${num + 1} part${num !== 0 ? 's' : ''}`, () => {
972+
context('when address does not match parent domain', () => {
973+
it('without a trailing dot throws', () => {
974+
expect(() =>
975+
checkParentDomainMatch(exampleHostNameThatDoNotMatchParent[num], exampleSrvName[num])
976+
).to.throw('Server record does not share hostname with parent URI');
977+
});
954978

955-
it('with a trailing dot returns false', () => {
956-
expect(matchesParentDomain(exampleHostNamThatDoNotMatchParentWithDot, exampleSrvName)).to.be
957-
.false;
958-
});
959-
});
979+
it('with a trailing dot throws', () => {
980+
expect(() =>
981+
checkParentDomainMatch(
982+
exampleHostNameThatDoNotMatchParentWithDot[num],
983+
exampleSrvName[num]
984+
)
985+
).to.throw();
986+
});
987+
});
960988

961-
context('when addresses in SRV record end with a dot', () => {
962-
it('accepts address since it is considered to still match the parent domain', () => {
963-
expect(matchesParentDomain(exampleHostNamesWithDot, exampleSrvName)).to.be.true;
964-
});
965-
});
989+
context('when addresses in SRV record end with a dot', () => {
990+
it('accepts address since it is considered to still match the parent domain', () => {
991+
expect(() =>
992+
checkParentDomainMatch(exampleHostNamesWithDot[num], exampleSrvName[num])
993+
).to.not.throw();
994+
});
995+
});
966996

967-
context('when SRV host ends with a dot', () => {
968-
it('accepts address if it ends with a dot', () => {
969-
expect(matchesParentDomain(exampleHostNamesWithDot, exampleSrvNameWithDot)).to.be.true;
970-
});
997+
context('when SRV host ends with a dot', () => {
998+
it('accepts address if it ends with a dot', () => {
999+
expect(() =>
1000+
checkParentDomainMatch(exampleHostNamesWithDot[num], exampleSrvNameWithDot[num])
1001+
).to.not.throw();
1002+
});
9711003

972-
it('accepts address if it does not end with a dot', () => {
973-
expect(matchesParentDomain(exampleHostNameWithoutDot, exampleSrvName)).to.be.true;
974-
});
975-
});
1004+
it('accepts address if it does not end with a dot', () => {
1005+
expect(() =>
1006+
checkParentDomainMatch(exampleHostNameWithoutDot[num], exampleSrvNameWithDot[num])
1007+
).to.not.throw();
1008+
});
9761009

977-
context('when addresses in SRV record end without dots', () => {
978-
it('accepts address since it matches the parent domain', () => {
979-
expect(matchesParentDomain(exampleHostNamesWithDot, exampleSrvName)).to.be.true;
1010+
if (num < 2) {
1011+
it('does not accept address if it does not contain an extra domain level', () => {
1012+
expect(() =>
1013+
checkParentDomainMatch(exampleSrvNameWithDot[num], exampleSrvNameWithDot[num])
1014+
).to.throw(
1015+
'Server record does not have at least one more domain level than parent URI'
1016+
);
1017+
});
1018+
}
1019+
});
1020+
1021+
context('when addresses in SRV record end without dots', () => {
1022+
it('accepts address since it matches the parent domain', () => {
1023+
expect(() =>
1024+
checkParentDomainMatch(exampleHostNamesWithDot[num], exampleSrvName[num])
1025+
).to.not.throw();
1026+
});
1027+
});
9801028
});
981-
});
1029+
}
9821030
});
9831031

9841032
describe('isUint8Array()', () => {

0 commit comments

Comments
 (0)
Please sign in to comment.