diff --git a/src/connection_string.ts b/src/connection_string.ts index e1c74927873..151269f1927 100644 --- a/src/connection_string.ts +++ b/src/connection_string.ts @@ -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 = @@ -94,11 +121,11 @@ export async function resolveSRVRecord(options: MongoOptions): Promise 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); @@ -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) { @@ -477,6 +510,10 @@ 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'); @@ -484,15 +521,13 @@ export function parseOptions( 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` ); } } @@ -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, @@ -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 diff --git a/src/mongo_client.ts b/src/mongo_client.ts index f4c0cde2eb4..fda2715840d 100644 --- a/src/mongo_client.ts +++ b/src/mongo_client.ts @@ -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. */ @@ -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; diff --git a/src/sdam/srv_polling.ts b/src/sdam/srv_polling.ts index cfc4779cf25..56fb2509fbe 100644 --- a/src/sdam/srv_polling.ts +++ b/src/sdam/srv_polling.ts @@ -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 @@ -25,6 +25,8 @@ export interface SrvPollerOptions { srvServiceName: string; srvMaxHosts: number; srvHost: string; + srvAllowedHostsSuffix?: string; + srvHostValidator?: (host: string) => boolean; heartbeatFrequencyMS: number; } @@ -42,6 +44,8 @@ export class SrvPoller extends TypedEventEmitter { generation: number; srvMaxHosts: number; srvServiceName: string; + srvAllowedHostsSuffix?: string; + srvHostValidator?: (host: string) => boolean; _timeout?: NodeJS.Timeout; /** @event */ @@ -58,6 +62,8 @@ export class SrvPoller extends TypedEventEmitter { 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; @@ -129,9 +135,12 @@ export class SrvPoller extends TypedEventEmitter { 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); } } diff --git a/src/sdam/topology.ts b/src/sdam/topology.ts index 36228f75947..a27ea95e2d5 100644 --- a/src/sdam/topology.ts +++ b/src/sdam/topology.ts @@ -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; @@ -334,7 +336,9 @@ export class Topology extends TypedEventEmitter { 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); diff --git a/src/utils.ts b/src/utils.ts index d9a96115fc8..ed43b827a4c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -4,6 +4,7 @@ import { promises as fs } from 'fs'; import * as http from 'http'; import * as process from 'process'; import { clearTimeout, setTimeout } from 'timers'; +import { domainToASCII } from 'url'; import { ByteUtils, @@ -29,7 +30,7 @@ import { MongoParseError, MongoRuntimeError } from './error'; -import type { MongoClient } from './mongo_client'; +import type { MongoClient, MongoOptions } from './mongo_client'; import { type Abortable } from './mongo_types'; import type { CommandOperationOptions, OperationParent } from './operations/command'; import type { Hint, OperationOptions } from './operations/operation'; @@ -1156,9 +1157,8 @@ export function parseUnsignedInteger(value: unknown): number | null { * @returns void */ export function checkParentDomainMatch(address: string, srvHost: string): void { - // Remove trailing dot if exists on either the resolved address or the srv hostname - const normalizedAddress = address.endsWith('.') ? address.slice(0, address.length - 1) : address; - const normalizedSrvHost = srvHost.endsWith('.') ? srvHost.slice(0, srvHost.length - 1) : srvHost; + const normalizedAddress = normalizeDnsName(address); + const normalizedSrvHost = normalizeDnsName(srvHost); const allCharacterBeforeFirstDot = /^.*?\./; const srvIsLessThanThreeParts = normalizedSrvHost.split('.').length < 3; @@ -1187,6 +1187,96 @@ export function checkParentDomainMatch(address: string, srvHost: string): void { } } +/** + * Forbidden domain code points, per the WHATWG URL Standard + */ +// eslint-disable-next-line no-control-regex +const FORBIDDEN_DOMAIN_CODE_POINTS = /[\u0000- \u007F#%/:<>?@[\\\]^|]/; + +/** + * Converts a DNS name to its lowercase A-label (Punycode) form, using the WHATWG URL Standard's + * domain parser (with beStrict false). + * + * @returns the converted name, or `null` if the name cannot be converted + */ +export function dnsNameToASCII(name: string): string | null { + if (FORBIDDEN_DOMAIN_CODE_POINTS.test(name)) { + return null; + } + // The standard returns an ASCII name lowercased, even if its `xn--` labels are not valid A-labels + // (https://github.com/whatwg/url/commit/a8d5ca3716c3). Node.js only does so from 24.20.0, which + // updated Ada to 4.0.0, so it is done here to behave the same on every Node.js version. + const ascii = /^\p{ASCII}*$/u.test(name) ? name.toLowerCase() : domainToASCII(name); + return ascii === '' || FORBIDDEN_DOMAIN_CODE_POINTS.test(ascii) ? null : ascii; +} + +/** + * Normalizes a DNS host name so it can be compared against another normalized host name, as + * required by the Initial DNS Seedlist Discovery specification: a trailing `.` is removed, and the + * name is converted to its lowercase A-label (Punycode) form. + * + * @throws MongoAPIError if the name is not a valid DNS host name + */ +export function normalizeDnsName(name: string): string { + const withoutTrailingDot = name.endsWith('.') ? name.slice(0, -1) : name; + const normalized = dnsNameToASCII(withoutTrailingDot); + if (normalized == null) { + throw new MongoAPIError(`Invalid DNS host name "${name}"`); + } + return normalized; +} + +/** + * Verifies a host name returned by an SRV lookup. Exactly one verification applies: + * - with `srvHostValidator`, the validator's return value is the complete verdict + * - with `srvAllowedHostsSuffix`, the host must end with the (already normalized) suffix + * - otherwise, the host must share the parent domain of `srvHost`, see {@link checkParentDomainMatch} + * + * @param host - A host name returned by an SRV lookup, already normalized with {@link normalizeDnsName} + * @param srvHost - The host from the `mongodb+srv` connection string + * @throws MongoAPIError if the host fails verification or the validator throws + * @throws MongoInvalidArgumentError if the validator returns a non-boolean value + */ +export function verifySrvHost( + host: string, + srvHost: string, + options: Pick +): void { + const { srvAllowedHostsSuffix, srvHostValidator } = options; + + if (srvHostValidator != null) { + let isValid: unknown; + try { + isValid = srvHostValidator(host); + } catch (error) { + throw new MongoAPIError(`srvHostValidator threw an error while validating "${host}"`, { + cause: error + }); + } + // A Promise (e.g. from an async validator) is truthy, so accepting it would silently allow every host + if (typeof isValid !== 'boolean') { + throw new MongoInvalidArgumentError( + `srvHostValidator must return a boolean, received ${typeof isValid}` + ); + } + if (!isValid) { + throw new MongoAPIError(`Server record "${host}" was rejected by srvHostValidator`); + } + return; + } + + if (srvAllowedHostsSuffix != null) { + if (!host.endsWith(srvAllowedHostsSuffix)) { + throw new MongoAPIError( + `Server record "${host}" does not end with srvAllowedHostsSuffix "${srvAllowedHostsSuffix}"` + ); + } + return; + } + + checkParentDomainMatch(host, srvHost); +} + /** * Perform a get request that returns status and body. * @internal diff --git a/test/integration/initial-dns-seedlist-discovery/initial_dns_seedlist_discovery.prose.test.ts b/test/integration/initial-dns-seedlist-discovery/initial_dns_seedlist_discovery.prose.test.ts index 2a2e7a6450f..25dcb477e5b 100644 --- a/test/integration/initial-dns-seedlist-discovery/initial_dns_seedlist_discovery.prose.test.ts +++ b/test/integration/initial-dns-seedlist-discovery/initial_dns_seedlist_discovery.prose.test.ts @@ -2,9 +2,36 @@ import { expect } from 'chai'; import * as dns from 'dns'; import * as sinon from 'sinon'; -import { ConnectionPool, MongoAPIError, Server, ServerDescription, Topology } from '../../mongodb'; +import { + ConnectionPool, + MongoAPIError, + type MongoClient, + MongoInvalidArgumentError, + MongoParseError, + resolveSRVRecord, + Server, + ServerDescription, + Topology +} from '../../mongodb'; import { topologyWithPlaceholderClient } from '../../tools/utils'; +/** Stubs DNS so the SRV lookup returns `names` and the TXT lookup returns no records */ +function stubSrvLookup(...names: string[]) { + const stub = sinon.stub(dns.promises, 'resolve'); + stub.withArgs(sinon.match.string, 'SRV').callsFake(async () => { + return names.map(name => ({ name, port: 27017, weight: 0, priority: 0 })); + }); + stub.withArgs(sinon.match.string, 'TXT').callsFake(async () => { + throw { code: 'ENODATA' }; + }); +} + +/** Performs the initial SRV lookup for `client`, returning the host names of the resulting seedlist */ +async function resolveSeedlist(client: MongoClient): Promise { + const hosts = await resolveSRVRecord(client.options); + return hosts.map(host => host.toHostPort().host); +} + describe('Initial DNS Seedlist Discovery (Prose Tests)', () => { describe('1. Allow SRVs with fewer than 3 . separated parts', function () { context('when running validation on an SRV string before DNS resolution', function () { @@ -307,4 +334,259 @@ describe('Initial DNS Seedlist Discovery (Prose Tests)', () => { } ); }); + + describe('5. srvHostValidator accepts a host the default verification would reject', function () { + /** + * When srvHostValidator is configured, it replaces the default verification entirely, so a returned + * address that the default verification check would reject must be accepted if the validator returns true. + * Configure a validator that returns true for every host name, then run each of the following cases: + * - the SRV mongodb+srv://blogs.mongodb.com resolving to blogs.evil.com, which does not share the SRV's + * domain name, produces a seedlist containing blogs.evil.com + * - the SRV mongodb+srv://mongo.local resolving to mongo.local, which does not add a domain level to an + * SRV hostname with fewer than three . separated parts, produces a seedlist containing mongo.local + */ + let client: MongoClient; + + afterEach(async function () { + sinon.restore(); + await client?.close(); + }); + + it('accepts a host that does not share the SRV domain name', async function () { + stubSrvLookup('blogs.evil.com'); + client = this.configuration.newClient('mongodb+srv://blogs.mongodb.com', { + srvHostValidator: () => true + }); + expect(await resolveSeedlist(client)).to.deep.equal(['blogs.evil.com']); + }); + + it('accepts a host that does not add a domain level to the SRV hostname', async function () { + stubSrvLookup('mongo.local'); + client = this.configuration.newClient('mongodb+srv://mongo.local', { + srvHostValidator: () => true + }); + expect(await resolveSeedlist(client)).to.deep.equal(['mongo.local']); + }); + }); + + describe('6. Reject a host the default verification would accept', function () { + /** + * Configure a validator that returns false for every host name and assert that the SRV + * mongodb+srv://blogs.mongodb.com resolving to cluster.mongodb.com throws an error, even though the + * returned address shares the SRV's domain name. + */ + afterEach(async function () { + sinon.restore(); + }); + + it('throws when the validator rejects the host', async function () { + stubSrvLookup('cluster.mongodb.com'); + const err = await this.configuration + .newClient('mongodb+srv://blogs.mongodb.com', { srvHostValidator: () => false }) + .connect() + .catch(e => e); + expect(err).to.be.instanceOf(MongoAPIError); + expect(err.message).to.equal( + 'Server record "cluster.mongodb.com" was rejected by srvHostValidator' + ); + }); + }); + + describe('7. The validator receives the normalized host name', function () { + /** + * The returned address is normalized before verification, so the validator must be passed the normalized + * form rather than the address exactly as returned by DNS. + * Configure a validator that records the host names it is passed and returns true, then run the SRV + * mongodb+srv://blogs.mongodb.com resolving to CLUSTER.MONGODB.COM. and assert that the validator was + * passed cluster.mongodb.com. + */ + let client: MongoClient; + + afterEach(async function () { + sinon.restore(); + await client?.close(); + }); + + it('passes the normalized host name to the validator', async function () { + stubSrvLookup('CLUSTER.MONGODB.COM.'); + const validatedHosts: string[] = []; + client = this.configuration.newClient('mongodb+srv://blogs.mongodb.com', { + srvHostValidator: host => { + validatedHosts.push(host); + return true; + } + }); + await resolveSeedlist(client); + expect(validatedHosts).to.deep.equal(['cluster.mongodb.com']); + }); + }); + + describe('8. Wrap an error raised by the validator', function () { + /** + * When the validator raises an error during initial seedlist resolution, the driver must catch it and + * re-raise it wrapped in a driver error rather than letting it propagate unchanged. + * Configure a validator that raises an error and assert that the SRV mongodb+srv://blogs.mongodb.com + * resolving to cluster.mongodb.com throws an error which retains the error raised by the validator. + */ + afterEach(async function () { + sinon.restore(); + }); + + it('wraps the error in a driver error that retains it as the cause', async function () { + stubSrvLookup('cluster.mongodb.com'); + const validatorError = new Error('validator failed'); + const err = await this.configuration + .newClient('mongodb+srv://blogs.mongodb.com', { + srvHostValidator: () => { + throw validatorError; + } + }) + .connect() + .catch(e => e); + expect(err).to.be.instanceOf(MongoAPIError); + expect(err).to.have.property('cause', validatorError); + }); + }); + + describe('9. Throw when both srvAllowedHostsSuffix and srvHostValidator are configured', function () { + /** + * The two options are mutually exclusive. + * Assert that configuring a MongoClient with both srvAllowedHostsSuffix=.mongodb.com and any + * srvHostValidator throws an error. + */ + it('throws', function () { + expect(() => + this.configuration.newClient( + 'mongodb+srv://blogs.mongodb.com/?srvAllowedHostsSuffix=.mongodb.com', + { srvHostValidator: () => true } + ) + ).to.throw( + MongoParseError, + 'Cannot use srvAllowedHostsSuffix together with srvHostValidator' + ); + }); + }); + + describe('10. Accept a mixed case returned address with srvAllowedHostsSuffix', function () { + /** + * Returned addresses must be normalized before verification. + * Configure a MongoClient with srvAllowedHostsSuffix=.mongodb.com and assert that the SRV + * mongodb+srv://blogs.mongodb.com resolving to CLUSTER.MONGODB.COM. produces a seedlist containing + * cluster.mongodb.com. + */ + let client: MongoClient; + + afterEach(async function () { + sinon.restore(); + await client?.close(); + }); + + it('produces a seedlist containing the normalized host', async function () { + stubSrvLookup('CLUSTER.MONGODB.COM.'); + client = this.configuration.newClient( + 'mongodb+srv://blogs.mongodb.com/?srvAllowedHostsSuffix=.mongodb.com' + ); + expect(await resolveSeedlist(client)).to.deep.equal(['cluster.mongodb.com']); + }); + }); + + describe('11. Throw when srvHostValidator is not callable', function () { + /** + * Assert that configuring a MongoClient with a srvHostValidator that is not callable, such as the string + * "notacallable", throws an error. + */ + it('throws', function () { + expect(() => + this.configuration.newClient('mongodb+srv://blogs.mongodb.com', { + // @ts-expect-error: srvHostValidator must be a function + srvHostValidator: 'notacallable' + }) + ).to.throw(MongoParseError, 'srvHostValidator must be a function'); + }); + }); + + describe('12. Accept a reserved single label as srvAllowedHostsSuffix', function () { + /** + * A single label is a public suffix under the Public Suffix List's * rule, but the names reserved for + * private or special use must be accepted despite that. + * Configure a MongoClient with srvAllowedHostsSuffix=localhost and assert that the SRV + * mongodb+srv://cluster.localhost resolving to db.cluster.localhost produces a seedlist containing + * db.cluster.localhost. + */ + let client: MongoClient; + + afterEach(async function () { + sinon.restore(); + await client?.close(); + }); + + it('produces a seedlist containing the host', async function () { + stubSrvLookup('db.cluster.localhost'); + client = this.configuration.newClient( + 'mongodb+srv://cluster.localhost/?srvAllowedHostsSuffix=localhost' + ); + expect(await resolveSeedlist(client)).to.deep.equal(['db.cluster.localhost']); + }); + }); + + describe('13. Throw when srvHostValidator is used with a non-SRV URI', function () { + /** + * srvHostValidator only has an effect on SRV resolution, so it MUST NOT be accepted alongside a non-SRV URI. + * Assert that configuring a MongoClient with any srvHostValidator and the non-SRV URI + * mongodb://localhost:27017 throws an error. + */ + it('throws', function () { + expect(() => + this.configuration.newClient('mongodb://localhost:27017', { srvHostValidator: () => true }) + ).to.throw(MongoParseError, 'Cannot use srvHostValidator with a non-srv connection string'); + }); + }); + + describe('14. Throw when srvHostValidator returns a non-boolean value', function () { + /** + * During initial seedlist resolution, a validator that returns a value that is not a bool results in an error. + * Configure a validator that returns the string "true" and assert that the SRV mongodb+srv://blogs.mongodb.com + * resolving to cluster.mongodb.com throws an error. + */ + afterEach(async function () { + sinon.restore(); + }); + + it('throws', async function () { + stubSrvLookup('cluster.mongodb.com'); + const err = await this.configuration + .newClient('mongodb+srv://blogs.mongodb.com', { + // @ts-expect-error: srvHostValidator must return a boolean + srvHostValidator: () => 'true' + }) + .connect() + .catch(e => e); + expect(err).to.be.instanceOf(MongoInvalidArgumentError); + expect(err.message).to.equal('srvHostValidator must return a boolean, received string'); + }); + }); + + describe('15. Accept an underscore in srvAllowedHostsSuffix', function () { + /** + * Drivers MUST NOT apply hostname syntax validation to srvAllowedHostsSuffix beyond the listed steps, so a value + * containing an underscore must be accepted. + * Configure a MongoClient with srvAllowedHostsSuffix=.my_domain.net and assert that the SRV + * mongodb+srv://blogs.my_domain.net resolving to cluster.my_domain.net produces a seedlist containing + * cluster.my_domain.net. + */ + let client: MongoClient; + + afterEach(async function () { + sinon.restore(); + await client?.close(); + }); + + it('produces a seedlist containing the host', async function () { + stubSrvLookup('cluster.my_domain.net'); + client = this.configuration.newClient( + 'mongodb+srv://blogs.my_domain.net/?srvAllowedHostsSuffix=.my_domain.net' + ); + expect(await resolveSeedlist(client)).to.deep.equal(['cluster.my_domain.net']); + }); + }); }); diff --git a/test/mongodb_bundled.ts b/test/mongodb_bundled.ts index 307a9e015c2..717ff9c86f5 100644 --- a/test/mongodb_bundled.ts +++ b/test/mongodb_bundled.ts @@ -29,6 +29,7 @@ export const { aws4Sign, AWSSDKCredentialProvider, azureCallback, + BASE_BACKOFF_MS, Binary, BSON, BSONError, @@ -42,6 +43,7 @@ export const { buildReplaceOneOperation, buildUpdateManyOperation, buildUpdateOneOperation, + calculateBaseBackoffMS, CallbackWorkflow, CancellationToken, ChangeStream, @@ -231,6 +233,7 @@ export const { needsRetryableWriteLabel, NODE_IS_RECOVERING_ERROR_MESSAGE, noop, + normalizeDnsName, ns, ObjectId, OIDC_VERSION, diff --git a/test/spec/initial-dns-seedlist-discovery/README.md b/test/spec/initial-dns-seedlist-discovery/README.md new file mode 100644 index 00000000000..50a11877230 --- /dev/null +++ b/test/spec/initial-dns-seedlist-discovery/README.md @@ -0,0 +1,274 @@ +# Initial DNS Seedlist Discovery tests + +This directory contains platform-independent tests that drivers can use to prove their conformance to the Initial DNS +Seedlist Discovery spec. + +## Prose Tests + +For the following prose tests, it is assumed drivers are be able to stub DNS results to easily test invalid DNS +resolution results. + +### 1. Allow SRVs with fewer than 3 `.` separated parts + +When running validation on an SRV string before DNS resolution, do not throw a error due to number of SRV parts. + +- `mongodb+srv://localhost` +- `mongodb+srv://mongo.local` + +### 2. Throw when return address does not end with SRV domain + +When given a returned address that does NOT end with the original SRV's domain name, throw a runtime error. + +For this test, run each of the following cases: + +- the SRV `mongodb+srv://localhost` resolving to `localhost.mongodb` +- the SRV `mongodb+srv://mongo.local` resolving to `test_1.evil.local` +- the SRV `mongodb+srv://blogs.mongodb.com` resolving to `blogs.evil.com` + +Remember, the domain of an SRV with one or two `.` separated parts is the SRVs entire hostname. + +### 3. Throw when return address is identical to SRV hostname + +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. + +For this test, run each of the following cases: + +- the SRV `mongodb+srv://localhost` resolving to `localhost` +- the SRV `mongodb+srv://mongo.local` resolving to `mongo.local` + +### 4. Throw when return address does not contain `.` separating shared part of domain + +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. + +For this test, run each of the following cases: + +- the SRV `mongodb+srv://localhost` resolving to `test_1.cluster_1localhost` +- the SRV `mongodb+srv://mongo.local` resolving to `test_1.my_hostmongo.local` +- the SRV `mongodb+srv://blogs.mongodb.com` resolving to `cluster.testmongodb.com` + +### 5. srvHostValidator accepts a host the default verification would reject + +When `srvHostValidator` is configured, it replaces the default verification entirely, so a returned address that the +default verification check would reject must be accepted if the validator returns `true`. + +Configure a validator that returns `true` for every host name, then run each of the following cases: + +- the SRV `mongodb+srv://blogs.mongodb.com` resolving to `blogs.evil.com`, which does not share the SRV's domain name, + produces a seedlist containing `blogs.evil.com` +- the SRV `mongodb+srv://mongo.local` resolving to `mongo.local`, which does not add a domain level to an SRV hostname + with fewer than three `.` separated parts, produces a seedlist containing `mongo.local` + +### 6. Reject a host the default verification would accept + +Configure a validator that returns `false` for every host name and assert that the SRV `mongodb+srv://blogs.mongodb.com` +resolving to `cluster.mongodb.com` throws an error, even though the returned address shares the SRV's domain name. + +### 7. The validator receives the normalized host name + +The returned address is normalized before verification, so the validator must be passed the normalized form rather than +the address exactly as returned by DNS. + +Configure a validator that records the host names it is passed and returns `true`, then run the SRV +`mongodb+srv://blogs.mongodb.com` resolving to `CLUSTER.MONGODB.COM.` and assert that the validator was passed +`cluster.mongodb.com`. + +### 8. Wrap an error raised by the validator + +When the validator raises an error during initial seedlist resolution, the driver must catch it and re-raise it wrapped +in a driver error rather than letting it propagate unchanged. + +Configure a validator that raises an error and assert that the SRV `mongodb+srv://blogs.mongodb.com` resolving to +`cluster.mongodb.com` throws an error which retains the error raised by the validator. + +### 9. Throw when both `srvAllowedHostsSuffix` and `srvHostValidator` are configured + +The two options are mutually exclusive. + +Assert that configuring a MongoClient with both `srvAllowedHostsSuffix=.mongodb.com` and any `srvHostValidator` throws +an error. + +### 10. Accept a mixed case returned address with `srvAllowedHostsSuffix` + +Returned addresses must be normalized before verification. + +Configure a MongoClient with `srvAllowedHostsSuffix=.mongodb.com` and assert that the SRV +`mongodb+srv://blogs.mongodb.com` resolving to `CLUSTER.MONGODB.COM.` produces a seedlist containing +`cluster.mongodb.com`. + +### 11. Throw when `srvHostValidator` is not callable + +Drivers whose language cannot express a non-callable value for `srvHostValidator` -- because the type is checked when +the program is compiled -- MUST skip this test. + +Assert that configuring a MongoClient with a `srvHostValidator` that is not callable, such as the string +`"notacallable"`, throws a error. + +### 12. Accept a reserved single label as `srvAllowedHostsSuffix` + +A single label is a public suffix under the Public Suffix List's `*` rule, but the names reserved for private or special +use listed in [srvAllowedHostsSuffix](../initial-dns-seedlist-discovery.md#srvallowedhostssuffix) must be accepted +despite that. + +Configure a MongoClient with `srvAllowedHostsSuffix=localhost` and assert that the SRV `mongodb+srv://cluster.localhost` +resolving to `db.cluster.localhost` produces a seedlist containing `db.cluster.localhost`. + +### 13. Throw when `srvHostValidator` is used with a non-SRV URI + +`srvHostValidator` only has an effect on SRV resolution, so it MUST NOT be accepted alongside a non-SRV URI. + +Assert that configuring a MongoClient with any `srvHostValidator` and the non-SRV URI `mongodb://localhost:27017` throws +an error. + +### 14. Throw when `srvHostValidator` returns a non-boolean value + +Drivers whose language cannot express a non-boolean return value for `srvHostValidator` -- because the type is checked +when the program is compiled -- MUST skip this test. During initial seedlist resolution, a validator that returns a +value that is not a bool results in an error. + +Configure a validator that returns the string `"true"` and assert that the SRV `mongodb+srv://blogs.mongodb.com` +resolving to `cluster.mongodb.com` throws an error. + +### 15. Accept an underscore in `srvAllowedHostsSuffix` + +Drivers MUST NOT apply hostname syntax validation to `srvAllowedHostsSuffix` beyond the steps listed in +[srvAllowedHostsSuffix](../initial-dns-seedlist-discovery.md#srvallowedhostssuffix), so a value containing an underscore +must be accepted. + +Configure a MongoClient with `srvAllowedHostsSuffix=.my_domain.net` and assert that the SRV +`mongodb+srv://blogs.my_domain.net` resolving to `cluster.my_domain.net` produces a seedlist containing +`cluster.my_domain.net`. + +## Test Setup + +The tests in the `replica-set` directory MUST be executed against a three-node replica set on localhost ports 27017, +27018, and 27019 with replica set name `repl0`. + +The tests in the `load-balanced` directory MUST be executed against a load-balanced sharded cluster with the mongos +servers running on localhost ports 27017 and 27018 and `--loadBalancerPort` 27050 and 27051, respectively (corresponding +to the script in +[drivers-evergreen-tools](https://github.com/mongodb-labs/drivers-evergreen-tools/blob/master/.evergreen/run-load-balancer.sh)). +The load balancers, shard servers, and config servers may run on any open ports. + +The tests in the `sharded` directory MUST be executed against a sharded cluster with the mongos servers running on +localhost ports 27017 and 27018. Shard servers and config servers may run on any open ports. + +In all cases, the clusters MUST be started with SSL enabled. + +To run the tests that accompany this spec, you need to configure the SRV and TXT records with a real name server. The +following records are required for these tests: + +```dns +Record TTL Class Address +localhost.test.build.10gen.cc. 86400 IN A 127.0.0.1 +localhost.sub.test.build.10gen.cc. 86400 IN A 127.0.0.1 + +Record TTL Class Port Target +_mongodb._tcp.test1.test.build.10gen.cc. 86400 IN SRV 27017 localhost.test.build.10gen.cc. +_mongodb._tcp.test1.test.build.10gen.cc. 86400 IN SRV 27018 localhost.test.build.10gen.cc. +_mongodb._tcp.test2.test.build.10gen.cc. 86400 IN SRV 27018 localhost.test.build.10gen.cc. +_mongodb._tcp.test2.test.build.10gen.cc. 86400 IN SRV 27019 localhost.test.build.10gen.cc. +_mongodb._tcp.test3.test.build.10gen.cc. 86400 IN SRV 27017 localhost.test.build.10gen.cc. +_mongodb._tcp.test5.test.build.10gen.cc. 86400 IN SRV 27017 localhost.test.build.10gen.cc. +_mongodb._tcp.test6.test.build.10gen.cc. 86400 IN SRV 27017 localhost.test.build.10gen.cc. +_mongodb._tcp.test7.test.build.10gen.cc. 86400 IN SRV 27017 localhost.test.build.10gen.cc. +_mongodb._tcp.test8.test.build.10gen.cc. 86400 IN SRV 27017 localhost.test.build.10gen.cc. +_mongodb._tcp.test10.test.build.10gen.cc. 86400 IN SRV 27017 localhost.test.build.10gen.cc. +_mongodb._tcp.test11.test.build.10gen.cc. 86400 IN SRV 27017 localhost.test.build.10gen.cc. +_mongodb._tcp.test12.test.build.10gen.cc. 86400 IN SRV 27017 localhost.build.10gen.cc. +_mongodb._tcp.test13.test.build.10gen.cc. 86400 IN SRV 27017 test.build.10gen.cc. +_mongodb._tcp.test14.test.build.10gen.cc. 86400 IN SRV 27017 localhost.not-test.build.10gen.cc. +_mongodb._tcp.test15.test.build.10gen.cc. 86400 IN SRV 27017 localhost.test.not-build.10gen.cc. +_mongodb._tcp.test16.test.build.10gen.cc. 86400 IN SRV 27017 localhost.test.build.not-10gen.cc. +_mongodb._tcp.test17.test.build.10gen.cc. 86400 IN SRV 27017 localhost.test.build.10gen.not-cc. +_mongodb._tcp.test18.test.build.10gen.cc. 86400 IN SRV 27017 localhost.sub.test.build.10gen.cc. +_mongodb._tcp.test19.test.build.10gen.cc. 86400 IN SRV 27017 localhost.evil.build.10gen.cc. +_mongodb._tcp.test19.test.build.10gen.cc. 86400 IN SRV 27017 localhost.test.build.10gen.cc. +_mongodb._tcp.test20.test.build.10gen.cc. 86400 IN SRV 27017 localhost.test.build.10gen.cc. +_mongodb._tcp.test21.test.build.10gen.cc. 86400 IN SRV 27017 localhost.test.build.10gen.cc. +_customname._tcp.test22.test.build.10gen.cc 86400 IN SRV 27017 localhost.test.build.10gen.cc. +_mongodb._tcp.test23.test.build.10gen.cc. 86400 IN SRV 8000 localhost.test.build.10gen.cc. +_mongodb._tcp.test24.test.build.10gen.cc. 86400 IN SRV 8000 localhost.test.build.10gen.cc. + +Record TTL Class Text +test5.test.build.10gen.cc. 86400 IN TXT "replicaSet=repl0&authSource=thisDB" +test6.test.build.10gen.cc. 86400 IN TXT "replicaSet=repl0" +test6.test.build.10gen.cc. 86400 IN TXT "authSource=otherDB" +test7.test.build.10gen.cc. 86400 IN TXT "ssl=false" +test8.test.build.10gen.cc. 86400 IN TXT "authSource" +test10.test.build.10gen.cc. 86400 IN TXT "socketTimeoutMS=500" +test11.test.build.10gen.cc. 86400 IN TXT "replicaS" "et=rep" "l0" +test20.test.build.10gen.cc. 86400 IN TXT "loadBalanced=true" +test21.test.build.10gen.cc. 86400 IN TXT "loadBalanced=false" +test24.test.build.10gen.cc. 86400 IN TXT "loadBalanced=true" +``` + +Notes: + +- `test4` is omitted deliberately to test what happens with no SRV record. +- `test9` is missing because it was deleted during the development of the tests. +- The missing `test.` sub-domain in the SRV record target for `test12` is deliberate. +- `test22` is used to test a custom service name (`customname`). +- `test23` and `test24` point to port 8000 (HAProxy) and are used for load-balanced tests. + +In our tests we have used `localhost.test.build.10gen.cc` as the domain, and then configured +`localhost.test.build.10gen.cc` to resolve to 127.0.0.1. + +You need to adapt the records shown above to replace `test.build.10gen.cc` with your own domain name, and update the +"uri" field in the YAML or JSON files in this directory with the actual domain. + +## Test Format and Use + +These YAML and JSON files contain the following fields: + +- `uri`: a `mongodb+srv` connection string +- `seeds`: the expected set of initial seeds discovered from the SRV record +- `numSeeds`: the expected number of initial seeds discovered from the SRV record. This is mainly used to test + `srvMaxHosts`, since randomly selected hosts cannot be deterministically asserted. +- `hosts`: the discovered topology's list of hosts once SDAM completes a scan +- `numHosts`: the expected number of hosts discovered once SDAM completes a scan. This is mainly used to test + `srvMaxHosts`, since randomly selected hosts cannot be deterministically asserted. +- `options`: the parsed [URI options](../../uri-options/uri-options.md) as discovered from the + [Connection String](../../connection-string/connection-string-spec.md)'s "Connection Options" component and SRV + resolution (e.g. TXT records, implicit `tls` default). +- `parsed_options`: additional, parsed options from other + [Connection String](../../connection-string/connection-string-spec.md) components. This is mainly used for asserting + `UserInfo` (as `user` and `password`) and `Auth database` (as `auth_database`). +- `error`: indicates that the parsing of the URI, or the resolving or contents of the SRV or TXT records included + errors. +- `comment`: a comment to indicate why a test would fail. +- `ping`: if false, the test runner should not run a "ping" operation. + +For each YAML file: + +- Create a MongoClient initialized with the `mongodb+srv` connection string. +- Run a "ping" operation unless `ping` is false or `error` is true. + +Assertions: + +- If `seeds` is specified, drivers SHOULD verify that the set of hosts in the client's initial seedlist matches the list + in `seeds`. If `numSeeds` is specified, drivers SHOULD verify that the size of that set matches `numSeeds`. + +- If `hosts` is specified, drivers MUST verify that the set of ServerDescriptions in the client's TopologyDescription + eventually matches the list in `hosts`. If `numHosts` is specified, drivers MUST verify that the size of that set + matches `numHosts`. + +- If `options` is specified, drivers MUST verify each of the values under `options` match the MongoClient's parsed value + for that option. There may be other options parsed by the MongoClient as well, which a test does not verify. + +- If `parsed_options` is specified, drivers MUST verify that each of the values under `parsed_options` match the + MongoClient's parsed value for that option. Supported values include, but are not limited to, `user` and `password` + (parsed from `UserInfo`) and `auth_database` (parsed from `Auth database`). + +- If `error` is specified and `true`, drivers MUST verify that initializing the MongoClient throws an error. If `error` + is not specified or is `false`, both initializing the MongoClient and running a ping operation must succeed without + throwing any errors. + +- If `ping` is not specified or `true`, drivers MUST verify that running a "ping" operation using the initialized + MongoClient succeeds. If `ping` is `false`, drivers MUST NOT run a "ping" operation. + + > **Note:** These tests are expected to be run against MongoDB databases with and without authentication enabled. The + > "ping" operation does not require authentication so should succeed with URIs that contain no userinfo (i.e. no + > username and password). Tests with URIs that contain userinfo always set `ping` to `false` because some drivers will + > fail handshake on a connection if userinfo is provided but incorrect. diff --git a/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-case-insensitive.json b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-case-insensitive.json new file mode 100644 index 00000000000..3b1f67fec9c --- /dev/null +++ b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-case-insensitive.json @@ -0,0 +1,10 @@ +{ + "uri": "mongodb+srv://test12.test.build.10gen.cc/?srvAllowedHostsSuffix=.BUILD.10GEN.CC", + "seeds": [ + "localhost.build.10gen.cc:27017" + ], + "options": { + "ssl": true + }, + "ping": false +} diff --git a/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-case-insensitive.yml b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-case-insensitive.yml new file mode 100644 index 00000000000..7957ba1f8c4 --- /dev/null +++ b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-case-insensitive.yml @@ -0,0 +1,8 @@ +# Tests that srvAllowedHostsSuffix comparison is case-insensitive (ASCII case folding per RFC 4343). +# The suffix .BUILD.10GEN.CC is uppercase but should match the returned host localhost.build.10gen.cc. +uri: "mongodb+srv://test12.test.build.10gen.cc/?srvAllowedHostsSuffix=.BUILD.10GEN.CC" +seeds: + - localhost.build.10gen.cc:27017 +options: + ssl: true +ping: false diff --git a/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-mismatch.json b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-mismatch.json new file mode 100644 index 00000000000..56e26524c46 --- /dev/null +++ b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-mismatch.json @@ -0,0 +1,6 @@ +{ + "uri": "mongodb+srv://test12.test.build.10gen.cc/?srvAllowedHostsSuffix=test.build.10gen.cc", + "seeds": [], + "hosts": [], + "error": true +} diff --git a/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-mismatch.yml b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-mismatch.yml new file mode 100644 index 00000000000..db6ee9811b7 --- /dev/null +++ b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-mismatch.yml @@ -0,0 +1,5 @@ +# DNS record for test12.test.build.10gen.cc returns localhost.build.10gen.cc which would not match test.build.10gen.cc +uri: "mongodb+srv://test12.test.build.10gen.cc/?srvAllowedHostsSuffix=test.build.10gen.cc" +seeds: [] +hosts: [] +error: true diff --git a/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-period-only.json b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-period-only.json new file mode 100644 index 00000000000..f3689428c10 --- /dev/null +++ b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-period-only.json @@ -0,0 +1,6 @@ +{ + "uri": "mongodb+srv://test1.test.build.10gen.cc/?srvAllowedHostsSuffix=.", + "seeds": [], + "hosts": [], + "error": true +} diff --git a/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-period-only.yml b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-period-only.yml new file mode 100644 index 00000000000..bd6621e8335 --- /dev/null +++ b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-period-only.yml @@ -0,0 +1,7 @@ +# srvAllowedHostsSuffix MUST name at least one label. A value of "." is empty once the +# leading and trailing "." are stripped, so it names no domain at all and MUST raise an +# error rather than being treated as a suffix that every host matches. +uri: "mongodb+srv://test1.test.build.10gen.cc/?srvAllowedHostsSuffix=." +seeds: [] +hosts: [] +error: true diff --git a/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-psl-not-public-suffix.json b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-psl-not-public-suffix.json new file mode 100644 index 00000000000..d6d02fa51a5 --- /dev/null +++ b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-psl-not-public-suffix.json @@ -0,0 +1,16 @@ +{ + "uri": "mongodb+srv://test1.test.build.10gen.cc/?srvAllowedHostsSuffix=10gen.cc", + "seeds": [ + "localhost.test.build.10gen.cc:27017", + "localhost.test.build.10gen.cc:27018" + ], + "hosts": [ + "localhost:27017", + "localhost:27018", + "localhost:27019" + ], + "options": { + "ssl": true + }, + "ping": true +} diff --git a/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-psl-not-public-suffix.yml b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-psl-not-public-suffix.yml new file mode 100644 index 00000000000..4e20a2d22fb --- /dev/null +++ b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-psl-not-public-suffix.yml @@ -0,0 +1,14 @@ +# "10gen.cc" is not in the Public Suffix List -- only its parent "cc" is -- so it is +# not a public suffix and must be accepted. The SRV hosts end with it, so resolution +# succeeds. +uri: "mongodb+srv://test1.test.build.10gen.cc/?srvAllowedHostsSuffix=10gen.cc" +seeds: + - localhost.test.build.10gen.cc:27017 + - localhost.test.build.10gen.cc:27018 +hosts: + - localhost:27017 + - localhost:27018 + - localhost:27019 +options: + ssl: true +ping: true diff --git a/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-psl-public-suffix-capitalized.json b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-psl-public-suffix-capitalized.json new file mode 100644 index 00000000000..d1ae4046bc8 --- /dev/null +++ b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-psl-public-suffix-capitalized.json @@ -0,0 +1,6 @@ +{ + "uri": "mongodb+srv://test1.test.build.10gen.cc/?srvAllowedHostsSuffix=COM", + "seeds": [], + "hosts": [], + "error": true +} diff --git a/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-psl-public-suffix-capitalized.yml b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-psl-public-suffix-capitalized.yml new file mode 100644 index 00000000000..b64bed89d7a --- /dev/null +++ b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-psl-public-suffix-capitalized.yml @@ -0,0 +1,7 @@ +# All rules in the Public Suffix List are lowercase, so a driver must normalize case +# before comparing. "COM" is the ordinary rule "com", so it is a public suffix and must +# be rejected. +uri: "mongodb+srv://test1.test.build.10gen.cc/?srvAllowedHostsSuffix=COM" +seeds: [] +hosts: [] +error: true diff --git a/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-psl-public-suffix.json b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-psl-public-suffix.json new file mode 100644 index 00000000000..96a5358a54f --- /dev/null +++ b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-psl-public-suffix.json @@ -0,0 +1,6 @@ +{ + "uri": "mongodb+srv://test1.test.build.10gen.cc/?srvAllowedHostsSuffix=cc", + "seeds": [], + "hosts": [], + "error": true +} diff --git a/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-psl-public-suffix.yml b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-psl-public-suffix.yml new file mode 100644 index 00000000000..c2f246d1893 --- /dev/null +++ b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-psl-public-suffix.yml @@ -0,0 +1,5 @@ +# "cc" is an ordinary rule in the Public Suffix List and is only one label and therefore MUST be rejected. +uri: "mongodb+srv://test1.test.build.10gen.cc/?srvAllowedHostsSuffix=cc" +seeds: [] +hosts: [] +error: true diff --git a/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-tld-only.json b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-tld-only.json new file mode 100644 index 00000000000..12098dbf5ee --- /dev/null +++ b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-tld-only.json @@ -0,0 +1,6 @@ +{ + "uri": "mongodb+srv://test1.test.build.10gen.cc/?srvAllowedHostsSuffix=.cc", + "seeds": [], + "hosts": [], + "error": true +} diff --git a/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-tld-only.yml b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-tld-only.yml new file mode 100644 index 00000000000..aeb9be44e63 --- /dev/null +++ b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-tld-only.yml @@ -0,0 +1,6 @@ +# srvAllowedHostsSuffix SHOULD NOT be a public suffix. "cc" is an ordinary rule in the +# Public Suffix List, and isn't two or more `.` separated labels so `.cc` MUST raise an error. +uri: "mongodb+srv://test1.test.build.10gen.cc/?srvAllowedHostsSuffix=.cc" +seeds: [] +hosts: [] +error: true diff --git a/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-trailing-dot.json b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-trailing-dot.json new file mode 100644 index 00000000000..9befc300603 --- /dev/null +++ b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-trailing-dot.json @@ -0,0 +1,10 @@ +{ + "uri": "mongodb+srv://test12.test.build.10gen.cc/?srvAllowedHostsSuffix=.build.10gen.cc.", + "seeds": [ + "localhost.build.10gen.cc:27017" + ], + "options": { + "ssl": true + }, + "ping": false +} diff --git a/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-trailing-dot.yml b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-trailing-dot.yml new file mode 100644 index 00000000000..4da99176968 --- /dev/null +++ b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-trailing-dot.yml @@ -0,0 +1,8 @@ +# A trailing dot in srvAllowedHostsSuffix must be stripped before comparison, +# so ".build.10gen.cc." is equivalent to ".build.10gen.cc". +uri: "mongodb+srv://test12.test.build.10gen.cc/?srvAllowedHostsSuffix=.build.10gen.cc." +seeds: + - localhost.build.10gen.cc:27017 +options: + ssl: true +ping: false diff --git a/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-with_dot.json b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-with_dot.json new file mode 100644 index 00000000000..724f813918f --- /dev/null +++ b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-with_dot.json @@ -0,0 +1,10 @@ +{ + "uri": "mongodb+srv://test12.test.build.10gen.cc/?srvAllowedHostsSuffix=.build.10gen.cc", + "seeds": [ + "localhost.build.10gen.cc:27017" + ], + "options": { + "ssl": true + }, + "ping": false +} diff --git a/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-with_dot.yml b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-with_dot.yml new file mode 100644 index 00000000000..bb73a502796 --- /dev/null +++ b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-with_dot.yml @@ -0,0 +1,8 @@ +# Also tests trailing dot normalization: DNS returns targets with a trailing dot +# (localhost.build.10gen.cc.) which must be stripped before suffix comparison. +uri: "mongodb+srv://test12.test.build.10gen.cc/?srvAllowedHostsSuffix=.build.10gen.cc" +seeds: + - localhost.build.10gen.cc:27017 +options: + ssl: true +ping: false diff --git a/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-without_dot_fail.json b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-without_dot_fail.json new file mode 100644 index 00000000000..b7544b66f24 --- /dev/null +++ b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-without_dot_fail.json @@ -0,0 +1,6 @@ +{ + "uri": "mongodb+srv://test12.test.build.10gen.cc/?srvAllowedHostsSuffix=uild.10gen.cc", + "seeds": [], + "hosts": [], + "error": true +} diff --git a/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-without_dot_fail.yml b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-without_dot_fail.yml new file mode 100644 index 00000000000..57fab7a5703 --- /dev/null +++ b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-without_dot_fail.yml @@ -0,0 +1,5 @@ +# dot should be prepended to `srvAllowedHostsSuffix` causing the host to be .uild.10gen.cc which does not match any available DNS records +uri: "mongodb+srv://test12.test.build.10gen.cc/?srvAllowedHostsSuffix=uild.10gen.cc" +seeds: [] +hosts: [] +error: true diff --git a/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-without_dot_pass.json b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-without_dot_pass.json new file mode 100644 index 00000000000..ea1f5c8918d --- /dev/null +++ b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-without_dot_pass.json @@ -0,0 +1,10 @@ +{ + "uri": "mongodb+srv://test12.test.build.10gen.cc/?srvAllowedHostsSuffix=build.10gen.cc", + "seeds": [ + "localhost.build.10gen.cc:27017" + ], + "options": { + "ssl": true + }, + "ping": false +} diff --git a/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-without_dot_pass.yml b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-without_dot_pass.yml new file mode 100644 index 00000000000..feca05f8c89 --- /dev/null +++ b/test/spec/initial-dns-seedlist-discovery/replica-set/srvAllowedHostsSuffix-without_dot_pass.yml @@ -0,0 +1,6 @@ +uri: "mongodb+srv://test12.test.build.10gen.cc/?srvAllowedHostsSuffix=build.10gen.cc" +seeds: + - localhost.build.10gen.cc:27017 +options: + ssl: true +ping: false diff --git a/test/spec/polling-srv-records-for-mongos-discovery/README.md b/test/spec/polling-srv-records-for-mongos-discovery/README.md new file mode 100644 index 00000000000..31bdc7f8745 --- /dev/null +++ b/test/spec/polling-srv-records-for-mongos-discovery/README.md @@ -0,0 +1,252 @@ +# SRV Polling Tests + +## Introduction + +This directory contains prose test descriptions that drivers can use to prove their conformance to the SRV polling +specification. + +Without (automated) access to a DNS server configuration, it is nearly impossible to implement functional tests for a +correct implementation of this specification. However, it might be possible to mock changes to DNS SRV records such that +automated testing is doable. In any case, the following tests should be executed, either manually, or programmatically. + +## Configuring the Test Environment + +To test, start a sharded cluster with mongos servers on ports 27017, 27018, 27019, and 27020. + +For each test, take as a starting point the test1, test3, and test22 SRV records from the +[test set-up](../../initial-dns-seedlist-discovery/tests/README.md) from the +[Initial DNS Seedlist Discovery](../../initial-dns-seedlist-discovery/initial-dns-seedlist-discovery.md) specification: + +```dns +Record TTL Class Address +localhost.test.test.build.10gen.cc. 86400 IN A 127.0.0.1 + +Record TTL Class Port Target +_mongodb._tcp.test1.test.build.10gen.cc. 86400 IN SRV 27017 localhost.test.build.10gen.cc. +_mongodb._tcp.test1.test.build.10gen.cc. 86400 IN SRV 27018 localhost.test.build.10gen.cc. +_mongodb._tcp.test3.test.build.10gen.cc. 86400 IN SRV 27017 localhost.test.build.10gen.cc. +_customname._tcp.test22.test.build.10gen.cc. 86400 IN SRV 27017 localhost.test.build.10gen.cc. +``` + +## Prose Tests + +### Changing DNS records with test1.test.build.10gen.cc + +The following tests MUST setup a MongoClient using the `test1.test.build.10gen.cc` SRV record. Each test MUST mock the +described DNS changes and then verify that the new list of hosts is present. + +#### 1. Addition of a new DNS record + +Add the following record: + +```dns +_mongodb._tcp.test1.test.build.10gen.cc. 86400 IN SRV 27019 localhost.test.build.10gen.cc. +``` + +#### 2. Removal of an existing DNS record + +Remove the following record: + +```dns +_mongodb._tcp.test1.test.build.10gen.cc. 86400 IN SRV 27018 localhost.test.build.10gen.cc. +``` + +#### 3. Replacement of a DNS record + +Replace the following record: + +```dns +_mongodb._tcp.test1.test.build.10gen.cc. 86400 IN SRV 27018 localhost.test.build.10gen.cc. +``` + +with: + +```dns +_mongodb._tcp.test1.test.build.10gen.cc. 86400 IN SRV 27019 localhost.test.build.10gen.cc. +``` + +#### 4. Replacement of both existing DNS records with *one* new record + +Replace both records with: + +```dns +_mongodb._tcp.test1.test.build.10gen.cc. 86400 IN SRV 27019 localhost.test.build.10gen.cc. +``` + +#### 5. Replacement of both existing DNS records with *two* new records + +Replace both records with: + +```dns +_mongodb._tcp.test1.test.build.10gen.cc. 86400 IN SRV 27019 localhost.test.build.10gen.cc. +_mongodb._tcp.test1.test.build.10gen.cc. 86400 IN SRV 27020 localhost.test.build.10gen.cc. +``` + +### Error scenarios with test1.test.build.10gen.cc + +The following tests MUST setup a MongoClient using the `test1.test.build.10gen.cc` SRV record. Each test MUST mock the +described error situation and assert that the internal list of discovered mongos servers has not changed. + +#### 6. DNS record lookup timeout + +Mock a DNS record lookup timeout error. + +#### 7. DNS record lookup failure + +Mock a DNS record lookup failure (i.e. domain no longer exists because it's no longer registered). + +#### 8. Removal of all DNS SRV records + +Mock a DNS record lookup that returns zero SRV records. + +### Changing DNS records with test3.test.build.10gen.cc + +The following tests MUST setup a MongoClient using the `test3.test.build.10gen.cc` SRV record. Each test MUST mock the +described situation and make the specified assertions. + +#### 9. Test that SRV polling is not done for load balalanced clusters + +Connect to `mongodb+srv://test3.test.build.10gen.cc/?loadBalanced=true`, mock the addition of the following DNS record: + +```dns +_mongodb._tcp.test3.test.build.10gen.cc. 86400 IN SRV 27018 localhost.test.build.10gen.cc. +``` + +Wait until `2*rescanSRVIntervalMS` and assert that the final topology description only contains one server: +`localhost.test.build.10gen.cc.` at port `27017`. + +### SRV polling with srvMaxHosts MongoClient option + +The following tests MUST setup a MongoClient using the `srvMaxHosts` option and `test1.test.build.10gen.cc` SRV record. +Each test MUST mock the described DNS changes and then verify that the new list of hosts is present. + +#### 10. All DNS records are selected (srvMaxHosts = 0) + +Configure the MongoClient with `srvMaxHosts=0`. + +Replace the following record: + +```dns +_mongodb._tcp.test1.test.build.10gen.cc. 86400 IN SRV 27018 localhost.test.build.10gen.cc. +``` + +with: + +```dns +_mongodb._tcp.test1.test.build.10gen.cc. 86400 IN SRV 27019 localhost.test.build.10gen.cc. +_mongodb._tcp.test1.test.build.10gen.cc. 86400 IN SRV 27020 localhost.test.build.10gen.cc. +``` + +Wait until `2*rescanSRVIntervalMS` and assert that the final topology description contains the following hosts: + +- localhost.test.build.10gen.cc:27017 +- localhost.test.build.10gen.cc:27019 +- localhost.test.build.10gen.cc:27020 + +#### 11. All DNS records are selected (srvMaxHosts >= records) + +Configure the MongoClient with `srvMaxHosts=2`. + +Replace both records with: + +```dns +_mongodb._tcp.test1.test.build.10gen.cc. 86400 IN SRV 27019 localhost.test.build.10gen.cc. +_mongodb._tcp.test1.test.build.10gen.cc. 86400 IN SRV 27020 localhost.test.build.10gen.cc. +``` + +Wait until `2*rescanSRVIntervalMS` and assert that the final topology description contains the following hosts: + +- localhost.test.build.10gen.cc:27019 +- localhost.test.build.10gen.cc:27020 + +#### 12. New DNS records are randomly selected (srvMaxHosts > 0) + +Configure the MongoClient with `srvMaxHosts=2`. + +Replace the following record: + +```dns +_mongodb._tcp.test1.test.build.10gen.cc. 86400 IN SRV 27018 localhost.test.build.10gen.cc. +``` + +with: + +```dns +_mongodb._tcp.test1.test.build.10gen.cc. 86400 IN SRV 27019 localhost.test.build.10gen.cc. +_mongodb._tcp.test1.test.build.10gen.cc. 86400 IN SRV 27020 localhost.test.build.10gen.cc. +``` + +Wait until `2*rescanSRVIntervalMS` and assert that the topology has two hosts present and that one of the hosts is +`localhost.test.build.10gen.cc:27017`. The second, new host will have been randomly selected and cannot be +deterministically asserted. + +### SRV polling with srvServiceName MongoClient option + +The following test MUST setup a MongoClient using the `srvServiceName` option and the `test22.test.build.10gen.cc` SRV +record. The test MUST mock the described DNS changes and then verify that the new list of hosts is present. + +#### 13. DNS record with custom service name can be found + +Configure the MongoClient with `srvServiceName=customname`. + +Replace both records with: + +```dns +_customname._tcp.test22.test.build.10gen.cc. 86400 IN SRV 27019 localhost.test.build.10gen.cc. +_customname._tcp.test22.test.build.10gen.cc. 86400 IN SRV 27020 localhost.test.build.10gen.cc. +``` + +Wait until `2*rescanSRVIntervalMS` and assert that the final topology description contains the following hosts: + +- localhost.test.build.10gen.cc:27019 +- localhost.test.build.10gen.cc:27020 + +### SRV polling with the srvHostValidator MongoClient option + +The following tests MUST setup a MongoClient using the `srvHostValidator` option and the `test1.test.build.10gen.cc` SRV +record. The test MUST mock the described DNS changes and then make the specified assertions. + +#### 14. The validator is consulted when SRV records are rescanned + +Configure the MongoClient with a validator that records the host names it is passed and returns `true`, then mock the +addition of the following DNS record: + +```dns +_mongodb._tcp.test1.test.build.10gen.cc. 86400 IN SRV 27019 localhost.test.build.10gen.cc. +``` + +Wait until `2*rescanSRVIntervalMS` and assert that the validator was passed `localhost.test.build.10gen.cc` during the +rescan, and that the final topology description contains the following hosts: + +- localhost.test.build.10gen.cc:27017 +- localhost.test.build.10gen.cc:27018 +- localhost.test.build.10gen.cc:27019 + +#### 15. A validator that rejects or raises does not raise an error or stop polling + +Run this test twice: once with a validator that returns `false` for every host name, and once with a validator that +raises an error for every host name. + +The validator's behavior MUST be controlled by state external to the validator (e.g. a flag the validator reads) so that +it can be changed later in the test without reconfiguring the MongoClient. + +In both cases the driver MUST treat the returned host name as non-compliant, and MUST NOT raise an error to the +application or stop rescanning. + +Mock the addition of the following DNS record: + +```dns +_mongodb._tcp.test1.test.build.10gen.cc. 86400 IN SRV 27019 localhost.test.build.10gen.cc. +``` + +Wait until `2*rescanSRVIntervalMS` and assert that no error was raised and that the topology description still contains +only the original hosts: + +- localhost.test.build.10gen.cc:27017 +- localhost.test.build.10gen.cc:27018 + +Then change that external state so the validator accepts every host name. Because the earlier rescans obtained no +verified hosts, the driver has temporarily set *rescanSRVIntervalMS* to *heartbeatFrequencyMS*, so the next rescan +occurs within `heartbeatFrequencyMS`. Wait until `2*heartbeatFrequencyMS` and assert that rescanning was not stopped by +the earlier failures: the final topology description MUST contain `localhost.test.build.10gen.cc:27019` in addition to +the two original hosts. diff --git a/test/spec/uri-options/srv-options.json b/test/spec/uri-options/srv-options.json index 0670612c0d2..75bb7d1c5f0 100644 --- a/test/spec/uri-options/srv-options.json +++ b/test/spec/uri-options/srv-options.json @@ -111,6 +111,15 @@ "loadBalanced": true, "srvMaxHosts": 0 } + }, + { + "description": "Non-SRV URI with srvAllowedHostsSuffix", + "uri": "mongodb://example.com/?srvAllowedHostsSuffix=.mongodb.net", + "valid": false, + "warning": false, + "hosts": null, + "auth": null, + "options": null } ] } diff --git a/test/spec/uri-options/srv-options.yml b/test/spec/uri-options/srv-options.yml index 991749b0efe..f8bab2d2b97 100644 --- a/test/spec/uri-options/srv-options.yml +++ b/test/spec/uri-options/srv-options.yml @@ -87,3 +87,10 @@ tests: options: loadBalanced: true srvMaxHosts: 0 + - description: "Non-SRV URI with srvAllowedHostsSuffix" + uri: "mongodb://example.com/?srvAllowedHostsSuffix=.mongodb.net" + valid: false + warning: false + hosts: ~ + auth: ~ + options: ~ diff --git a/test/tools/uri_spec_runner.ts b/test/tools/uri_spec_runner.ts index cc419495c0f..05b27176371 100644 --- a/test/tools/uri_spec_runner.ts +++ b/test/tools/uri_spec_runner.ts @@ -366,6 +366,7 @@ export function executeUriValidationTest( case 'replicaSet': case 'srvServiceName': case 'srvMaxHosts': + case 'srvAllowedHostsSuffix': case 'tls': expect(options, `${errorMessage} ${optionKey}`) .to.have.property(optionKey) diff --git a/test/types/community/client.test-d.ts b/test/types/community/client.test-d.ts index 0f290239aa8..23a23983cdc 100644 --- a/test/types/community/client.test-d.ts +++ b/test/types/community/client.test-d.ts @@ -1,4 +1,4 @@ -import { expectType } from 'tsd'; +import { expectError, expectType } from 'tsd'; import { type GridFSBucket, @@ -42,6 +42,21 @@ export async function testFunc(): Promise { expectType>(MongoClient.connect(connectionString, options)); +// SRV host validation +expectType(options.srvAllowedHostsSuffix); +expectType<((host: string) => boolean) | undefined>(options.srvHostValidator); +new MongoClient('mongodb+srv://cluster.example.com', { srvAllowedHostsSuffix: '.example.com' }); +new MongoClient('mongodb+srv://cluster.example.com', { + srvHostValidator: host => { + expectType(host); + return host.endsWith('.example.com'); + } +}); +// srvHostValidator is synchronous: an async function returns a Promise rather than a boolean +expectError( + new MongoClient('mongodb+srv://cluster.example.com', { srvHostValidator: async () => true }) +); + // TLS const userName = ''; const password = ''; diff --git a/test/unit/assorted/polling_srv_records_for_mongos_discovery.prose.test.ts b/test/unit/assorted/polling_srv_records_for_mongos_discovery.prose.test.ts index e5f9334b86f..a47f0d8b9a8 100644 --- a/test/unit/assorted/polling_srv_records_for_mongos_discovery.prose.test.ts +++ b/test/unit/assorted/polling_srv_records_for_mongos_discovery.prose.test.ts @@ -444,3 +444,112 @@ describe('Polling Srv Records for Mongos Discovery', () => { }); }); }); + +// Unlike the cases above, these are not skipped on Node 18+ (NODE-5666): rather than a MongoClient with fake +// timers, they use a real SrvPoller within a topology of mock mongoses, and drive each rescan by calling _poll. +// Since the validator's verdict replaces the default verification, the mock mongoses' `localhost` addresses +// stand in for the spec's localhost.test.build.10gen.cc records. +describe('Polling Srv Records for Mongos Discovery with the srvHostValidator option', () => { + const SRV_HOST = 'test1.test.build.10gen.cc'; + const HEARTBEAT_FREQUENCY_MS = 10000; + let mongoses: MockServer[]; + let srvPoller: SrvPoller; + let topology: Topology; + + beforeEach(async () => { + mongoses = await Promise.all(Array.from({ length: 3 }, () => mock.createServer())); + for (const mongos of mongoses) { + mongos.setMessageHandler(request => { + if (isHello(request.document)) { + request.reply({ ...mock.HELLO, msg: 'isdbgrid' }); + } + }); + } + }); + + afterEach(async () => { + sinon.restore(); + srvPoller?.stop(); + topology?.close(); + await mock.cleanup(); + }); + + /** The address of a mock mongos as it appears in the topology, using the `localhost` host name */ + function mongosHost(mongos: MockServer) { + return `localhost:${mongos.port}`; + } + + /** Connects a topology seeded with the first two mongoses, polled by an SrvPoller using `srvHostValidator` */ + async function connectTopology(srvHostValidator: (host: string) => boolean) { + srvPoller = new SrvPoller({ + srvHost: SRV_HOST, + srvHostValidator, + heartbeatFrequencyMS: HEARTBEAT_FREQUENCY_MS + } as SrvPollerOptions); + const seedlist = mongoses.slice(0, 2).map(mongos => HostAddress.fromString(mongosHost(mongos))); + topology = topologyWithPlaceholderClient(seedlist, { srvPoller, srvHost: SRV_HOST }); + await topology.connect({}); + expect(topology.description).to.have.property('type', TopologyType.Sharded); + } + + /** Stubs DNS so every SRV rescan returns all three mongoses, in the un-normalized form DNS may use */ + function stubRescanWithAllMongoses() { + sinon + .stub(dns.promises, 'resolve') + .resolves(mongoses.map(({ port }) => srvRecord('LOCALHOST.', port))); + } + + function topologyHosts() { + return Array.from(topology.description.servers.keys()).sort(); + } + + function allMongosHosts() { + return mongoses.map(mongosHost).sort(); + } + + it('14. The validator is consulted when SRV records are rescanned', async () => { + const validatedHosts: string[] = []; + await connectTopology(host => { + validatedHosts.push(host); + return true; + }); + stubRescanWithAllMongoses(); + + const willChange = once(topology, 'topologyDescriptionChanged'); + await srvPoller._poll(); + await willChange; + + expect(validatedHosts).to.deep.equal(['localhost', 'localhost', 'localhost']); + expect(topologyHosts()).to.deep.equal(allMongosHosts()); + }); + + for (const behavior of ['returns false', 'throws'] as const) { + it(`15. A validator that ${behavior} does not raise an error or stop polling`, async () => { + let acceptHosts = false; + await connectTopology(() => { + if (acceptHosts) return true; + if (behavior === 'throws') throw new Error('validator failure'); + return false; + }); + stubRescanWithAllMongoses(); + const originalHosts = topologyHosts(); + + // Two rescans in which every host is rejected: neither raises, and the topology keeps its hosts + await srvPoller._poll(); + await srvPoller._poll(); + expect(topologyHosts()).to.deep.equal(originalHosts); + + // No verified hosts means the next rescan is scheduled after heartbeatFrequencyMS + expect(srvPoller).to.have.property('haMode', true); + expect(srvPoller).to.have.property('intervalMS', HEARTBEAT_FREQUENCY_MS); + expect(srvPoller._timeout).to.exist; + + acceptHosts = true; + const willChange = once(topology, 'topologyDescriptionChanged'); + await srvPoller._poll(); + await willChange; + + expect(topologyHosts()).to.deep.equal(allMongosHosts()); + }); + } +}); diff --git a/test/unit/connection_string.test.ts b/test/unit/connection_string.test.ts index 38b27318fd0..045cab970c8 100644 --- a/test/unit/connection_string.test.ts +++ b/test/unit/connection_string.test.ts @@ -13,6 +13,7 @@ import { type Log, MongoAPIError, MongoClient, + type MongoClientOptions, MongoCredentials, MongoDriverError, MongoInvalidArgumentError, @@ -616,6 +617,194 @@ describe('Connection String', function () { expect(options.dbName).to.equal('somedb'); expect(options.srvHost).to.equal('test1.test.build.10gen.cc'); }); + + describe('srvAllowedHostsSuffix', function () { + const SRV_URI = 'mongodb+srv://cluster.mongodb.mydomain.net'; + + context('when set in the connection string', function () { + it('strips leading and trailing dots, lowercases, and prepends a dot', function () { + const options = parseOptions(`${SRV_URI}/?srvAllowedHostsSuffix=.MyDomain.NET.`); + expect(options.srvAllowedHostsSuffix).to.equal('.mydomain.net'); + }); + }); + + context('when set in the options', function () { + it('strips leading and trailing dots, lowercases, and prepends a dot', function () { + const options = parseOptions(SRV_URI, { srvAllowedHostsSuffix: 'MyDomain.NET' }); + expect(options.srvAllowedHostsSuffix).to.equal('.mydomain.net'); + }); + }); + + it('converts internationalized labels to A-label (Punycode) form', function () { + const options = parseOptions(SRV_URI, { srvAllowedHostsSuffix: '.公司.cn' }); + expect(options.srvAllowedHostsSuffix).to.equal('.xn--55qx5d.cn'); + }); + + it('accepts underscores', function () { + expect( + parseOptions(`${SRV_URI}/?srvAllowedHostsSuffix=my_domain.net`).srvAllowedHostsSuffix + ).to.equal('.my_domain.net'); + expect( + parseOptions(SRV_URI, { srvAllowedHostsSuffix: '.My_Domain.NET' }).srvAllowedHostsSuffix + ).to.equal('.my_domain.net'); + }); + + for (const suffix of ['localhost', '.INTERNAL', 'corp.']) { + it(`accepts the reserved single label ${suffix}`, function () { + const options = parseOptions(SRV_URI, { srvAllowedHostsSuffix: suffix }); + expect(options.srvAllowedHostsSuffix).to.equal( + `.${suffix.replace(/\./g, '').toLowerCase()}` + ); + }); + } + + for (const suffix of ['net', '.com', 'cc']) { + it(`throws for the single label ${suffix}`, function () { + expect(() => parseOptions(SRV_URI, { srvAllowedHostsSuffix: suffix })).to.throw( + MongoParseError, + 'must contain at least two domain labels' + ); + }); + } + + for (const suffix of ['.', '..']) { + it(`throws for ${suffix}, which contains no domain labels`, function () { + expect(() => parseOptions(SRV_URI, { srvAllowedHostsSuffix: suffix })).to.throw( + MongoParseError, + 'srvAllowedHostsSuffix must contain at least one domain label' + ); + }); + } + + // The specification permits only its listed normalization and validation steps + for (const [description, suffix] of [ + ['an empty label', 'my..domain.net'], + ['a label longer than 63 characters', `${'a'.repeat(64)}.net`], + ['a name longer than 255 characters', `${'a'.repeat(63)}.`.repeat(4) + 'net'] + ]) { + it(`applies no other host name syntax validation, accepting ${description}`, function () { + expect( + parseOptions(SRV_URI, { srvAllowedHostsSuffix: suffix }).srvAllowedHostsSuffix + ).to.equal(`.${suffix}`); + }); + } + + for (const [description, suffix] of [ + ['a non-ASCII name with an invalid xn-- label', '公司.xn--a.cn'], + ['a bidi rule violation', 'a\u05D0b.example.com'], + ['a disallowed code point', 'a\u2028b.example.com'], + ['a space', 'my domain.net'], + ['a slash', 'my/domain.net'], + ['a backslash', 'my\\domain.net'], + ['a question mark', 'my?domain.net'], + ['a number sign', 'my#domain.net'], + ['a percent sign', 'my%64omain.net'], + ['a colon', 'my:domain.net'], + ['an at sign', 'my@domain.net'], + ['non-ASCII characters combined with a slash', '公司/x.cn'] + ]) { + it(`throws when it cannot be converted to A-label form: ${description}`, function () { + expect(() => parseOptions(SRV_URI, { srvAllowedHostsSuffix: suffix })).to.throw( + MongoParseError, + 'is not a valid domain name' + ); + }); + } + + it('converts valid xn-- labels', function () { + expect( + parseOptions(SRV_URI, { srvAllowedHostsSuffix: 'DB.XN--STRAE-OQA.example' }) + .srvAllowedHostsSuffix + ).to.equal('.db.xn--strae-oqa.example'); + }); + + // The WHATWG URL Standard returns an ASCII name lowercased without validating its xn-- labels + for (const [description, suffix] of [ + ['invalid Punycode', 'XN--A.example.com'], + ['a label that decodes to only ASCII', 'xn--abc-.example.com'], + ['a label that cannot be decoded', 'xn--zzzzzz.example.com'], + ['a label that decodes to mapped code points', 'xn--8i7caa.example.com'] + ]) { + it(`accepts an ASCII xn-- label that is not a valid A-label, lowercased: ${description}`, function () { + expect( + parseOptions(SRV_URI, { srvAllowedHostsSuffix: suffix }).srvAllowedHostsSuffix + ).to.equal(`.${suffix.toLowerCase()}`); + }); + } + + it('throws when not a string', function () { + expect(() => + // @ts-expect-error: srvAllowedHostsSuffix must be a string + parseOptions(SRV_URI, { srvAllowedHostsSuffix: 42 }) + ).to.throw(MongoParseError, 'srvAllowedHostsSuffix must be a string'); + }); + + it('throws when combined with srvHostValidator', function () { + expect(() => + parseOptions(SRV_URI, { + srvAllowedHostsSuffix: '.mydomain.net', + srvHostValidator: () => true + }) + ).to.throw( + MongoParseError, + 'Cannot use srvAllowedHostsSuffix together with srvHostValidator' + ); + }); + + it('throws with a non-srv connection string', function () { + expect(() => + parseOptions('mongodb://localhost/?srvAllowedHostsSuffix=.mydomain.net') + ).to.throw( + MongoParseError, + 'Cannot use srvAllowedHostsSuffix with a non-srv connection string' + ); + expect(() => + parseOptions('mongodb://localhost', { srvAllowedHostsSuffix: '.mydomain.net' }) + ).to.throw( + MongoParseError, + 'Cannot use srvAllowedHostsSuffix with a non-srv connection string' + ); + }); + }); + + describe('srvHostValidator', function () { + const SRV_URI = 'mongodb+srv://cluster.mongodb.mydomain.net'; + + it('is set to the provided function', function () { + const srvHostValidator = () => true; + const options = parseOptions(SRV_URI, { srvHostValidator }); + expect(options.srvHostValidator).to.equal(srvHostValidator); + }); + + it('throws when set in the connection string', function () { + expect(() => parseOptions(`${SRV_URI}/?srvHostValidator=anything`)).to.throw( + MongoParseError, + 'URI cannot contain `srvHostValidator`, it can only be passed to the client' + ); + }); + + it('throws when not a function', function () { + expect(() => + // @ts-expect-error: srvHostValidator must be a function + parseOptions(SRV_URI, { srvHostValidator: 'notacallable' }) + ).to.throw(MongoParseError, 'srvHostValidator must be a function'); + }); + + it('throws with a non-srv connection string', function () { + expect(() => + parseOptions('mongodb://localhost', { srvHostValidator: () => true }) + ).to.throw(MongoParseError, 'Cannot use srvHostValidator with a non-srv connection string'); + }); + }); + + it('names every srv-only option provided with a non-srv connection string', function () { + expect(() => + parseOptions('mongodb://localhost/?srvMaxHosts=2&srvAllowedHostsSuffix=.mydomain.net') + ).to.throw( + MongoParseError, + 'Cannot use srvMaxHosts, srvAllowedHostsSuffix with a non-srv connection string' + ); + }); }); describe('resolveSRVRecord()', () => { @@ -743,6 +932,142 @@ describe('Connection String', function () { expect(options).to.have.nested.property('credentials.mechanism', 'DEFAULT'); expect(options).to.have.nested.property('credentials.source', 'thisShouldBeAuthSource'); }); + + context('when verifying the hosts returned by the SRV lookup', function () { + /** Stubs DNS so the SRV lookup returns `names` and the TXT lookup returns no records */ + function stubSrvLookup(...names: string[]) { + const stub = sinon.stub(dns.promises, 'resolve'); + stub + .withArgs(sinon.match.any, 'SRV') + .resolves(names.map(name => ({ name, port: 27017, weight: 0, priority: 0 }))); + stub + .withArgs(sinon.match.any, 'TXT') + .rejects(Object.assign(new Error('no TXT records'), { code: 'ENODATA' })); + } + + async function resolveSeedlist(uri: string, options?: MongoClientOptions) { + const hosts = await resolveSRVRecord(parseOptions(uri, options)); + return hosts.map(host => host.toString()); + } + + it('compares against the SRV host from the connection string case-insensitively', async function () { + stubSrvLookup('localhost.test.build.10gen.cc'); + expect(await resolveSeedlist('mongodb+srv://TEST1.TEST.BUILD.10GEN.CC')).to.deep.equal([ + 'localhost.test.build.10gen.cc:27017' + ]); + }); + + it('normalizes returned host names, and seeds with the normalized form', async function () { + stubSrvLookup('LOCALHOST.Test.Build.10gen.CC.'); + expect(await resolveSeedlist('mongodb+srv://test1.test.build.10gen.cc')).to.deep.equal([ + 'localhost.test.build.10gen.cc:27017' + ]); + }); + + it('compares internationalized host names in A-label (Punycode) form', async function () { + stubSrvLookup('db.公司.cn'); + expect(await resolveSeedlist('mongodb+srv://cluster.xn--55qx5d.cn')).to.deep.equal([ + 'db.xn--55qx5d.cn:27017' + ]); + }); + + it('accepts host names containing underscores', async function () { + stubSrvLookup('db_1.my_domain.net'); + expect(await resolveSeedlist('mongodb+srv://cluster.my_domain.net')).to.deep.equal([ + 'db_1.my_domain.net:27017' + ]); + }); + + context('when srvAllowedHostsSuffix is set', function () { + const CLUSTER_URI = 'mongodb+srv://cluster.mongodb.dyn.example.net'; + const REGIONAL_HOSTS = ['host1.na.example.net', 'host2.asia.example.net']; + + it('accepts hosts in other subdomains of the suffix, which are rejected by default', async function () { + stubSrvLookup(...REGIONAL_HOSTS); + const error = await resolveSeedlist(CLUSTER_URI).catch(error => error); + expect(error).to.be.instanceOf(MongoAPIError); + + expect( + await resolveSeedlist(`${CLUSTER_URI}/?srvAllowedHostsSuffix=example.net`) + ).to.deep.equal(REGIONAL_HOSTS.map(host => `${host}:27017`)); + }); + + for (const host of ['host1.na.example.net.evil.com', 'host1.notexample.net']) { + it(`rejects ${host}, which does not end with the suffix as whole labels`, async function () { + stubSrvLookup(host); + const error = await resolveSeedlist( + `${CLUSTER_URI}/?srvAllowedHostsSuffix=example.net` + ).catch(error => error); + expect(error).to.be.instanceOf(MongoAPIError); + expect(error.message).to.equal( + `Server record "${host}" does not end with srvAllowedHostsSuffix ".example.net"` + ); + }); + } + + it('does not require the SRV host to end with the suffix', async function () { + stubSrvLookup('db1.hosts.example.net'); + expect( + await resolveSeedlist( + 'mongodb+srv://cluster.example.org/?srvAllowedHostsSuffix=.hosts.example.net' + ) + ).to.deep.equal(['db1.hosts.example.net:27017']); + }); + + it('does not require an extra domain level for an SRV host with fewer than three parts', async function () { + stubSrvLookup('db.local'); + expect( + await resolveSeedlist('mongodb+srv://mongo.local/?srvAllowedHostsSuffix=local') + ).to.deep.equal(['db.local:27017']); + }); + + it('accepts a suffix and host names containing underscores', async function () { + stubSrvLookup('db1.us_east.my_domain.net', 'db_2.my_domain.net'); + expect( + await resolveSeedlist(`${CLUSTER_URI}/?srvAllowedHostsSuffix=my_domain.net`) + ).to.deep.equal(['db1.us_east.my_domain.net:27017', 'db_2.my_domain.net:27017']); + }); + + it('does not treat an underscore as a label boundary', async function () { + stubSrvLookup('db1.other_my_domain.net'); + const error = await resolveSeedlist( + `${CLUSTER_URI}/?srvAllowedHostsSuffix=my_domain.net` + ).catch(error => error); + expect(error).to.be.instanceOf(MongoAPIError); + expect(error.message).to.equal( + 'Server record "db1.other_my_domain.net" does not end with srvAllowedHostsSuffix ".my_domain.net"' + ); + }); + }); + + context('when srvHostValidator is set', function () { + it('passes host names containing underscores to the validator', async function () { + stubSrvLookup('DB_1.My_Domain.NET.'); + const validatedHosts: string[] = []; + expect( + await resolveSeedlist('mongodb+srv://cluster.example.com', { + srvHostValidator: host => { + validatedHosts.push(host); + return true; + } + }) + ).to.deep.equal(['db_1.my_domain.net:27017']); + expect(validatedHosts).to.deep.equal(['db_1.my_domain.net']); + }); + }); + + context('when srvHostValidator returns a non-boolean', function () { + it('throws a MongoInvalidArgumentError', async function () { + stubSrvLookup('cluster.mongodb.com'); + const error = await resolveSeedlist('mongodb+srv://blogs.mongodb.com', { + // @ts-expect-error: an async validator returns a Promise, which must not be treated as accepting + srvHostValidator: async () => true + }).catch(error => error); + expect(error).to.be.instanceOf(MongoInvalidArgumentError); + expect(error.message).to.equal('srvHostValidator must return a boolean, received object'); + }); + }); + }); }); describe('IPv6 host addresses', () => { diff --git a/test/unit/sdam/srv_polling.test.ts b/test/unit/sdam/srv_polling.test.ts index fe7f52d8f66..4f2c3423eba 100644 --- a/test/unit/sdam/srv_polling.test.ts +++ b/test/unit/sdam/srv_polling.test.ts @@ -184,6 +184,101 @@ describe('Mongos SRV Polling', function () { expect(poller.success).to.have.been.calledOnce.and.calledWithMatch([records[0]]); expect(poller.failure).to.not.have.been.called; }); + + it('should report records with normalized host names', async () => { + const poller = new SrvPoller({ srvHost: SRV_HOST }); + + stubDns(null, [srvRecord('JALAD.Tanagra.COM.', 27017)]); + stubPoller(poller); + + await poller._poll(); + + expect(poller.success).to.have.been.calledOnceWith([srvRecord('jalad.tanagra.com', 27017)]); + }); + + it('should succeed with records whose names contain underscores', async () => { + const poller = new SrvPoller({ srvHost: SRV_HOST }); + const records = [srvRecord('jalad_1.tanagra.com'), srvRecord('the_beast.tanagra.com')]; + + stubDns(null, records); + stubPoller(poller); + + await poller._poll(); + + expect(poller.success).to.have.been.calledOnce.and.calledWithMatch(records); + expect(poller.failure).to.not.have.been.called; + }); + + context('when srvAllowedHostsSuffix is set', () => { + it('should succeed with records that end with a suffix containing underscores', async () => { + const poller = new SrvPoller({ + srvHost: SRV_HOST, + srvAllowedHostsSuffix: '.my_domain.net' + }); + const records = [ + srvRecord('jalad.us_east.my_domain.net'), + srvRecord('jalad.other_my_domain.net') + ]; + + stubDns(null, records); + stubPoller(poller); + + await poller._poll(); + + expect(poller.success).to.have.been.calledOnce.and.calledWithMatch([records[0]]); + expect(poller.failure).to.not.have.been.called; + }); + + it('should only succeed with records that end with the suffix', async () => { + const poller = new SrvPoller({ + srvHost: SRV_HOST, + srvAllowedHostsSuffix: '.tanagra.org' + }); + const records = [srvRecord('jalad.us-east.tanagra.org'), srvRecord('jalad.tanagra.com')]; + + stubDns(null, records); + stubPoller(poller); + + await poller._poll(); + + expect(poller.success).to.have.been.calledOnce.and.calledWithMatch([records[0]]); + expect(poller.failure).to.not.have.been.called; + }); + }); + + context('when srvHostValidator is set', () => { + it('should use the validator verdict instead of the parent domain check', async () => { + const poller = new SrvPoller({ + srvHost: SRV_HOST, + srvHostValidator: host => host.endsWith('.walls.com') + }); + const records = [srvRecord('shaka.walls.com'), srvRecord('jalad.tanagra.com')]; + + stubDns(null, records); + stubPoller(poller); + + await poller._poll(); + + expect(poller.success).to.have.been.calledOnce.and.calledWithMatch([records[0]]); + expect(poller.failure).to.not.have.been.called; + }); + + it('should fail without throwing if the validator returns a non-boolean', async () => { + const poller = new SrvPoller({ + srvHost: SRV_HOST, + // @ts-expect-error: an async validator returns a Promise, which must not be treated as accepting + srvHostValidator: async () => true + }); + + stubDns(null, [srvRecord('jalad.tanagra.com')]); + stubPoller(poller); + + await poller._poll(); + + expect(poller.success).to.not.have.been.called; + expect(poller.failure).to.have.been.calledOnce; + }); + }); }); }); diff --git a/test/unit/utils.test.ts b/test/unit/utils.test.ts index cb98f69dc3f..b2e05555125 100644 --- a/test/unit/utils.test.ts +++ b/test/unit/utils.test.ts @@ -19,10 +19,12 @@ import { isUint8Array, LEGACY_HELLO_COMMAND, List, + MongoAPIError, MongoDBCollectionNamespace, MongoDBNamespace, MongoInvalidArgumentError, MongoRuntimeError, + normalizeDnsName, runNodelessTests, shuffle } from '../mongodb'; @@ -1130,6 +1132,66 @@ describe('driver utils', function () { ).to.not.throw(); }); }); + + context('when the address and SRV host differ only in case', () => { + it('accepts address since host names are compared case-insensitively', () => { + expect(() => + checkParentDomainMatch( + exampleHostNameWithoutDot[num].toUpperCase(), + exampleSrvName[num] + ) + ).to.not.throw(); + expect(() => + checkParentDomainMatch( + exampleHostNameWithoutDot[num], + exampleSrvName[num].toUpperCase() + ) + ).to.not.throw(); + }); + }); + }); + } + }); + + describe('normalizeDnsName()', () => { + it('removes a trailing dot', () => { + expect(normalizeDnsName('cluster.mongodb.com.')).to.equal('cluster.mongodb.com'); + }); + + it('lowercases', () => { + expect(normalizeDnsName('CLUSTER.MongoDB.com')).to.equal('cluster.mongodb.com'); + }); + + it('converts internationalized labels to A-label (Punycode) form', () => { + expect(normalizeDnsName('db.公司.cn')).to.equal('db.xn--55qx5d.cn'); + expect(normalizeDnsName('db.XN--55QX5D.CN')).to.equal('db.xn--55qx5d.cn'); + }); + + it('preserves underscores, which are valid in DNS names', () => { + expect(normalizeDnsName('test_1.my_host.example.com')).to.equal('test_1.my_host.example.com'); + }); + + it('accepts ASCII xn-- labels that are not valid A-labels, lowercased, as the WHATWG URL Standard does', () => { + expect(normalizeDnsName('XN--A.Example.COM.')).to.equal('xn--a.example.com'); + expect(normalizeDnsName('db1.xn--abc-.example.com')).to.equal('db1.xn--abc-.example.com'); + expect(normalizeDnsName('xn--8i7caa.example.net')).to.equal('xn--8i7caa.example.net'); + }); + + for (const name of [ + '', + '.', + 'bad host.example.com', + 'bad/host.example.com', + 'bad\\host.example.com', + 'bad?host.example.com', + 'bad#host.example.com', + 'bad%68ost.example.com', + 'bad:host.example.com', + 'bad@host.example.com', + '公司.xn--a.cn' + ]) { + it(`throws for ${JSON.stringify(name)}`, () => { + expect(() => normalizeDnsName(name)).to.throw(MongoAPIError, 'Invalid DNS host name'); }); } });