diff --git a/app/ldap/controllers/RootDSE.controller.js b/app/ldap/controllers/RootDSE.controller.js new file mode 100644 index 0000000..da35701 --- /dev/null +++ b/app/ldap/controllers/RootDSE.controller.js @@ -0,0 +1,114 @@ +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 diff --git a/app/ldap/routes/rootdse.routes.js b/app/ldap/routes/rootdse.routes.js new file mode 100644 index 0000000..65a10a4 --- /dev/null +++ b/app/ldap/routes/rootdse.routes.js @@ -0,0 +1,37 @@ +const rootdse_routes = { + + prefix: false, // false | string + + /* + * The Root DSE lives at the zero-length DN, which sits outside every naming + * context by definition, so these routes must not be suffixed with the + * server's base DC the way every other router's are. (RFC 4512 §5.1) + */ + absolute: true, + + middleware: [ + 'Logger' + ], + + /* + * No BindUser here on purpose. RFC 4512 §5.1 expects the Root DSE to be + * readable before the client has bound, and ldap3 reads it as part of + * connecting. Nothing it exposes is sensitive. + */ + search: { + '': ['ldap_controller::RootDSE.search_root_dse'], + }, + + bind: {}, + + add: {}, + + del: {}, + + modify: {}, + + compare: {}, + +} + +module.exports = exports = rootdse_routes diff --git a/app/unit/LDAPRoutingUnit.js b/app/unit/LDAPRoutingUnit.js index 533702c..0a43f0b 100644 --- a/app/unit/LDAPRoutingUnit.js +++ b/app/unit/LDAPRoutingUnit.js @@ -88,8 +88,15 @@ class LDAPRoutingUnit extends CanonicalUnit { route_functions.push(route_handler) } - this.output.debug(`Registering route ${type} :: ${[route_prefix, suffix].join(',')} with ${route_functions.length} handlers.`) - this.ldap_server.server[type]([route_prefix, suffix].join(','), ...route_functions) + // Routers marked `absolute` opt out of the base DC suffix. The Root + // DSE needs this: its DN is the zero-length string, which sits + // outside every naming context by definition. (RFC 4512 §5.1) + const route_dn = instance.absolute === true + ? route_prefix + : [route_prefix, suffix].join(',') + + this.output.debug(`Registering route ${type} :: ${route_dn} with ${route_functions.length} handlers.`) + this.ldap_server.server[type](route_dn, ...route_functions) } } else { // Unbind has a default handler, so don't warn about that.