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.
305 lines
9.5 KiB
305 lines
9.5 KiB
'use strict';
|
|
const URL = require('url');
|
|
const qs = require('querystring');
|
|
const dns = require('dns');
|
|
const MongoParseError = require('./error').MongoParseError;
|
|
|
|
/**
|
|
* The following regular expression validates a connection string and breaks the
|
|
* provide string into the following capture groups: [protocol, username, password, hosts]
|
|
*/
|
|
const HOSTS_RX = /(mongodb(?:\+srv|)):\/\/(?: (?:[^:]*) (?: : ([^@]*) )? @ )?([^/?]*)(?:\/|)(.*)/;
|
|
|
|
/**
|
|
* Determines whether a provided address matches the provided parent domain in order
|
|
* to avoid certain attack vectors.
|
|
*
|
|
* @param {String} srvAddress The address to check against a domain
|
|
* @param {String} parentDomain The domain to check the provided address against
|
|
* @return {Boolean} Whether the provided address matches the parent domain
|
|
*/
|
|
function matchesParentDomain(srvAddress, parentDomain) {
|
|
const regex = /^.*?\./;
|
|
const srv = `.${srvAddress.replace(regex, '')}`;
|
|
const parent = `.${parentDomain.replace(regex, '')}`;
|
|
return srv.endsWith(parent);
|
|
}
|
|
|
|
/**
|
|
* Lookup an `mongodb+srv` connection string, combine the parts and reparse it as a normal
|
|
* connection string.
|
|
*
|
|
* @param {string} uri The connection string to parse
|
|
* @param {object} options Optional user provided connection string options
|
|
* @param {function} callback
|
|
*/
|
|
function parseSrvConnectionString(uri, options, callback) {
|
|
const result = URL.parse(uri, true);
|
|
|
|
if (result.hostname.split('.').length < 3) {
|
|
return callback(new MongoParseError('URI does not have hostname, domain name and tld'));
|
|
}
|
|
|
|
result.domainLength = result.hostname.split('.').length;
|
|
if (result.pathname && result.pathname.match(',')) {
|
|
return callback(new MongoParseError('Invalid URI, cannot contain multiple hostnames'));
|
|
}
|
|
|
|
if (result.port) {
|
|
return callback(new MongoParseError(`Ports not accepted with '${PROTOCOL_MONGODB_SRV}' URIs`));
|
|
}
|
|
|
|
let srvAddress = `_mongodb._tcp.${result.host}`;
|
|
dns.resolveSrv(srvAddress, (err, addresses) => {
|
|
if (err) return callback(err);
|
|
|
|
if (addresses.length === 0) {
|
|
return callback(new MongoParseError('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 MongoParseError('Server record does not share hostname with parent URI')
|
|
);
|
|
}
|
|
}
|
|
|
|
let base = result.auth ? `mongodb://${result.auth}@` : `mongodb://`;
|
|
let connectionStrings = addresses.map(
|
|
(address, i) =>
|
|
i === 0 ? `${base}${address.name}:${address.port}` : `${address.name}:${address.port}`
|
|
);
|
|
|
|
let connectionString = connectionStrings.join(',') + '/';
|
|
let connectionStringOptions = [];
|
|
|
|
// Default to SSL true
|
|
if (!options.ssl && (!result.search || result.query['ssl'] == null)) {
|
|
connectionStringOptions.push('ssl=true');
|
|
}
|
|
|
|
// Keep original uri options
|
|
if (result.search) {
|
|
connectionStringOptions.push(result.search.replace('?', ''));
|
|
}
|
|
|
|
dns.resolveTxt(result.host, (err, record) => {
|
|
if (err) {
|
|
if (err.code !== 'ENODATA') {
|
|
return callback(err);
|
|
}
|
|
record = null;
|
|
}
|
|
|
|
if (record) {
|
|
if (record.length > 1) {
|
|
return callback(new MongoParseError('Multiple text records not allowed'));
|
|
}
|
|
|
|
record = record[0];
|
|
record = record.length > 1 ? record.join('') : record[0];
|
|
if (!record.includes('authSource') && !record.includes('replicaSet')) {
|
|
return callback(
|
|
new MongoParseError('Text record must only set `authSource` or `replicaSet`')
|
|
);
|
|
}
|
|
|
|
connectionStringOptions.push(record);
|
|
}
|
|
|
|
// Add any options to the connection string
|
|
if (connectionStringOptions.length) {
|
|
connectionString += `?${connectionStringOptions.join('&')}`;
|
|
}
|
|
|
|
parseConnectionString(connectionString, callback);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Parses a query string item according to the connection string spec
|
|
*
|
|
* @param {Array|String} value The value to parse
|
|
* @return {Array|Object|String} The parsed value
|
|
*/
|
|
function parseQueryStringItemValue(value) {
|
|
if (Array.isArray(value)) {
|
|
// deduplicate and simplify arrays
|
|
value = value.filter((value, idx) => value.indexOf(value) === idx);
|
|
if (value.length === 1) value = value[0];
|
|
} else if (value.indexOf(':') > 0) {
|
|
value = value.split(',').reduce((result, pair) => {
|
|
const parts = pair.split(':');
|
|
result[parts[0]] = parseQueryStringItemValue(parts[1]);
|
|
return result;
|
|
}, {});
|
|
} else if (value.toLowerCase() === 'true' || value.toLowerCase() === 'false') {
|
|
value = value.toLowerCase() === 'true';
|
|
} else if (!Number.isNaN(value)) {
|
|
const numericValue = parseFloat(value);
|
|
if (!Number.isNaN(numericValue)) {
|
|
value = parseFloat(value);
|
|
}
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Parses a query string according the connection string spec.
|
|
*
|
|
* @param {String} query The query string to parse
|
|
* @return {Object|Error} The parsed query string as an object, or an error if one was encountered
|
|
*/
|
|
function parseQueryString(query) {
|
|
const result = {};
|
|
let parsedQueryString = qs.parse(query);
|
|
|
|
for (const key in parsedQueryString) {
|
|
const value = parsedQueryString[key];
|
|
if (value === '' || value == null) {
|
|
return new MongoParseError('Incomplete key value pair for option');
|
|
}
|
|
|
|
result[key.toLowerCase()] = parseQueryStringItemValue(value);
|
|
}
|
|
|
|
// special cases for known deprecated options
|
|
if (result.wtimeout && result.wtimeoutms) {
|
|
delete result.wtimeout;
|
|
console.warn('Unsupported option `wtimeout` specified');
|
|
}
|
|
|
|
return Object.keys(result).length ? result : null;
|
|
}
|
|
|
|
const PROTOCOL_MONGODB = 'mongodb';
|
|
const PROTOCOL_MONGODB_SRV = 'mongodb+srv';
|
|
const SUPPORTED_PROTOCOLS = [PROTOCOL_MONGODB, PROTOCOL_MONGODB_SRV];
|
|
|
|
/**
|
|
* Parses a MongoDB connection string
|
|
*
|
|
* @param {*} uri the MongoDB connection string to parse
|
|
* @param {object} [options] Optional settings.
|
|
* @param {parseCallback} callback
|
|
*/
|
|
function parseConnectionString(uri, options, callback) {
|
|
if (typeof options === 'function') (callback = options), (options = {});
|
|
options = options || {};
|
|
|
|
// Check for bad uris before we parse
|
|
try {
|
|
URL.parse(uri);
|
|
} catch (e) {
|
|
return callback(new MongoParseError('URI malformed, cannot be parsed'));
|
|
}
|
|
|
|
const cap = uri.match(HOSTS_RX);
|
|
if (!cap) {
|
|
return callback(new MongoParseError('Invalid connection string'));
|
|
}
|
|
|
|
const protocol = cap[1];
|
|
if (SUPPORTED_PROTOCOLS.indexOf(protocol) === -1) {
|
|
return callback(new MongoParseError('Invalid protocol provided'));
|
|
}
|
|
|
|
if (protocol === PROTOCOL_MONGODB_SRV) {
|
|
return parseSrvConnectionString(uri, options, callback);
|
|
}
|
|
|
|
const dbAndQuery = cap[4].split('?');
|
|
const db = dbAndQuery.length > 0 ? dbAndQuery[0] : null;
|
|
const query = dbAndQuery.length > 1 ? dbAndQuery[1] : null;
|
|
let parsedOptions = parseQueryString(query);
|
|
if (parsedOptions instanceof MongoParseError) {
|
|
return callback(parsedOptions);
|
|
}
|
|
|
|
parsedOptions = Object.assign({}, parsedOptions, options);
|
|
const auth = { username: null, password: null, db: db && db !== '' ? qs.unescape(db) : null };
|
|
if (cap[4].split('?')[0].indexOf('@') !== -1) {
|
|
return callback(new MongoParseError('Unescaped slash in userinfo section'));
|
|
}
|
|
|
|
const authorityParts = cap[3].split('@');
|
|
if (authorityParts.length > 2) {
|
|
return callback(new MongoParseError('Unescaped at-sign in authority section'));
|
|
}
|
|
|
|
if (authorityParts.length > 1) {
|
|
const authParts = authorityParts.shift().split(':');
|
|
if (authParts.length > 2) {
|
|
return callback(new MongoParseError('Unescaped colon in authority section'));
|
|
}
|
|
|
|
auth.username = qs.unescape(authParts[0]);
|
|
auth.password = authParts[1] ? qs.unescape(authParts[1]) : null;
|
|
}
|
|
|
|
let hostParsingError = null;
|
|
const hosts = authorityParts
|
|
.shift()
|
|
.split(',')
|
|
.map(host => {
|
|
let parsedHost = URL.parse(`mongodb://${host}`);
|
|
if (parsedHost.path === '/:') {
|
|
hostParsingError = new MongoParseError('Double colon in host identifier');
|
|
return null;
|
|
}
|
|
|
|
// heuristically determine if we're working with a domain socket
|
|
if (host.match(/\.sock/)) {
|
|
parsedHost.hostname = qs.unescape(host);
|
|
parsedHost.port = null;
|
|
}
|
|
|
|
if (Number.isNaN(parsedHost.port)) {
|
|
hostParsingError = new MongoParseError('Invalid port (non-numeric string)');
|
|
return;
|
|
}
|
|
|
|
const result = {
|
|
host: parsedHost.hostname,
|
|
port: parsedHost.port ? parseInt(parsedHost.port) : null
|
|
};
|
|
|
|
if (result.port === 0) {
|
|
hostParsingError = new MongoParseError('Invalid port (zero) with hostname');
|
|
return;
|
|
}
|
|
|
|
if (result.port > 65535) {
|
|
hostParsingError = new MongoParseError('Invalid port (larger than 65535) with hostname');
|
|
return;
|
|
}
|
|
|
|
if (result.port < 0) {
|
|
hostParsingError = new MongoParseError('Invalid port (negative number)');
|
|
return;
|
|
}
|
|
|
|
return result;
|
|
})
|
|
.filter(host => !!host);
|
|
|
|
if (hostParsingError) {
|
|
return callback(hostParsingError);
|
|
}
|
|
|
|
if (hosts.length === 0 || hosts[0].host === '' || hosts[0].host === null) {
|
|
return callback(new MongoParseError('No hostname or hostnames provided in connection string'));
|
|
}
|
|
|
|
callback(null, {
|
|
hosts: hosts,
|
|
auth: auth.db || auth.username ? auth : null,
|
|
options: Object.keys(parsedOptions).length ? parsedOptions : null
|
|
});
|
|
}
|
|
|
|
module.exports = parseConnectionString;
|