sc-idp/lib/ldap/search.js
2026-06-01 16:40:54 -05:00

192 lines
8.5 KiB
JavaScript

// 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
};