Files
CoreID/app/ldap/controllers/RootDSE.controller.js
garrettmills e1d7a809f9
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/deployment/woodpecker Pipeline was successful
LDAP server - add handling for root DSE queries (needed for Radicale 3.5.2+)
2026-09-22 22:39:18 -05:00

115 lines
4.2 KiB
JavaScript

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