You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

638 lines
21 KiB

2 months ago
'use strict';
const ReadPreference = require('./core').ReadPreference;
const parser = require('url');
const f = require('util').format;
const Logger = require('./core').Logger;
const dns = require('dns');
const ReadConcern = require('./read_concern');
const qs = require('querystring');
const MongoParseError = require('./core/error').MongoParseError;
module.exports = function(url, options, callback) {
if (typeof options === 'function') (callback = options), (options = {});
options = options || {};
let result;
try {
result = parser.parse(url, true);
} catch (e) {
return callback(new Error('URL malformed, cannot be parsed'));
}
if (result.protocol !== 'mongodb:' && result.protocol !== 'mongodb+srv:') {
return callback(new Error('Invalid schema, expected `mongodb` or `mongodb+srv`'));
}
if (result.protocol === 'mongodb:') {
return parseHandler(url, options, callback);
}
// Otherwise parse this as an SRV record
if (result.hostname.split('.').length < 3) {
return callback(new Error('URI does not have hostname, domain name and tld'));
}
result.domainLength = result.hostname.split('.').length;
const hostname = url.substring('mongodb+srv://'.length).split('/')[0];
if (hostname.match(',')) {
return callback(new Error('Invalid URI, cannot contain multiple hostnames'));
}
if (result.port) {
return callback(new Error('Ports not accepted with `mongodb+srv` URIs'));
}
let srvAddress = `_mongodb._tcp.${result.host}`;
dns.resolveSrv(srvAddress, function(err, addresses) {
if (err) return callback(err);
if (addresses.length === 0) {
return callback(new Error('No addresses found at host'));
}
for (let i = 0; i < addresses.length; i++) {
if (!matchesParentDomain(addresses[i].name, result.hostname, result.domainLength)) {
return callback(new Error('Server record does not share hostname with parent URI'));
}
}
let base = result.auth ? `mongodb://${result.auth}@` : `mongodb://`;
let connectionStrings = addresses.map(function(address, i) {
if (i === 0) return `${base}${address.name}:${address.port}`;
else return `${address.name}:${address.port}`;
});
let connectionString = connectionStrings.join(',') + '/';
let connectionStringOptions = [];
// Add the default database if needed
if (result.path) {
let defaultDb = result.path.slice(1);
if (defaultDb.indexOf('?') !== -1) {
defaultDb = defaultDb.slice(0, defaultDb.indexOf('?'));
}
connectionString += defaultDb;
}
// Default to SSL true
if (!options.ssl && !result.search) {
connectionStringOptions.push('ssl=true');
} else if (!options.ssl && result.search && !result.search.match('ssl')) {
connectionStringOptions.push('ssl=true');
}
// Keep original uri options
if (result.search) {
connectionStringOptions.push(result.search.replace('?', ''));
}
dns.resolveTxt(result.host, function(err, record) {
if (err && err.code !== 'ENODATA' && err.code !== 'ENOTFOUND') return callback(err);
if (err && err.code === 'ENODATA') record = null;
if (record) {
if (record.length > 1) {
return callback(new MongoParseError('Multiple text records not allowed'));
}
record = record[0].join('');
const parsedRecord = qs.parse(record);
const items = Object.keys(parsedRecord);
if (Object.keys(items).some(k => k.toLowerCase() === 'loadbalanced')) {
return callback(new MongoParseError('Load balancer mode requires driver version 4+'));
}
if (items.some(item => item !== 'authSource' && item !== 'replicaSet')) {
return callback(
new MongoParseError('Text record must only set `authSource` or `replicaSet`')
);
}
if (items.length > 0) {
connectionStringOptions.push(record);
}
}
// Add any options to the connection string
if (connectionStringOptions.length) {
connectionString += `?${connectionStringOptions.join('&')}`;
}
parseHandler(connectionString, options, callback);
});
});
};
function matchesParentDomain(srvAddress, parentDomain) {
let regex = /^.*?\./;
let srv = `.${srvAddress.replace(regex, '')}`;
let parent = `.${parentDomain.replace(regex, '')}`;
if (srv.endsWith(parent)) return true;
else return false;
}
function parseHandler(address, options, callback) {
let result, err;
try {
result = parseConnectionString(address, options);
} catch (e) {
err = e;
}
return err ? callback(err, null) : callback(null, result);
}
function parseConnectionString(url, options) {
// Variables
let connection_part = '';
let auth_part = '';
let query_string_part = '';
let dbName = 'admin';
// Url parser result
let result = parser.parse(url, true);
if ((result.hostname == null || result.hostname === '') && url.indexOf('.sock') === -1) {
throw new Error('No hostname or hostnames provided in connection string');
}
if (result.port === '0') {
throw new Error('Invalid port (zero) with hostname');
}
if (!isNaN(parseInt(result.port, 10)) && parseInt(result.port, 10) > 65535) {
throw new Error('Invalid port (larger than 65535) with hostname');
}
if (
result.path &&
result.path.length > 0 &&
result.path[0] !== '/' &&
url.indexOf('.sock') === -1
) {
throw new Error('Missing delimiting slash between hosts and options');
}
if (result.query) {
for (let name in result.query) {
if (name.indexOf('::') !== -1) {
throw new Error('Double colon in host identifier');
}
if (result.query[name] === '') {
throw new Error('Query parameter ' + name + ' is an incomplete value pair');
}
}
}
if (result.auth) {
let parts = result.auth.split(':');
if (url.indexOf(result.auth) !== -1 && parts.length > 2) {
throw new Error('Username with password containing an unescaped colon');
}
if (url.indexOf(result.auth) !== -1 && result.auth.indexOf('@') !== -1) {
throw new Error('Username containing an unescaped at-sign');
}
}
// Remove query
let clean = url.split('?').shift();
// Extract the list of hosts
let strings = clean.split(',');
let hosts = [];
for (let i = 0; i < strings.length; i++) {
let hostString = strings[i];
if (hostString.indexOf('mongodb') !== -1) {
if (hostString.indexOf('@') !== -1) {
hosts.push(hostString.split('@').pop());
} else {
hosts.push(hostString.substr('mongodb://'.length));
}
} else if (hostString.indexOf('/') !== -1) {
hosts.push(hostString.split('/').shift());
} else if (hostString.indexOf('/') === -1) {
hosts.push(hostString.trim());
}
}
for (let i = 0; i < hosts.length; i++) {
let r = parser.parse(f('mongodb://%s', hosts[i].trim()));
if (r.path && r.path.indexOf('.sock') !== -1) continue;
if (r.path && r.path.indexOf(':') !== -1) {
// Not connecting to a socket so check for an extra slash in the hostname.
// Using String#split as perf is better than match.
if (r.path.split('/').length > 1 && r.path.indexOf('::') === -1) {
throw new Error('Slash in host identifier');
} else {
throw new Error('Double colon in host identifier');
}
}
}
// If we have a ? mark cut the query elements off
if (url.indexOf('?') !== -1) {
query_string_part = url.substr(url.indexOf('?') + 1);
connection_part = url.substring('mongodb://'.length, url.indexOf('?'));
} else {
connection_part = url.substring('mongodb://'.length);
}
// Check if we have auth params
if (connection_part.indexOf('@') !== -1) {
auth_part = connection_part.split('@')[0];
connection_part = connection_part.split('@')[1];
}
// Check there is not more than one unescaped slash
if (connection_part.split('/').length > 2) {
throw new Error(
"Unsupported host '" +
connection_part.split('?')[0] +
"', hosts must be URL encoded and contain at most one unencoded slash"
);
}
// Check if the connection string has a db
if (connection_part.indexOf('.sock') !== -1) {
if (connection_part.indexOf('.sock/') !== -1) {
dbName = connection_part.split('.sock/')[1];
// Check if multiple database names provided, or just an illegal trailing backslash
if (dbName.indexOf('/') !== -1) {
if (dbName.split('/').length === 2 && dbName.split('/')[1].length === 0) {
throw new Error('Illegal trailing backslash after database name');
}
throw new Error('More than 1 database name in URL');
}
connection_part = connection_part.split(
'/',
connection_part.indexOf('.sock') + '.sock'.length
);
}
} else if (connection_part.indexOf('/') !== -1) {
// Check if multiple database names provided, or just an illegal trailing backslash
if (connection_part.split('/').length > 2) {
if (connection_part.split('/')[2].length === 0) {
throw new Error('Illegal trailing backslash after database name');
}
throw new Error('More than 1 database name in URL');
}
dbName = connection_part.split('/')[1];
connection_part = connection_part.split('/')[0];
}
// URI decode the host information
connection_part = decodeURIComponent(connection_part);
// Result object
let object = {};
// Pick apart the authentication part of the string
let authPart = auth_part || '';
let auth = authPart.split(':', 2);
// Decode the authentication URI components and verify integrity
let user = decodeURIComponent(auth[0]);
if (auth[0] !== encodeURIComponent(user)) {
throw new Error('Username contains an illegal unescaped character');
}
auth[0] = user;
if (auth[1]) {
let pass = decodeURIComponent(auth[1]);
if (auth[1] !== encodeURIComponent(pass)) {
throw new Error('Password contains an illegal unescaped character');
}
auth[1] = pass;
}
// Add auth to final object if we have 2 elements
if (auth.length === 2) object.auth = { user: auth[0], password: auth[1] };
// if user provided auth options, use that
if (options && options.auth != null) object.auth = options.auth;
// Variables used for temporary storage
let hostPart;
let urlOptions;
let servers;
let compression;
let serverOptions = { socketOptions: {} };
let dbOptions = { read_preference_tags: [] };
let replSetServersOptions = { socketOptions: {} };
let mongosOptions = { socketOptions: {} };
// Add server options to final object
object.server_options = serverOptions;
object.db_options = dbOptions;
object.rs_options = replSetServersOptions;
object.mongos_options = mongosOptions;
// Let's check if we are using a domain socket
if (url.match(/\.sock/)) {
// Split out the socket part
let domainSocket = url.substring(
url.indexOf('mongodb://') + 'mongodb://'.length,
url.lastIndexOf('.sock') + '.sock'.length
);
// Clean out any auth stuff if any
if (domainSocket.indexOf('@') !== -1) domainSocket = domainSocket.split('@')[1];
domainSocket = decodeURIComponent(domainSocket);
servers = [{ domain_socket: domainSocket }];
} else {
// Split up the db
hostPart = connection_part;
// Deduplicate servers
let deduplicatedServers = {};
// Parse all server results
servers = hostPart
.split(',')
.map(function(h) {
let _host, _port, ipv6match;
//check if it matches [IPv6]:port, where the port number is optional
if ((ipv6match = /\[([^\]]+)\](?::(.+))?/.exec(h))) {
_host = ipv6match[1];
_port = parseInt(ipv6match[2], 10) || 27017;
} else {
//otherwise assume it's IPv4, or plain hostname
let hostPort = h.split(':', 2);
_host = hostPort[0] || 'localhost';
_port = hostPort[1] != null ? parseInt(hostPort[1], 10) : 27017;
// Check for localhost?safe=true style case
if (_host.indexOf('?') !== -1) _host = _host.split(/\?/)[0];
}
// No entry returned for duplicate server
if (deduplicatedServers[_host + '_' + _port]) return null;
deduplicatedServers[_host + '_' + _port] = 1;
// Return the mapped object
return { host: _host, port: _port };
})
.filter(function(x) {
return x != null;
});
}
// Get the db name
object.dbName = dbName || 'admin';
// Split up all the options
urlOptions = (query_string_part || '').split(/[&;]/);
if (urlOptions.some(k => k.toLowerCase() === 'loadbalanced')) {
throw new MongoParseError('Load balancer mode requires driver version 4+');
}
// Ugh, we have to figure out which options go to which constructor manually.
urlOptions.forEach(function(opt) {
if (!opt) return;
var splitOpt = opt.split('='),
name = splitOpt[0],
value = splitOpt[1];
// Options implementations
switch (name) {
case 'slaveOk':
case 'slave_ok':
serverOptions.slave_ok = value === 'true';
dbOptions.slaveOk = value === 'true';
break;
case 'maxPoolSize':
case 'poolSize':
serverOptions.poolSize = parseInt(value, 10);
replSetServersOptions.poolSize = parseInt(value, 10);
break;
case 'appname':
object.appname = decodeURIComponent(value);
break;
case 'autoReconnect':
case 'auto_reconnect':
serverOptions.auto_reconnect = value === 'true';
break;
case 'ssl':
if (value === 'prefer') {
serverOptions.ssl = value;
replSetServersOptions.ssl = value;
mongosOptions.ssl = value;
break;
}
serverOptions.ssl = value === 'true';
replSetServersOptions.ssl = value === 'true';
mongosOptions.ssl = value === 'true';
break;
case 'sslValidate':
serverOptions.sslValidate = value === 'true';
replSetServersOptions.sslValidate = value === 'true';
mongosOptions.sslValidate = value === 'true';
break;
case 'replicaSet':
case 'rs_name':
replSetServersOptions.rs_name = value;
break;
case 'reconnectWait':
replSetServersOptions.reconnectWait = parseInt(value, 10);
break;
case 'retries':
replSetServersOptions.retries = parseInt(value, 10);
break;
case 'readSecondary':
case 'read_secondary':
replSetServersOptions.read_secondary = value === 'true';
break;
case 'fsync':
dbOptions.fsync = value === 'true';
break;
case 'journal':
dbOptions.j = value === 'true';
break;
case 'safe':
dbOptions.safe = value === 'true';
break;
case 'nativeParser':
case 'native_parser':
dbOptions.native_parser = value === 'true';
break;
case 'readConcernLevel':
dbOptions.readConcern = new ReadConcern(value);
break;
case 'connectTimeoutMS':
serverOptions.socketOptions.connectTimeoutMS = parseInt(value, 10);
replSetServersOptions.socketOptions.connectTimeoutMS = parseInt(value, 10);
mongosOptions.socketOptions.connectTimeoutMS = parseInt(value, 10);
break;
case 'socketTimeoutMS':
serverOptions.socketOptions.socketTimeoutMS = parseInt(value, 10);
replSetServersOptions.socketOptions.socketTimeoutMS = parseInt(value, 10);
mongosOptions.socketOptions.socketTimeoutMS = parseInt(value, 10);
break;
case 'w':
dbOptions.w = parseInt(value, 10);
if (isNaN(dbOptions.w)) dbOptions.w = value;
break;
case 'authSource':
dbOptions.authSource = value;
break;
case 'gssapiServiceName':
dbOptions.gssapiServiceName = value;
break;
case 'authMechanism':
if (value === 'GSSAPI') {
// If no password provided decode only the principal
if (object.auth == null) {
let urlDecodeAuthPart = decodeURIComponent(authPart);
if (urlDecodeAuthPart.indexOf('@') === -1)
throw new Error('GSSAPI requires a provided principal');
object.auth = { user: urlDecodeAuthPart, password: null };
} else {
object.auth.user = decodeURIComponent(object.auth.user);
}
} else if (value === 'MONGODB-X509') {
object.auth = { user: decodeURIComponent(authPart) };
}
// Only support GSSAPI or MONGODB-CR for now
if (
value !== 'GSSAPI' &&
value !== 'MONGODB-X509' &&
value !== 'MONGODB-CR' &&
value !== 'DEFAULT' &&
value !== 'SCRAM-SHA-1' &&
value !== 'SCRAM-SHA-256' &&
value !== 'PLAIN'
)
throw new Error(
'Only DEFAULT, GSSAPI, PLAIN, MONGODB-X509, or SCRAM-SHA-1 is supported by authMechanism'
);
// Authentication mechanism
dbOptions.authMechanism = value;
break;
case 'authMechanismProperties':
{
// Split up into key, value pairs
let values = value.split(',');
let o = {};
// For each value split into key, value
values.forEach(function(x) {
let v = x.split(':');
o[v[0]] = v[1];
});
// Set all authMechanismProperties
dbOptions.authMechanismProperties = o;
// Set the service name value
if (typeof o.SERVICE_NAME === 'string') dbOptions.gssapiServiceName = o.SERVICE_NAME;
if (typeof o.SERVICE_REALM === 'string') dbOptions.gssapiServiceRealm = o.SERVICE_REALM;
if (typeof o.CANONICALIZE_HOST_NAME === 'string')
dbOptions.gssapiCanonicalizeHostName =
o.CANONICALIZE_HOST_NAME === 'true' ? true : false;
}
break;
case 'wtimeoutMS':
dbOptions.wtimeout = parseInt(value, 10);
break;
case 'readPreference':
if (!ReadPreference.isValid(value))
throw new Error(
'readPreference must be either primary/primaryPreferred/secondary/secondaryPreferred/nearest'
);
dbOptions.readPreference = value;
break;
case 'maxStalenessSeconds':
dbOptions.maxStalenessSeconds = parseInt(value, 10);
break;
case 'readPreferenceTags':
{
// Decode the value
value = decodeURIComponent(value);
// Contains the tag object
let tagObject = {};
if (value == null || value === '') {
dbOptions.read_preference_tags.push(tagObject);
break;
}
// Split up the tags
let tags = value.split(/,/);
for (let i = 0; i < tags.length; i++) {
let parts = tags[i].trim().split(/:/);
tagObject[parts[0]] = parts[1];
}
// Set the preferences tags
dbOptions.read_preference_tags.push(tagObject);
}
break;
case 'compressors':
{
compression = serverOptions.compression || {};
let compressors = value.split(',');
if (
!compressors.every(function(compressor) {
return compressor === 'snappy' || compressor === 'zlib';
})
) {
throw new Error('Compressors must be at least one of snappy or zlib');
}
compression.compressors = compressors;
serverOptions.compression = compression;
}
break;
case 'zlibCompressionLevel':
{
compression = serverOptions.compression || {};
let zlibCompressionLevel = parseInt(value, 10);
if (zlibCompressionLevel < -1 || zlibCompressionLevel > 9) {
throw new Error('zlibCompressionLevel must be an integer between -1 and 9');
}
compression.zlibCompressionLevel = zlibCompressionLevel;
serverOptions.compression = compression;
}
break;
case 'retryWrites':
dbOptions.retryWrites = value === 'true';
break;
case 'minSize':
dbOptions.minSize = parseInt(value, 10);
break;
default:
{
let logger = Logger('URL Parser');
logger.warn(`${name} is not supported as a connection string option`);
}
break;
}
});
// No tags: should be null (not [])
if (dbOptions.read_preference_tags.length === 0) {
dbOptions.read_preference_tags = null;
}
// Validate if there are an invalid write concern combinations
if (
(dbOptions.w === -1 || dbOptions.w === 0) &&
(dbOptions.journal === true || dbOptions.fsync === true || dbOptions.safe === true)
)
throw new Error('w set to -1 or 0 cannot be combined with safe/w/journal/fsync');
// If no read preference set it to primary
if (!dbOptions.readPreference) {
dbOptions.readPreference = 'primary';
}
// make sure that user-provided options are applied with priority
dbOptions = Object.assign(dbOptions, options);
// Add servers to result
object.servers = servers;
// Returned parsed object
return object;
}