Skip to content
Draft
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
116 changes: 102 additions & 14 deletions src/connection_string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,46 @@ import { resolveRuntimeAdapters } from './runtime_adapters';
import { ServerMonitoringMode } from './sdam/monitor';
import type { TagSet } from './sdam/server_description';
import {
checkParentDomainMatch,
DEFAULT_PK_FACTORY,
dnsNameToASCII,
emitWarning,
HostAddress,
isRecord,
normalizeDnsName,
parseInteger,
setDifference,
squashError
squashError,
verifySrvHost
} from './utils';
import { type W, WriteConcern } from './write_concern';

const VALID_TXT_RECORDS = ['authSource', 'replicaSet', 'loadBalanced'];

/** Options that only apply to SRV resolution, and so are invalid with a non-SRV connection string */
const SRV_ONLY_OPTIONS = [
'srvMaxHosts',
'srvServiceName',
'srvAllowedHostsSuffix',
'srvHostValidator'
] as const;

/** Single labels that srvAllowedHostsSuffix may be, as they are reserved for private or special use */
const RESERVED_SINGLE_LABEL_SUFFIXES = new Set([
// RFC 6761 special use names
'test',
'localhost',
'invalid',
'example',
// RFC 6762 multicast DNS
'local',
// Reserved by ICANN for private use
'internal',
// Not officially reserved by ICANN, but commonly used privately
'corp',
'home',
'mail'
]);

const LB_SINGLE_HOST_ERROR = 'loadBalanced option only supported with a single host in the URI';
const LB_REPLICA_SET_ERROR = 'loadBalanced option not supported with a replicaSet option';
const LB_DIRECT_CONNECTION_ERROR =
Expand Down Expand Up @@ -94,11 +121,11 @@ export async function resolveSRVRecord(options: MongoOptions): Promise<HostAddre
throw new MongoAPIError('No addresses found at host');
}

for (const { name } of addresses) {
checkParentDomainMatch(name, lookupAddress);
}

const hostAddresses = addresses.map(r => HostAddress.fromString(`${r.name}:${r.port ?? 27017}`));
const hostAddresses = addresses.map(({ name, port }) => {
const host = normalizeDnsName(name);
verifySrvHost(host, lookupAddress, options);
return HostAddress.fromString(`${host}:${port ?? 27017}`);
});

validateLoadBalancedOptions(hostAddresses, options, true);

Expand Down Expand Up @@ -315,6 +342,12 @@ export function parseOptions(
);
}

if (urlOptions.has('srvHostValidator')) {
throw new MongoParseError(
'URI cannot contain `srvHostValidator`, it can only be passed to the client'
);
}

const uriMechanismProperties = urlOptions.get('authMechanismProperties');
if (uriMechanismProperties) {
for (const property of uriMechanismProperties) {
Expand Down Expand Up @@ -477,22 +510,24 @@ export function parseOptions(
throw new MongoParseError('Cannot use srvMaxHosts option with replicaSet');
}

if (mongoOptions.srvAllowedHostsSuffix != null && mongoOptions.srvHostValidator != null) {
throw new MongoParseError('Cannot use srvAllowedHostsSuffix together with srvHostValidator');
}

// SRV turns on TLS by default, but users can override and turn it off
const noUserSpecifiedTLS = !objectOptions.has('tls') && !urlOptions.has('tls');
const noUserSpecifiedSSL = !objectOptions.has('ssl') && !urlOptions.has('ssl');
if (noUserSpecifiedTLS && noUserSpecifiedSSL) {
mongoOptions.tls = true;
}
} else {
const userSpecifiedSrvOptions =
urlOptions.has('srvMaxHosts') ||
objectOptions.has('srvMaxHosts') ||
urlOptions.has('srvServiceName') ||
objectOptions.has('srvServiceName');
const userSpecifiedSrvOptions = SRV_ONLY_OPTIONS.filter(
option => urlOptions.has(option) || objectOptions.has(option)
);

if (userSpecifiedSrvOptions) {
if (userSpecifiedSrvOptions.length > 0) {
throw new MongoParseError(
'Cannot use srvMaxHosts or srvServiceName with a non-srv connection string'
`Cannot use ${userSpecifiedSrvOptions.join(', ')} with a non-srv connection string`
);
}
}
Expand Down Expand Up @@ -584,6 +619,43 @@ function validateLoadBalancedOptions(
return;
}

/**
* Validates and normalizes an `srvAllowedHostsSuffix` value, following the steps in the Initial DNS
* Seedlist Discovery specification.
*
* @returns the suffix in lowercase A-label (Punycode) form, beginning with `.`
* @throws MongoParseError if the value is not a valid suffix
*/
function normalizeSrvAllowedHostsSuffix(value: string): string {
// 1. Strip leading and trailing dots
const stripped = value.replace(/^\.+|\.+$/g, '');
if (stripped === '') {
throw new MongoParseError('srvAllowedHostsSuffix must contain at least one domain label');
}

// 2 and 3. Convert to lowercase A-label form, using the WHATWG URL Standard's domain parser, the
// same conversion applied to the host names returned by the SRV lookup
const suffix = dnsNameToASCII(stripped);
if (suffix == null) {
throw new MongoParseError(`srvAllowedHostsSuffix "${value}" is not a valid domain name`);
}

// 4. Require at least two labels, unless the value is a reserved single label
if (!suffix.includes('.') && !RESERVED_SINGLE_LABEL_SUFFIXES.has(suffix)) {
throw new MongoParseError(
`srvAllowedHostsSuffix "${value}" must contain at least two domain labels, or be one of: ${[
...RESERVED_SINGLE_LABEL_SUFFIXES
].join(', ')}`
);
}

// 5. Rejecting public suffixes via the Public Suffix List is a SHOULD that is intentionally not
// implemented, matching the Java and C# drivers

// 6. Prepend a dot so the suffix only matches whole labels
return `.${suffix}`;
}

function setOption(
mongoOptions: any,
key: string,
Expand Down Expand Up @@ -1109,6 +1181,22 @@ export const OPTIONS = {
default: 0,
type: 'uint'
},
srvAllowedHostsSuffix: {
transform({ values: [value] }): string {
if (typeof value !== 'string') {
throw new MongoParseError('srvAllowedHostsSuffix must be a string');
}
return normalizeSrvAllowedHostsSuffix(value);
}
},
srvHostValidator: {
transform({ values: [value] }): unknown {
if (typeof value !== 'function') {
throw new MongoParseError('srvHostValidator must be a function');
}
return value;
}
},
srvMaxHosts: {
type: 'uint',
default: 0
Expand Down
44 changes: 44 additions & 0 deletions src/mongo_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,48 @@ export interface MongoClientOptions extends BSONSerializeOptions, SupportedNodeC
* Querying this DNS URI is expected to respond with SRV records
*/
srvServiceName?: string;
/**
* A host name suffix that every host returned by the SRV lookup must end with. It replaces the
* default verification, which requires returned hosts to share the parent domain of the SRV host
* name (the SRV host name without its leftmost label). Only valid with a `mongodb+srv` connection
* string, and cannot be combined with `srvHostValidator`.
*
* For example, with `mongodb+srv://cluster.mongodb.mydomain.net`, the default verification
* rejects `host1.us-east-1.mydomain.net`, while `srvAllowedHostsSuffix: '.mydomain.net'` accepts
* it. A leading `.` is optional. Internationalized domain names may be given in Unicode or in
* A-label (`xn--`) form: the value is converted to lowercase A-label form, and a value that cannot
* be converted is an error. The value must contain at least two labels, unless it is one of the
* names reserved for private or special use: `test`, `localhost`, `invalid`, `example`, `local`,
* `internal`, `corp`, `home`, or `mail`.
*
* **WARNING: Modifying the default SRV domain name validation can create vulnerabilities.**
* SRV host verification prevents a spoofed DNS response from directing the driver to arbitrary
* hosts, and this option widens the set of hosts such a response can direct it to. Configure the
* narrowest suffix that covers your deployment: for a seed of
* `cluster.test.internal.example.com`, prefer `.internal.example.com` over `.example.com`.
*/
srvAllowedHostsSuffix?: string;
/**
* A synchronous function that decides whether a host returned by the SRV lookup may be used. It
* receives the host name, normalized to lowercase A-label (Punycode) form without a trailing `.`,
* and must return `true` to accept the host or `false` to reject it. Its return value replaces the
* default verification entirely: the driver applies no other checks to the host. Only valid with a
* `mongodb+srv` connection string, cannot be combined with `srvAllowedHostsSuffix`, and cannot be
* set in the connection string.
*
* The function runs synchronously during SRV resolution and SRV polling, so it must not block.
* Returning anything other than a boolean, including the Promise returned by an `async` function,
* is an error.
*
* If the function throws or returns a non-boolean during the initial SRV lookup, `connect()`
* rejects; a thrown error is available as the `cause` of the resulting `MongoAPIError`. During SRV
* polling, the host is rejected instead and no error is raised.
*
* **WARNING: Modifying the default SRV domain name validation can create vulnerabilities.**
* SRV host verification prevents a spoofed DNS response from directing the driver to arbitrary
* hosts, and a validator that accepts too broadly removes that protection.
*/
srvHostValidator?: (host: string) => boolean;
/** The maximum number of connections in the connection pool. */
maxPoolSize?: number;
/** The minimum number of connections in the connection pool. */
Expand Down Expand Up @@ -1092,6 +1134,8 @@ export interface MongoOptions
appName?: string;
hosts: HostAddress[];
srvHost?: string;
srvAllowedHostsSuffix?: string;
srvHostValidator?: (host: string) => boolean;
credentials?: MongoCredentials;
readPreference: ReadPreference;
readConcern: ReadConcern;
Expand Down
15 changes: 12 additions & 3 deletions src/sdam/srv_polling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { clearTimeout, setTimeout } from 'timers';

import { MongoRuntimeError } from '../error';
import { TypedEventEmitter } from '../mongo_types';
import { checkParentDomainMatch, HostAddress, noop, squashError } from '../utils';
import { HostAddress, noop, normalizeDnsName, squashError, verifySrvHost } from '../utils';

/**
* @internal
Expand All @@ -25,6 +25,8 @@ export interface SrvPollerOptions {
srvServiceName: string;
srvMaxHosts: number;
srvHost: string;
srvAllowedHostsSuffix?: string;
srvHostValidator?: (host: string) => boolean;
heartbeatFrequencyMS: number;
}

Expand All @@ -42,6 +44,8 @@ export class SrvPoller extends TypedEventEmitter<SrvPollerEvents> {
generation: number;
srvMaxHosts: number;
srvServiceName: string;
srvAllowedHostsSuffix?: string;
srvHostValidator?: (host: string) => boolean;
_timeout?: NodeJS.Timeout;

/** @event */
Expand All @@ -58,6 +62,8 @@ export class SrvPoller extends TypedEventEmitter<SrvPollerEvents> {
this.srvHost = options.srvHost;
this.srvMaxHosts = options.srvMaxHosts ?? 0;
this.srvServiceName = options.srvServiceName ?? 'mongodb';
this.srvAllowedHostsSuffix = options.srvAllowedHostsSuffix;
this.srvHostValidator = options.srvHostValidator;
this.rescanSrvIntervalMS = 60000;
this.heartbeatFrequencyMS = options.heartbeatFrequencyMS ?? 10000;

Expand Down Expand Up @@ -129,9 +135,12 @@ export class SrvPoller extends TypedEventEmitter<SrvPollerEvents> {
const finalAddresses: dns.SrvRecord[] = [];
for (const record of srvRecords) {
try {
checkParentDomainMatch(record.name, this.srvHost);
finalAddresses.push(record);
const name = normalizeDnsName(record.name);
// A validator that throws is treated as rejecting the host, so polling continues
verifySrvHost(name, this.srvHost, this);
finalAddresses.push({ ...record, name });
} catch (error) {
// TODO(NODE-4994): log the rejected host name, as the polling spec recommends
squashError(error);
}
}
Expand Down
6 changes: 5 additions & 1 deletion src/sdam/topology.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ export interface TopologyOptions extends BSONSerializeOptions, ServerOptions {
/** The name of the replica set to connect to */
replicaSet?: string;
srvHost?: string;
srvAllowedHostsSuffix?: string;
srvHostValidator?: (host: string) => boolean;
srvPoller?: SrvPoller;
/** Indicates that a client should directly connect to a node without attempting to discover its topology type */
directConnection: boolean;
Expand Down Expand Up @@ -334,7 +336,9 @@ export class Topology extends TypedEventEmitter<TopologyEvents> {
heartbeatFrequencyMS: this.s.heartbeatFrequencyMS,
srvHost: options.srvHost,
srvMaxHosts: options.srvMaxHosts,
srvServiceName: options.srvServiceName
srvServiceName: options.srvServiceName,
srvAllowedHostsSuffix: options.srvAllowedHostsSuffix,
srvHostValidator: options.srvHostValidator
});

this.on(Topology.TOPOLOGY_DESCRIPTION_CHANGED, this.s.detectShardedTopology);
Expand Down
Loading
Loading