sc-idp/test/ldapGate.js
2026-06-01 16:40:54 -05:00

354 lines
14 KiB
JavaScript

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