LDAP server - add handling for root DSE queries (needed for Radicale 3.5.2+)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/deployment/woodpecker Pipeline was successful

This commit is contained in:
2026-09-22 22:39:18 -05:00
parent 4398d8dfb3
commit e1d7a809f9
3 changed files with 160 additions and 2 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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.