const LDAPController = require('./LDAPController') const LDAP = require('ldapjs') /** * Serves the Root DSE: the zero-length-DN entry clients read to discover what * the server supports. (RFC 4512 §5.1) * * This is mounted on the empty DN, which ldapjs treats as a wildcard matching * every request no other route claimed - see Server.prototype._getHandlerChain, * which tests `suffix === ''` alongside the real DN comparisons. That makes this * controller the server's catch-all for searches, so it also has to reproduce * ldapjs' own noSuffixHandler for DNs that genuinely don't exist. */ class RootDSEController extends LDAPController { static get services() { return [...super.services, 'output', 'ldap_server', 'configs'] } async search_root_dse(req, res, next) { // The empty DN matches everything, so anything that isn't literally the // Root DSE arrived here unrouted. Answer exactly as ldapjs would have if // this route didn't exist. if ( req.dn.toString() !== '' ) { return next(new LDAP.NoSuchObjectError(`No tree found for: ${req.dn.toString()}`)) } // The Root DSE belongs to no naming context, so only a baseObject search // returns it. Wider scopes get an empty success rather than the directory. if ( req.scope !== 'base' ) { this.output.debug(`Ignoring ${req.scope} scope search on the Root DSE.`) res.end() return next() } this.output.debug(`Running base DN search for the Root DSE.`) // Built via createSearchEntry() rather than passing a plain object to // res.send(): that path runs ldapjs' attribute filter, which only knows // about '*' and would drop everything here for a '+'-only request. res.send(res.createSearchEntry({ objectName: LDAP.parseDN(''), attributes: this.select_attributes(this.root_dse(), req.attributes), })) res.end() return next() } /** * Build the Root DSE attribute set. * * Deliberately absent - RFC 4512 §5.1 makes all of these optional, and * advertising something unimplemented is worse than staying quiet: * * - supportedControl, supportedExtension, supportedFeatures: no router * registers an exop, and no request control is honoured. * - supportedSASLMechanisms: bind is simple-only, see LDAPController#bind. * - subschemaSubentry: there is no cn=Subschema entry to point at. ldap3 * follows this attribute and fails the connection if the lookup misses, * so omitting it makes clients skip the schema fetch instead. * * @returns {object} */ root_dse() { return { objectClass: ['top'], namingContexts: this.naming_contexts(), supportedLDAPVersion: ['3'], vendorName: ['Starship CoreID'], } } /** * The suffixes this server answers for. CoreID serves one tree, rooted at * the configured base DC. * @returns {string[]} */ naming_contexts() { return [this.ldap_server.build_dn().format(this.configs.get('ldap:server.format'))] } /** * Select the subset of the Root DSE the client actually asked for. * * Everything here but objectClass is an operational attribute, returned only * on an explicit request or a wildcard. ldap3 asks for both '*' and '+'. * * @param {object} attributes * @param {string[]} requested - req.attributes, lower-cased by the ldapjs parser * @returns {object} */ select_attributes(attributes, requested) { // '1.1' is the RFC 4511 §4.5.1 "no attributes" OID. if ( requested.length === 1 && requested[0] === '1.1' ) { return {} } // Naming nothing means "all user attributes", same as '*'. if ( !requested.length || requested.includes('*') || requested.includes('+') ) { return attributes } const selected = {} for ( const name in attributes ) { if ( requested.includes(name.toLowerCase()) ) { selected[name] = attributes[name] } } return selected } } module.exports = exports = RootDSEController