192 lines
8.5 KiB
JavaScript
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
|
|
};
|