// LDAP search handler. Returns Saltcorn users as inetOrgPerson entries (with // mail + memberOf) and the effective groups as groupOfNames entries, where the // membership reuses the SAME identity model as OIDC (groups.effectiveGroups = // role-as-group + custom groups). Requires an authenticated (non-anonymous) // bind, enforces a filter-nesting-depth cap, selects requested attributes // case-insensitively, resolves the tenant from the search base, and DENIES a // search whose base tenant differs from the bound connection's tenant. // // MVP: loads all users and filters via req.filter.matches (fine for dev-scale // directories). A production build would translate the LDAP filter into a // targeted query. const User = require("@saltcorn/data/models/user"); const groups = require("../groups"); const vendor = require("./vendor"); const tenant = require("./tenant"); const constants = require("../constants"); const { userDn, groupDn } = require("./dn"); // Iterative (non-recursive) walk of the filter tree to measure nesting depth. // and/or/not expose children via .clauses (with .filters as an alias). const filterDepth = (root) => { let max = 0; const stack = [[root, 1]]; while (stack.length > 0) { const item = stack.pop(); const node = item[0]; const depth = item[1]; if (depth > max) { max = depth; } const kids = (node && (node.clauses || node.filters)) || []; for (const kid of kids) { stack.push([kid, depth + 1]); } } return max; }; const userEntry = (user, effective, tenantName) => { const name = (user._attributes && user._attributes.name) || user.email; // Lowercase attribute names match the case-insensitive request normalization // below (and ldapjs lowercases entry keys when filtering requested attrs). return { dn: userDn(user.email, tenantName), attributes: { objectclass: ["inetOrgPerson", "organizationalPerson", "person", "top"], uid: [user.email], cn: [name], sn: [name], mail: [user.email], displayname: [name], memberof: effective.map((g) => groupDn(g, tenantName)) } }; }; const handler = async (req, res, next) => { try { if (req.connection.ldap.bindDN.equals("cn=anonymous")) { return next(new vendor.InsufficientAccessRightsError()); } // DoS guard: reject pathologically nested filters before any DB work. if (req.filter && filterDepth(req.filter) > constants.LDAP_MAX_FILTER_DEPTH) { return next(new vendor.OperationsError()); } // Case-insensitive attribute selection: SearchResponse.send() compares the // lowercased entry key against the REQUESTED list (res.attributes, copied // at response construction BEFORE this handler) WITHOUT lowercasing it, so // a mixed-case request (memberOf) drops our lowercase keys. Lowercase the // response's attribute list in place (mutating req.attributes is too late). if (Array.isArray(res.attributes)) { for (let i = 0; i < res.attributes.length; i++) { res.attributes[i] = String(res.attributes[i]).toLowerCase(); } } // Tenant: the search base names a tenant; the bound connection is in a // tenant; they must match (no cross-tenant reads). const baseT = tenant.resolveTenant(tenant.tenantFromDn(req.dn)); if (baseT.deny) { return next(new vendor.InsufficientAccessRightsError()); } const boundT = tenant.resolveTenant(tenant.tenantFromDn(req.connection.ldap.bindDN)); if ((boundT.tenant || null) !== (baseT.tenant || null)) { return next(new vendor.InsufficientAccessRightsError()); } let truncated = false; await tenant.withTenant(baseT.tenant, async () => { // Fast path: a simple equality on uid/mail (the common search-then-bind // "find me this user" query) is answered with a targeted DB lookup -- // no full-directory load, correct even for directories larger than the // cap. A presence filter like (uid=*) is NOT equality (no .value) and // falls through to the capped scan. const f = req.filter; const attr = f && f.attribute ? String(f.attribute).toLowerCase() : null; // A leaf equality on uid/mail (same structural test as the existing // single-equality fast path). Reused to classify OR children below. const isUidMailEquality = (node) => { const a = node && node.attribute ? String(node.attribute).toLowerCase() : null; return !!node && (a === "uid" || a === "mail") && node.value !== undefined && node.value !== null && !node.filters && !node.clauses; }; // OR children expose siblings via .filters (ldapjs) or .clauses (alias). const orChildren = (node) => { if (!node) { return null; } const t = String(node.type || "").toLowerCase(); if (t !== "or" && t !== "orfilter") { return null; } const kids = node.filters || node.clauses; return Array.isArray(kids) ? kids : null; }; let users; if (f && (attr === "uid" || attr === "mail") && f.value !== undefined && f.value !== null && !f.filters && !f.clauses) { const one = await User.findOne({ email: String(f.value) }); users = one ? [one] : []; } else if (orChildren(f) && orChildren(f).length > 0 && orChildren(f).every(isUidMailEquality)) { // Fast path extension: an OR of uid/mail equalities (e.g. // (|(uid=a)(mail=b))) resolves each child via findOne and unions // the hits, deduped by email. Still targeted (no full scan). // Full LDAP-filter-to-SQL translation remains out of scope. const byEmail = new Map(); for (const child of orChildren(f)) { const one = await User.findOne({ email: String(child.value) }); if (one && !byEmail.has(one.email)) { byEmail.set(one.email, one); } } users = Array.from(byEmail.values()); } else { // Broad/complex filter: load up to the cap and filter in memory. // Past the cap we report sizeLimitExceeded rather than letting a // broad filter against a huge directory exhaust the heap. const max = constants.LDAP_MAX_SEARCH_RESULTS; users = await User.find({}, { limit: max + 1 }); if (users.length > max) { users.length = max; truncated = true; } } const groupMap = {}; for (const user of users) { const effective = await groups.effectiveGroups(user); const entry = userEntry(user, effective, baseT.tenant); if (req.filter.matches(entry.attributes)) { res.send(entry); } for (const g of effective) { if (!groupMap[g]) { groupMap[g] = []; } groupMap[g].push(userDn(user.email, baseT.tenant)); } } // Emit groupOfNames entries (one per non-empty effective group), // built from the SAME membership source as memberOf above. for (const gname of Object.keys(groupMap)) { const gentry = { dn: groupDn(gname, baseT.tenant), attributes: { objectclass: ["groupOfNames", "top"], cn: [gname], member: groupMap[gname] } }; if (req.filter.matches(gentry.attributes)) { res.send(gentry); } } }); if (truncated) { return next(new vendor.SizeLimitExceededError()); } res.end(); return next(); } catch (e) { // eslint-disable-next-line no-console console.error("[saltcorn-idp] ldap search error:", e); return next(new vendor.OperationsError()); } }; module.exports = { handler };