Shell out to ldapsearch for the directory browse instead of ldapjs

Bisecting through attribute selection and paging kept reproducing the
same failure in different shapes ("Encoding too long" BER parser error,
then ECONNRESET) — all while a plain `ldapsearch` against the exact same
query, run from inside this same container, completed successfully every
single time. That's strong enough evidence of a bug somewhere in
ldapjs/@ldapjs-asn1's BER decoding against this AD's actual response
bytes, not in our query. Rather than keep chasing a third-party parser
bug, searchLdapDirectory() now shells out to the system `ldapsearch`
(added to the image via ldap-utils) and parses its LDIF output directly
— the same tool that's already proven reliable here. authenticateLdapUser
(the per-login lookup) is untouched: it's a narrow single-match query
that has shown no sign of this issue, and isn't worth the added latency
of spawning a process on every login.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-08-05 11:41:26 +00:00
1 parent cb20d9eea5
commit b8ca595d34
2 files changed
+89 -9

No files matched your search

+4 -2
View File
@@ -1,9 +1,11 @@
FROM node:20-bookworm-slim FROM node:20-bookworm-slim
# python3/make/g++ let npm fall back to compiling better-sqlite3/argon2 from # python3/make/g++ let npm fall back to compiling better-sqlite3/argon2 from
# source if no prebuilt binary matches this platform. # source if no prebuilt binary matches this platform. ldap-utils provides
# the `ldapsearch` binary used for the LDAP directory browse — ldapjs's own
# BER decoder proved unreliable against real AD responses for that query.
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ ca-certificates \ python3 make g++ ca-certificates ldap-utils \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
+85 -7
View File
@@ -1,6 +1,10 @@
import ldap from "ldapjs"; import ldap from "ldapjs";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { getLdapSettings } from "@/lib/auth/ldap-config"; import { getLdapSettings } from "@/lib/auth/ldap-config";
const execFileAsync = promisify(execFile);
export interface LdapUserInfo { export interface LdapUserInfo {
dn: string; dn: string;
email: string; email: string;
@@ -100,6 +104,86 @@ function unbindQuietly(client: ldap.Client): void {
client.unbind(() => {}); client.unbind(() => {});
} }
/** Un-folds RFC 2849 line continuations (a line starting with a single space extends the previous line). */
function unfoldLdif(raw: string): string[] {
const lines: string[] = [];
for (const line of raw.split("\n")) {
if (line.startsWith(" ") && lines.length > 0) {
lines[lines.length - 1] += line.slice(1);
} else {
lines.push(line);
}
}
return lines;
}
function parseLdif(raw: string): { dn: string; attributes: Record<string, string> }[] {
const entries: { dn: string; attributes: Record<string, string> }[] = [];
let current: { dn: string; attributes: Record<string, string> } | null = null;
for (const line of unfoldLdif(raw)) {
if (line === "") {
if (current) entries.push(current);
current = null;
continue;
}
const sepIndex = line.indexOf(":");
if (sepIndex === -1) continue;
const attr = line.slice(0, sepIndex);
const rest = line.slice(sepIndex + 1);
// `attr:: <base64>` for values needing encoding (binary or non-ASCII, e.g. Cyrillic DNs); plain `attr: value` otherwise.
const value = rest.startsWith(":")
? Buffer.from(rest.slice(1).trim(), "base64").toString("utf8")
: rest.trim();
if (attr === "dn") {
current = { dn: value, attributes: {} };
} else if (current) {
current.attributes[attr] = value;
}
}
if (current) entries.push(current);
return entries;
}
/**
* Shells out to the system `ldapsearch` (ldap-utils) instead of ldapjs for
* the directory-wide browse. `ldapjs`'s BER decoder was reproducibly
* throwing "Encoding too long" against this AD's search response,
* independent of attribute selection or paging, while a plain `ldapsearch`
* against the exact same query — run from inside this same container —
* completed cleanly every time. Rather than keep guessing at a bug in a
* third-party BER parser, use the client that's actually proven reliable
* here for this one operation.
*/
async function ldapSearchCli(
settings: ConnectionSettings & { bindDn: string; bindPassword: string },
baseDn: string,
filter: string,
): Promise<{ dn: string; attributes: Record<string, string> }[]> {
const protocol = settings.useTls ? "ldaps" : "ldap";
const { stdout } = await execFileAsync(
"ldapsearch",
[
"-x",
"-LLL",
"-H",
`${protocol}://${settings.host}:${settings.port}`,
"-D",
settings.bindDn,
"-w",
settings.bindPassword,
"-b",
baseDn,
filter,
"mail",
"cn",
"displayName",
],
{ maxBuffer: 20 * 1024 * 1024 },
);
return parseLdif(stdout);
}
/** Escapes an untrusted value for safe interpolation into an LDAP search filter (RFC 4515). */ /** Escapes an untrusted value for safe interpolation into an LDAP search filter (RFC 4515). */
function escapeFilterValue(value: string): string { function escapeFilterValue(value: string): string {
return value.replace(/[\\*()\0]/g, (c) => `\\${c.charCodeAt(0).toString(16).padStart(2, "0")}`); return value.replace(/[\\*()\0]/g, (c) => `\\${c.charCodeAt(0).toString(16).padStart(2, "0")}`);
@@ -158,10 +242,7 @@ export async function searchLdapDirectory(): Promise<LdapUserInfo[]> {
const settings = await getLdapSettings(); const settings = await getLdapSettings();
if (!settings) return []; if (!settings) return [];
const client = createLdapClient(settings, 30_000); const entries = await ldapSearchCli(settings, settings.baseDn, settings.listFilter);
try {
await bind(client, settings.bindDn, settings.bindPassword);
const entries = await search(client, settings.baseDn, settings.listFilter);
return entries return entries
.filter((e) => e.attributes.mail) .filter((e) => e.attributes.mail)
.map((e) => ({ .map((e) => ({
@@ -169,7 +250,4 @@ export async function searchLdapDirectory(): Promise<LdapUserInfo[]> {
email: e.attributes.mail, email: e.attributes.mail,
name: e.attributes.cn || e.attributes.displayName || e.attributes.mail, name: e.attributes.cn || e.attributes.displayName || e.attributes.mail,
})); }));
} finally {
unbindQuietly(client);
}
} }