354 lines
14 KiB
JavaScript
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);
|
|
});
|