// Phase 4 gate: drive the LDAPS server (MAIN, port 1636) as an LDAP client. // Verifies simple bind against the bcrypt hash, user search (mail + memberOf, // the same role-as-group + custom-group model as OIDC), wrong-password and // anonymous-search rejection, plus the hardening additions: case-insensitive // attribute selection, groupOfNames entries, filter-nesting-depth rejection, // the per-connection inbound byte cap (DoS), and the configurable service // account (search-then-bind). Uses the ldapjs client + a little HTTP for the // service-account config. Run: node idp/test/ldapGate.js (MAIN must be up). const ldap = require("ldapjs"); const tls = require("tls"); const http = require("http"); const HOST = "127.0.0.1"; const LDAP_PORT = 1636; const URL = "ldaps://127.0.0.1:1636"; const BASE = "dc=saltcorn,dc=local"; const PEOPLE = "ou=people," + BASE; const ADMIN_DN = "uid=admin@local," + PEOPLE; const ADMIN_PW = "AdminP@ss1"; const MAIN_PORT = 3000; const SVC_DN = "cn=svc,ou=people," + BASE; const SVC_PW = "svcSecret123!"; const jar = {}; let pass = 0; let fail = 0; const ok = (cond, msg) => { if (cond) { pass++; console.log(" PASS " + msg); } else { fail++; console.log(" FAIL " + msg); } }; const newClient = () => { return ldap.createClient({ url: URL, tlsOptions: { rejectUnauthorized: false }, connectTimeout: 5000, timeout: 8000 }); }; const doBind = (client, dn, pw) => { return new Promise((resolve) => { client.bind(dn, pw, (err) => resolve(err)); }); }; const doSearch = (client, base, opts) => { return new Promise((resolve, reject) => { client.search(base, opts, (err, res) => { if (err) { reject(err); return; } const entries = []; res.on("searchEntry", (e) => entries.push(e.pojo)); res.on("error", (e) => reject(e)); res.on("end", (r) => resolve({ status: r ? r.status : -1, entries: entries })); }); }); }; const attrMap = (entry) => { const out = {}; if (entry && entry.attributes) { for (const a of entry.attributes) { out[a.type.toLowerCase()] = a.values; } } return out; }; // --- minimal HTTP (to configure the LDAP service account via the admin UI) --- const storeCookies = (headers) => { const sc = headers["set-cookie"]; if (!sc) { return; } for (const line of sc) { const pair = line.split(";")[0]; const eq = pair.indexOf("="); if (eq > 0) { jar[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim(); } } }; const httpReq = (method, path, opts) => { const options = opts || {}; return new Promise((resolve, reject) => { const headers = {}; if (Object.keys(jar).length > 0) { headers["Cookie"] = Object.keys(jar).map((k) => k + "=" + jar[k]).join("; "); } let data = null; if (options.body) { data = new URLSearchParams(options.body).toString(); headers["Content-Type"] = "application/x-www-form-urlencoded"; headers["Content-Length"] = Buffer.byteLength(data); } const r = http.request({ host: "localhost", port: MAIN_PORT, method: method, path: path, headers: headers }, (resp) => { storeCookies(resp.headers); let body = ""; resp.on("data", (c) => { body += c; }); resp.on("end", () => resolve({ status: resp.statusCode, body: body })); }); r.on("error", reject); if (data !== null) { r.write(data); } r.end(); }); }; const configureServiceAccount = async () => { try { const lp = await httpReq("GET", "/auth/login"); const csrf = (lp.body.match(/name="_csrf" value="([^"]+)"/) || [])[1] || ""; await httpReq("POST", "/auth/login", { body: { email: "admin@local", password: ADMIN_PW, _csrf: csrf } }); const sp = await httpReq("GET", "/admin/idp/ldap"); const csrf2 = (sp.body.match(/name="_csrf" value="([^"]+)"/) || [])[1] || ""; const r = await httpReq("POST", "/admin/idp/ldap/service", { body: { dn: SVC_DN, password: SVC_PW, _csrf: csrf2 } }); return r.status === 302 || r.status === 303; } catch (e) { return false; } }; // Send a BER header declaring a huge length, then dribble bytes; the server's // per-connection byte cap must destroy the connection well before the declared // length is reached. const oversizedMessageClosed = () => { return new Promise((resolve) => { const sock = tls.connect({ host: HOST, port: LDAP_PORT, rejectUnauthorized: false }, () => { sock.write(Buffer.from([0x30, 0x84, 0x7f, 0xff, 0xff, 0xff])); const chunk = Buffer.alloc(64 * 1024, 0x00); let written = 0; const pump = () => { while (written < 400 * 1024) { written += chunk.length; if (!sock.write(chunk)) { sock.once("drain", pump); return; } } }; pump(); }); let done = false; const finish = (v) => { if (!done) { done = true; try { sock.destroy(); } catch (e) { /* ignore */ } resolve(v); } }; sock.on("close", () => finish(true)); sock.on("error", () => { /* expected when the server destroys us */ }); setTimeout(() => finish(false), 7000); }); }; const run = async () => { // 0. uidFromDn RFC 4514 unescaping (unit): a bind DN whose uid contains an // escaped special char (e.g. a comma, rendered \2c or \,) must decode back to // the literal email, or users with such emails could never bind. const { uidFromDn } = require("../lib/ldap/dn"); ok(uidFromDn("uid=admin@local,ou=people,dc=saltcorn,dc=local") === "admin@local", "uidFromDn plain email"); ok(uidFromDn("uid=alice\\2cbob@x.com,ou=people,dc=saltcorn,dc=local") === "alice,bob@x.com", "uidFromDn unescapes hex \\2c -> comma"); ok(uidFromDn("uid=a\\+b@x.com,ou=people,dc=saltcorn,dc=local") === "a+b@x.com", "uidFromDn unescapes \\+ -> plus"); // 1. correct bind let c = newClient(); let err = await doBind(c, ADMIN_DN, ADMIN_PW); ok(!err, "simple bind as admin@local succeeds" + (err ? " (" + err.name + ")" : "")); // 2. authenticated search for the user if (!err) { const r = await doSearch(c, PEOPLE, { scope: "sub", filter: "(uid=admin@local)", attributes: ["mail", "memberof", "cn"] }); ok(r.entries.length === 1, "search returns exactly one entry for admin@local"); const a = attrMap(r.entries[0]); ok(a.mail && a.mail[0] === "admin@local", "entry mail = " + (a.mail && a.mail[0])); ok(Array.isArray(a.memberof) && a.memberof.some((g) => g.indexOf("role:admin") >= 0), "memberOf includes role:admin (" + JSON.stringify(a.memberof) + ")"); const priv = a.userpassword || a.password || a.private_ciphertext; ok(!priv, "no password/secret attributes exposed"); } c.unbind(() => {}); // 3. wrong password rejected c = newClient(); err = await doBind(c, ADMIN_DN, "wrong-password"); ok(!!err && /InvalidCredentials/i.test(err.name || ""), "wrong password rejected (" + (err && err.name) + ")"); c.unbind(() => {}); // 4. anonymous bind is accepted (standard LDAP), but anonymous SEARCH is denied c = newClient(); err = await doBind(c, "", ""); let anonSearchErr = null; if (!err) { try { await doSearch(c, PEOPLE, { scope: "sub", filter: "(uid=admin@local)", attributes: ["mail"] }); } catch (e) { anonSearchErr = e; } } ok(!!anonSearchErr && /InsufficientAccessRights/i.test(anonSearchErr.name || ""), "anonymous search denied (" + (anonSearchErr && anonSearchErr.name) + ")"); c.unbind(() => {}); // 5. mixed-case attribute request still returns the attributes c = newClient(); err = await doBind(c, ADMIN_DN, ADMIN_PW); if (!err) { const r = await doSearch(c, PEOPLE, { scope: "sub", filter: "(uid=admin@local)", attributes: ["Mail", "MemberOf", "CN"] }); const a = attrMap(r.entries[0] || {}); ok(a.mail && a.mail[0] === "admin@local" && Array.isArray(a.memberof), "mixed-case attribute request returns mail + memberOf"); } c.unbind(() => {}); // 6. groupOfNames entries c = newClient(); err = await doBind(c, ADMIN_DN, ADMIN_PW); if (!err) { const r = await doSearch(c, BASE, { scope: "sub", filter: "(objectclass=groupOfNames)", attributes: ["cn", "member"] }); const groupsFound = r.entries.map(attrMap); const adminGroup = groupsFound.find((g) => Array.isArray(g.member) && g.member.some((m) => m.indexOf("uid=admin@local") >= 0)); ok(r.entries.length >= 1 && !!adminGroup, "groupOfNames entries returned with admin@local as member (" + r.entries.length + " groups)"); } c.unbind(() => {}); // 7. deeply-nested filter rejected (depth > LDAP_MAX_FILTER_DEPTH) c = newClient(); err = await doBind(c, ADMIN_DN, ADMIN_PW); let deepErr = null; if (!err) { const deepFilter = "(!".repeat(40) + "(uid=admin@local)" + ")".repeat(40); try { await doSearch(c, PEOPLE, { scope: "sub", filter: deepFilter, attributes: ["mail"] }); } catch (e) { deepErr = e; } } ok(!!deepErr, "deeply-nested (40) filter rejected (" + (deepErr && deepErr.name) + ")"); c.unbind(() => {}); // 8. oversized inbound message -> server destroys the connection, survives const closed = await oversizedMessageClosed(); ok(closed, "oversized inbound message: connection destroyed by byte cap"); c = newClient(); err = await doBind(c, ADMIN_DN, ADMIN_PW); ok(!err, "server survived the oversized message (fresh bind still works)"); c.unbind(() => {}); // 9. configurable service account (search-then-bind binder) const configured = await configureServiceAccount(); ok(configured, "configured LDAP service account via admin UI"); if (configured) { c = newClient(); err = await doBind(c, SVC_DN, SVC_PW); ok(!err, "service-account bind succeeds" + (err ? " (" + err.name + ")" : "")); if (!err) { const r = await doSearch(c, PEOPLE, { scope: "sub", filter: "(uid=admin@local)", attributes: ["mail"] }); ok(r.entries.length === 1, "service account can search (search-then-bind)"); } c.unbind(() => {}); c = newClient(); err = await doBind(c, SVC_DN, "wrong-service-pw"); ok(!!err && /InvalidCredentials/i.test(err.name || ""), "service-account wrong password rejected (" + (err && err.name) + ")"); c.unbind(() => {}); } // 10. per-message byte cap (NOT a connection-lifetime quota): a single // connection issuing many sequential sub-cap searches whose CUMULATIVE bytes // exceed LDAP_MAX_MSG_BYTES (256 KiB) must keep working. A cumulative counter // would destroy the connection partway through; the per-message reset must not. c = newClient(); err = await doBind(c, ADMIN_DN, ADMIN_PW); let allSucceeded = !err; if (!err) { // ~26 KiB flat-OR filter (depth 2, under the depth cap); 16 iterations // is ~416 KiB cumulative, comfortably past the 256 KiB single-message cap. const bigFilter = "(|" + "(uid=nobody@local)".repeat(1400) + ")"; for (let i = 0; i < 16 && allSucceeded; i++) { try { await doSearch(c, PEOPLE, { scope: "sub", filter: bigFilter, attributes: ["mail"] }); } catch (e) { allSucceeded = false; } } } ok(allSucceeded, "16 sequential sub-cap searches (>256 KiB cumulative) all succeed on one connection (per-message reset, not lifetime quota)"); c.unbind(() => {}); // 11. RFC 4514 DN escaping: a group whose NAME contains a DN special char // (comma) must not abort the search. Without escaping, groupDn() emits a // malformed "cn=group:ev,il,ou=groups,..." that ldapjs DN.fromString() rejects // when building the SearchEntry, failing the whole search (OperationsError). const grpCsrf = async () => ((await httpReq("GET", "/admin/idp/groups")).body.match(/name="_csrf" value="([^"]+)"/) || [])[1] || ""; let commaGid = null; try { await httpReq("POST", "/admin/idp/groups/create", { body: { name: "ev,il", _csrf: await grpCsrf() } }); const gp = await httpReq("GET", "/admin/idp/groups"); commaGid = (gp.body.match(/ev,il<\/code>[\s\S]*?name="id" value="(\d+)"/) || [])[1] || null; if (commaGid) { await httpReq("POST", "/admin/idp/groups/addmember", { body: { group_id: commaGid, email: "admin@local", _csrf: await grpCsrf() } }); } } catch (e) { /* leave commaGid null; assertion below will flag the setup */ } c = newClient(); err = await doBind(c, ADMIN_DN, ADMIN_PW); let commaSearchOk = false; let escapedDnSeen = false; if (!err) { try { const r = await doSearch(c, PEOPLE, { scope: "sub", filter: "(uid=admin@local)", attributes: ["memberof"] }); commaSearchOk = r.entries.length === 1; const a = attrMap(r.entries[0] || {}); escapedDnSeen = Array.isArray(a.memberof) && a.memberof.some((d) => d.indexOf("group:ev") >= 0 && d.indexOf("il") >= 0); } catch (e) { commaSearchOk = false; } } ok(!!commaGid && commaSearchOk, "search succeeds with a comma-containing group (DN value RFC 4514 escaped, no parse abort)"); ok(escapedDnSeen, "memberOf still carries the comma group's DN"); c.unbind(() => {}); if (commaGid) { await httpReq("POST", "/admin/idp/groups/delete", { body: { id: commaGid, _csrf: await grpCsrf() } }); } console.log("\nRESULT: " + pass + " passed, " + fail + " failed"); process.exit(fail ? 1 : 0); }; run().catch((e) => { console.error("LDAP GATE ERROR:", e); process.exit(2); });