Skip to content

Instantly share code, notes, and snippets.

@mcasimir
Last active Oct 21, 2020
Embed
What would you like to do?
gssapi.js fixes

This is a POC fix for some kerberos issues in Compass.

Bug with kerberos in Compass

Compass crashes after a successful authentication to kerberos.

This is caused by the fact that the authentication with kerberos gets triggered multiple times inside the driver (to start the connection pool?), however the GSSPI code is not meant to deal with concurrent calls as that seems to break the state of the authentication completely.

Looks like the kerberos module itself could be not re-entrant.

In the fix contained here we do a serialisation of the authentication reusing the same state for the same requests, and that seems to fix it.

How to reproduce:

Clone this gist and run npm install:

git clone https://gist.github.com/mcasimir/1d89b8fa81c8fed51f387f394abe6c51 reproduce-kerberos-issue
cd reproduce-kerberos-issue
npm i

Clone the manual-testing-images-2 branch of compass and start the kerberos docker-compose setup:

`` git clone git@github.com:mongodb-js/compass.git git checkout manual-testing-images-2 cd compass/docker/kerberos

docker-compose up ``

Make sure you have this line in your /etc/hosts.

127.0.0.1 mongodb-enterprise.example.com

Authenticate with kdc (the password is password):

kinit --kdc-hostname=localhost mongodb.user@EXAMPLE.COM

Run a simulation of what Compass does on connection:

node reproduce-kerberos-issue.js

Misc

GSSAPI Errors

The gssapi module has few issues with the error reported.

  • When built with electron the kerberos module adds some non-ascii characters to error messages ie.: The context has expired: Success0�v��. That looks like extra garbage memory that is getting pulled in.
  • Errors coming from kerberos have no stack trace and no type.
  • More in general is hard for us to provide the user with useful errors since:
    • authentication / gssapi errors are indistinguishable from others
    • client side driver errors has no error code / classification that can be used to point users to docs or a common solution
const dns = require('dns');
const util = require('util');
const AuthProvider = require('./auth_provider').AuthProvider;
const retrieveKerberos = require('../utils').retrieveKerberos;
const MongoError = require('../error').MongoError;
const kGssapiClientCache = Symbol('GSSAPI_CLIENT_CACHE');
let kerberos;
class GSSAPI extends AuthProvider {
prepare(handshakeDoc, authContext, callback) {
if (!this[kGssapiClientCache]) {
this[kGssapiClientCache] = new Map();
}
prepare(this[kGssapiClientCache], handshakeDoc, authContext, callback);
}
auth(authContext, callback) {
auth(this[kGssapiClientCache], authContext, callback);
}
}
module.exports = GSSAPI;
// This should avoid `prepare` and `auth` calls to race both among each other and
// among other calls of the same function.
let lastCall = Promise.resolve();
function serializeCalls(fn) {
if (!lastCall) {
lastCall = Promise.resolve();
}
return async(...args) => {
lastCall = lastCall.then(() => {
return fn(...args);
});
return lastCall;
};
}
// eslint-disable-next-line complexity
const prepare = util.callbackify(serializeCalls(async(clients, handshakeDoc, authContext) => {
if (clients.get(authContext)) { // already prepared for that context
return handshakeDoc;
}
const host = authContext.options.host;
const port = authContext.options.port;
const credentials = authContext.credentials;
if (!host || !port || !credentials) {
throw new MongoError(
`Connection must specify: ${host ? 'host' : ''}, ${port ? 'port' : ''}, ${
credentials ? 'host' : 'credentials'
}.`
);
}
const username = credentials.username;
const password = credentials.password;
const mechanismProperties = credentials.mechanismProperties;
const serviceName =
mechanismProperties.gssapiservicename ||
mechanismProperties.gssapiServiceName ||
'mongodb';
kerberos = kerberos || retrieveKerberos();
const initializeClient = util.promisify(kerberos.initializeClient.bind(kerberos));
const canonicalizedHost = await performGssapiCanonicalizeHostName(host, mechanismProperties);
const initOptions = {};
if (password) {
Object.assign(initOptions, { user: username, password: password });
}
const client = await initializeClient(
`${serviceName}${process.platform === 'win32' ? '/' : '@'}${canonicalizedHost}`,
initOptions
);
if (!client) {
return; // this is translated from `if (!client) return callback();`.
// No idea why we don't throw an error here.
}
clients.set(authContext, client);
return handshakeDoc;
}));
const auth = util.callbackify(serializeCalls(async(clients, authContext) => {
const client = clients.get(authContext);
if (!client) {
throw new MongoError('GSSAPI: client missing');
}
const connection = authContext.connection;
const credentials = authContext.credentials;
if (!credentials) {
throw new MongoError('GSSAPI: credentials required');
}
const username = credentials.username;
const externalCommand = util.promisify((command, cb) => {
return connection.command('$external.$cmd', command, cb);
});
const clientStep = util.promisify(client.step.bind(client));
const stepPayload = await (clientStep('').catch(adaptKerberosError()));
const saslStartCommand = saslStart(stepPayload);
const { result: saslStartResult } = await externalCommand(
saslStartCommand
);
const negotiationPayload = await (negotiate(
client, 10, saslStartResult.payload).catch(adaptKerberosError()));
const { result: saslContinueResult } = await externalCommand(
saslContinue(negotiationPayload, saslStartResult.conversationId)
);
const finalizePayload = await (finalize(
client, username, saslContinueResult.payload
).catch(adaptKerberosError()));
return await externalCommand(
{
saslContinue: 1,
conversationId: saslContinueResult.conversationId,
payload: finalizePayload
}
);
}));
// Errors coming from the kerberos module does not have
// a stack and are quite confusing as they always end with a ': Success' and
// there is nothing suggesting a GSSAPI failure.
//
// Also in electron a random amount of garbage characters gets pulled
// in the message.
//
// This is an attempt to improve the situation a bit, however all of this should
// better be done properly in the kerberos module.
//
function adaptKerberosError() {
return (err) => {
let message = err && err.message;
if (!message) message = 'Unknown Kerberos Error';
message = message.replace(/: Success.*/, '');
message = `GSSAPI: ${message}`;
return Promise.reject(new MongoError(message));
};
}
function saslStart(payload) {
return {
saslStart: 1,
mechanism: 'GSSAPI',
payload,
autoAuthorize: 1
};
}
function saslContinue(payload, conversationId) {
return {
saslContinue: 1,
conversationId,
payload
};
}
function negotiateCb(client, retries, payload, callback) {
client.step(payload, (err, response) => {
// Retries exhausted, raise error
if (err && retries === 0) return callback(err);
// Adjust number of retries and call step again
if (err) return negotiateCb(client, retries - 1, payload, callback);
// Return the payload
callback(undefined, response || '');
});
}
const negotiate = util.promisify(negotiateCb);
function finalizeCb(client, user, payload, callback) {
// GSS Client Unwrap
client.unwrap(payload, (err, response) => {
if (err) return callback(err);
// Wrap the response
client.wrap(response || '', { user }, (wrapErr, wrapped) => {
if (wrapErr) return callback(wrapErr);
// Return the payload
callback(undefined, wrapped);
});
});
}
const finalize = util.promisify(finalizeCb);
function performGssapiCanonicalizeHostNameCb(host, mechanismProperties, callback) {
const canonicalizeHostName =
typeof mechanismProperties.gssapiCanonicalizeHostName === 'boolean'
? mechanismProperties.gssapiCanonicalizeHostName
: false;
if (!canonicalizeHostName) return callback(undefined, host);
// Attempt to resolve the host name
dns.resolveCname(host, (err, r) => {
if (err) return callback(err);
// Get the first resolve host id
if (Array.isArray(r) && r.length > 0) {
return callback(undefined, r[0]);
}
callback(undefined, host);
});
}
const performGssapiCanonicalizeHostName = util.promisify(performGssapiCanonicalizeHostNameCb);
{
"name": "compass-kerberos-issue",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "jest",
"lint": "eslint ."
},
"keywords": [],
"author": "",
"license": "ISC",
"engines": {
"node": ">= 12"
},
"devDependencies": {
"eslint": "^7.11.0",
"eslint-config-google": "^0.14.0",
"eslint-plugin-jest": "^24.1.0",
"eslint-plugin-node": "^11.1.0",
"jest": "^26.6.0"
},
"dependencies": {
"debug": "^4.2.0",
"kerberos": "^1.1.4",
"lodash": "^4.17.20",
"mongodb": "^3.6.2",
"mongodb-build-info": "^1.1.0",
"mongodb-js-errors": "^0.5.0",
"mongodb-ns": "^2.2.0"
}
}
const { MongoClient, ReadPreference } = require('mongodb');
const { isEnterprise, getGenuineMongoDB, getDataLake } = require('mongodb-build-info');
const { isNotAuthorized } = require('mongodb-js-errors');
const { get, union, unionBy } = require('lodash');
const toNS = require('mongodb-ns');
const debug = require('debug')('instance-details');
async function fetchClusterDetails(client) {
const adminDb = client.db('admin');
const [
connectionStatus,
cmdLineOpts,
rawHostInfo,
rawBuildInfo,
listedDatabases,
] = await Promise.all([
adminDb.command({ connectionStatus: 1, showPrivileges: true }),
adminDb.command({ getCmdLineOpts: 1 }).catch(ignoreNotAuthorized(null)),
adminDb.command({ hostInfo: 1 }).catch(ignoreNotAuthorized({})),
adminDb.command({ buildInfo: 1 }).catch(ignoreNotAuthorized({})),
adminDb.command({ listDatabases: 1 }).catch(ignoreNotAuthorized(null))
]);
const databases = await fetchDatabases(
client,
connectionStatus,
listedDatabases
);
return {
build: adaptBuildInfo(rawBuildInfo),
dataLake: buildDataLakeInfo(rawBuildInfo),
genuineMongoDB: buildGenuineMongoDBInfo(rawBuildInfo, cmdLineOpts),
host: adaptHostInfo(rawHostInfo),
databases: databases
};
}
function isMongosLocalException(err) {
if (!err) {
return false;
}
var msg = err.message || err.err || JSON.stringify(err);
return new RegExp('database through mongos').test(msg);
}
function ignoreNotAuthorized(fallback) {
return (err) => {
if (isNotAuthorized(err)) {
debug('ignoring not authorized error and returning fallback value:', {err, fallback});
return fallback;
}
return Promise.reject(err)
};
}
function ignoreMongosLocalException(fallback) {
return (err) => {
if (isMongosLocalException(err)) {
debug('ignoring mongos action on local db error and returning fallback value:', {
err,
fallback
});
return fallback;
}
return Promise.reject(err)
};
}
function adaptHostInfo(rawHostInfo) {
return {
system_time: get(rawHostInfo, 'system.currentTime'),
hostname: get(rawHostInfo, 'system.hostname') || 'unknown',
os: get(rawHostInfo, 'os.name'),
os_family: (get(rawHostInfo, 'os.type') || '').toLowerCase(),
kernel_version: get(rawHostInfo, 'os.version'),
kernel_version_string: get(rawHostInfo, 'extra.versionString'),
memory_bits:
parseInt(get(rawHostInfo, 'system.memSizeMB') || 0, 10) * 1024 * 1024,
memory_page_size: get(rawHostInfo, 'extra.pageSize'),
arch: get(rawHostInfo, 'system.cpuArch'),
cpu_cores: get(rawHostInfo, 'system.numCores'),
cpu_cores_physical: get(rawHostInfo, 'extra.physicalCores'),
cpu_scheduler: get(rawHostInfo, 'extra.scheduler'),
cpu_frequency:
parseInt(get(rawHostInfo, 'extra.cpuFrequencyMHz') || 0, 10) * 1000000,
cpu_string: get(rawHostInfo, 'extra.cpuString'),
cpu_bits: get(rawHostInfo, 'system.cpuAddrSize'),
machine_model: get(rawHostInfo, 'extra.model'),
feature_numa: get(rawHostInfo, 'system.numaEnabled'),
feature_always_full_sync: get(rawHostInfo, 'extra.alwaysFullSync'),
feature_nfs_async: get(rawHostInfo, 'extra.nfsAsync')
};
}
function adaptBuildInfo(rawBuildInfo) {
return {
version: rawBuildInfo.version,
commit: rawBuildInfo.gitVersion,
commit_url: rawBuildInfo.gitVersion ?
`https://github.com/mongodb/mongo/commit/${rawBuildInfo.gitVersion}` : '',
flags_loader: rawBuildInfo.loaderFlags,
flags_compiler: rawBuildInfo.compilerFlags,
allocator: rawBuildInfo.allocator,
javascript_engine: rawBuildInfo.javascriptEngine,
debug: rawBuildInfo.debug,
for_bits: rawBuildInfo.bits,
max_bson_object_size: rawBuildInfo.maxBsonObjectSize,
// Cover both cases of detecting enterprise module, see SERVER-18099.
enterprise_module: isEnterprise(rawBuildInfo),
query_engine: rawBuildInfo.queryEngine ? rawBuildInfo.queryEngine : null
};
}
function buildGenuineMongoDBInfo(buildInfo, cmdLineOpts) {
const {
isGenuine,
serverName
} = getGenuineMongoDB(buildInfo, cmdLineOpts);
return {
isGenuine,
dbType: serverName
};
}
function buildDataLakeInfo(buildInfo) {
const { isDataLake, dlVersion } = getDataLake(buildInfo);
return {
isDataLake,
version: dlVersion
};
}
async function fetchDatabases(client, connectionStatus, listedDatabases) {
const privileges = extractPrivilegesByDatabaseAndCollection(connectionStatus);
const databaseNames = getDatabaseNames(
client,
listedDatabases,
privileges
);
const databases = (
await Promise.all(
databaseNames.map((name) => fetchDatabase(client, name, privileges))
)
)
.filter(Boolean)
.filter(({ name }) => name);
return databases;
}
function extractPrivilegesByDatabaseAndCollection(connectionStatus) {
const privileges = get(
connectionStatus,
'authInfo.authenticatedUserPrivileges', []);
const databases = {};
for (const privilege of privileges) {
const {db, collection} = (privilege || {}).resource || {};
databases[db] = {[collection || '']: privilege.actions || []};
}
return databases;
}
function getDatabaseNames(
client,
listedDatabases,
privileges
) {
const connectionDatabase = get(client, 's.options.dbName', 'test');
const listedDatabaseNames = (
(listedDatabases || {}).databases || []
).map(({name}) => name);
// we pull in the database names listed among the user privileges.
// this accounts for situations where a user would not have rights to listDatabases
// on the cluster but is authorized to perform actions on specific databases.
const databasesFromPrivileges = Object.keys(privileges);
return union(
[connectionDatabase],
listedDatabaseNames,
databasesFromPrivileges
)
.filter(
(databaseName) => (
!!databaseName &&
!isSystemDatabase(databaseName)
)
);
}
async function fetchDatabase(client, dbName, privileges = {}) {
const db = client.db(dbName);
/**
* @note: Durran: For some reason the listCollections call does not take into
* account the read preference that was set on the db instance - it only looks
* in the passed options: https://github.com/mongodb/node-mongodb-native/blob/2.2/lib/db.js#L671
*/
const readPreference = get(db, 's.readPreference', ReadPreference.PRIMARY);
const [database, rawCollections] = await Promise.all([
db.command({ dbStats: 1 })
.catch(ignoreNotAuthorized({
db: dbName
}))
.then(adaptDatabaseInfo),
db.listCollections({}, { readPreference })
.toArray()
.catch(ignoreNotAuthorized([]))
.catch(ignoreMongosLocalException([]))
]);
const listedCollections = rawCollections
.map((rawCollection) => ({ db: dbName, ...rawCollection }))
const collectionsFromPrivileges =
Object.keys(privileges[dbName] || {})
.filter(Boolean)
.filter((name) => !isSystemCollection(name))
.map((name) => ({
db: dbName,
name
}));
const collections = unionBy(
listedCollections,
collectionsFromPrivileges,
'name'
)
.filter(Boolean)
.filter(({name, db}) => name && db)
.map(adaptCollectionInfo);
return {
...database,
collections
};
}
function isSystemCollection(name) {
return name.startsWith('system.');
}
function isSystemDatabase(name) {
return name === 'config' ||
name === 'local' ||
name === 'admin'
}
function adaptDatabaseInfo(databaseStats = {}) {
return {
_id: databaseStats.db,
name: databaseStats.db,
document_count: databaseStats.objects || 0,
storage_size: databaseStats.storageSize || 0,
index_count: databaseStats.indexes || 0,
index_size: databaseStats.indexSize || 0
};
}
function adaptCollectionInfo(rawCollectionInfo) {
const ns = toNS(rawCollectionInfo.db + '.' + rawCollectionInfo.name);
return {
_id: ns.toString(),
name: ns.collection,
database: ns.database,
readonly: get(rawCollectionInfo, 'info.readOnly', false),
collation: get(rawCollectionInfo, 'options.collation', null),
type: get(rawCollectionInfo, 'type', 'collection'),
view_on: get(rawCollectionInfo, 'options.viewOn', undefined),
pipeline: get(rawCollectionInfo, 'options.pipeline', undefined)
};
}
async function main() {
let client;
const connectionString = 'mongodb://mongodb.user%40EXAMPLE.COM@mongodb-enterprise.EXAMPLE.COM:29017?authMechanism=GSSAPI&authSource=%24external&gssapiServiceName=mongodb';
try {
client = await MongoClient.connect(
connectionString,
{
useUnifiedTopology: true,
useNewUrlParser: true
}
);
console.info('\nconnected.\n');
const details = await fetchClusterDetails(client);
console.info('\ninstance details:.\n', JSON.stringify(details));
} finally {
if (client) {
client.close();
}
}
}
main();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment