From b8ca595d34d0289bc7a9b23c76386d206a3e3364 Mon Sep 17 00:00:00 2001 From: Oleg Date: Wed, 5 Aug 2026 11:41:26 +0000 Subject: [PATCH] Shell out to ldapsearch for the directory browse instead of ldapjs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY --- Dockerfile | 6 ++- src/lib/ldap/client.ts | 106 +++++++++++++++++++++++++++++++++++------ 2 files changed, 96 insertions(+), 16 deletions(-) diff --git a/Dockerfile b/Dockerfile index 721c6f6..705f197 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,11 @@ FROM node:20-bookworm-slim # 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 \ - python3 make g++ ca-certificates \ + python3 make g++ ca-certificates ldap-utils \ && rm -rf /var/lib/apt/lists/* WORKDIR /app diff --git a/src/lib/ldap/client.ts b/src/lib/ldap/client.ts index a6d34e9..2280126 100644 --- a/src/lib/ldap/client.ts +++ b/src/lib/ldap/client.ts @@ -1,6 +1,10 @@ import ldap from "ldapjs"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import { getLdapSettings } from "@/lib/auth/ldap-config"; +const execFileAsync = promisify(execFile); + export interface LdapUserInfo { dn: string; email: string; @@ -100,6 +104,86 @@ function unbindQuietly(client: ldap.Client): void { 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 }[] { + const entries: { dn: string; attributes: Record }[] = []; + let current: { dn: string; attributes: Record } | 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:: ` 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 }[]> { + 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). */ function escapeFilterValue(value: string): string { return value.replace(/[\\*()\0]/g, (c) => `\\${c.charCodeAt(0).toString(16).padStart(2, "0")}`); @@ -158,18 +242,12 @@ export async function searchLdapDirectory(): Promise { const settings = await getLdapSettings(); if (!settings) return []; - const client = createLdapClient(settings, 30_000); - try { - await bind(client, settings.bindDn, settings.bindPassword); - const entries = await search(client, settings.baseDn, settings.listFilter); - return entries - .filter((e) => e.attributes.mail) - .map((e) => ({ - dn: e.dn, - email: e.attributes.mail, - name: e.attributes.cn || e.attributes.displayName || e.attributes.mail, - })); - } finally { - unbindQuietly(client); - } + const entries = await ldapSearchCli(settings, settings.baseDn, settings.listFilter); + return entries + .filter((e) => e.attributes.mail) + .map((e) => ({ + dn: e.dn, + email: e.attributes.mail, + name: e.attributes.cn || e.attributes.displayName || e.attributes.mail, + })); }