# LDAP `saltcorn-idp` exposes Saltcorn's users and groups over an LDAPS directory service, so applications that only speak LDAP can authenticate against, and read group membership from, the same identity model that backs OIDC and SAML. This document covers enabling the listener, the bind interface, the DN scheme (including the multi-tenant tenant-in-DN convention), the bind and search operations, the denial-of-service guards, the cluster single-binder behaviour, multi-tenancy, and the vendoring chokepoint. For the other protocols this plugin implements see the sibling docs [`./oidc.md`](./oidc.md) and [`./saml.md`](./saml.md). For the cross-cutting security posture see [`./security.md`](./security.md), and for the rationale behind pinning (not forking) `ldapjs` see [`../VENDORING.md`](../VENDORING.md). The implementation lives under `lib/ldap/`: ``` lib/ldap/server.js LDAPS listener, cluster gate, bind retry/backoff lib/ldap/bind.js simple-bind handler (user + service account) lib/ldap/search.js search handler (inetOrgPerson / groupOfNames) lib/ldap/dn.js DN construction + RFC 4514 escaping lib/ldap/tenant.js tenant-from-DN resolution + withTenant wrap lib/ldap/serviceAccount.js sealed service-account credential storage lib/ldap/vendor.js single ldapjs require point + per-message byte cap lib/ldap/harden.js per-IP failed-bind lockout ``` ## Enabling LDAP LDAP is off unless the port environment variable is set. The listener is started from the plugin's `onLoad` hook (`startLdap` in `lib/ldap/server.js`), and only when `SALTCORN_IDP_LDAP_PORT` holds a parseable integer. | Variable | Constant | Default | Purpose | |----------|----------|---------|---------| | `SALTCORN_IDP_LDAP_PORT` | `LDAP_PORT_ENV` | unset (disabled) | Port for the LDAPS listener. Unset or non-numeric means LDAP does not start. Each dev instance uses its own port so they do not collide on one. | | `SALTCORN_IDP_LDAP_HOST` | `LDAP_HOST_ENV` | `127.0.0.1` (`LDAP_DEFAULT_HOST`) | Interface the listener binds to. An empty or whitespace-only value falls back to the default. | The transport is LDAPS only. `ldapjs` has no StartTLS support, so TLS is required from the first byte; there is no plaintext LDAP on the wire. For development the server generates a self-signed RSA-2048 / SHA-256 certificate at startup (via `selfsigned`); a production deployment would supply its own certificate. ### Bind interface and network-exposure warning The default bind host is loopback (`127.0.0.1`), so LDAP is not reachable from the network unless an operator explicitly opts in (for example by setting `SALTCORN_IDP_LDAP_HOST=0.0.0.0`). On a successful listen the server logs: ``` [saltcorn-idp] LDAPS listening on ldaps://: base dc=saltcorn,dc=local (tenant = dc=,) ``` When the chosen host is not loopback (`server.js` `isLoopbackHost` checks for `127.0.0.1`, `::1`, and `localhost`), it additionally emits a warning so a network-exposed bind is never silent: ``` [saltcorn-idp] NOTE: LDAP is bound to (beyond loopback) -- it is reachable from the network; ensure this is intended and firewalled appropriately. ``` ## Directory tree and DN scheme The directory mirrors Saltcorn's users and groups under a fixed base. The DN helpers live in `lib/ldap/dn.js` and the base constants in `lib/constants.js`. | Constant | Value | |----------|-------| | `LDAP_BASE_DN` | `dc=saltcorn,dc=local` | | `LDAP_PEOPLE_OU` | `ou=people,dc=saltcorn,dc=local` | | `LDAP_GROUPS_OU` | `ou=groups,dc=saltcorn,dc=local` | Users are keyed by email (the `uid`), groups by name (the `cn`): ``` uid=,ou=people,dc=saltcorn,dc=local (a user) cn=,ou=groups,dc=saltcorn,dc=local (a group) ``` ### Tenant-in-DN In a multi-tenant deployment the tenant is encoded as one extra `dc` component immediately before the base (`baseFor(tenant)` in `dn.js`): ``` uid=,ou=people,dc=,dc=saltcorn,dc=local cn=,ou=groups,dc=,dc=saltcorn,dc=local ``` A falsy tenant yields the bare default base, so single-tenant DNs are unchanged. See [Multi-tenancy](#multi-tenancy) below for how the tenant is resolved and how cross-tenant access is denied. ### RFC 4514 DN escaping A user email or group name is admin-controlled free text and may contain DN special characters. `escapeDnValue` in `dn.js` applies RFC 4514 escaping when building the result and `memberOf` DNs the server emits: - the characters `\ " , + ; < > =` are backslash-escaped anywhere in the value; - a leading space or `#`, and a trailing space, are positionally escaped; - a NUL byte becomes `\00`. This is escaping on output only. Inbound bind DNs are formatted by the client, not by us. Without it, an unescaped special character would produce a malformed DN, and `ldapjs`'s `DN.fromString()` would throw while `res.send()` builds the `SearchEntry`, aborting the whole search. ## Bind The simple-bind handler is `lib/ldap/bind.js`. It supports two principals: a Saltcorn user, and an optional service account. Both authenticate against the tenant resolved from the bind DN, with all lookups running inside `withTenant` (which wraps `db.runWithTenant` for a non-default tenant). ### User bind The email is extracted from the bind DN with `uidFromDn` (matches `uid=...`), the user is loaded with Saltcorn's `User.findOne({ email })`, and the typed password is checked with `user.checkPassword(creds)` -- Saltcorn's bcrypt verification, which never stores or echoes plaintext. The bind fails if the user does not exist, is `disabled`, or the password does not match. ### Service account (search-then-bind) A configured service DN plus a sealed password lets an application bind as a non-user principal, search for a user, then re-bind as that user to validate the password -- the standard enterprise search-then-bind flow -- without exposing a real user as the binder. When the bind DN matches the configured service DN (compared case-insensitively after stripping whitespace via `dnEquals`), the supplied credential is compared to the stored password with a constant-time comparison (`crypto.timingSafeEqual`, in `constantTimeEqual`), so no user lookup occurs and the match is not timing-leaky. The service credential is stored in the single-row, tenant-scoped `_idp_ldap_service` table (`TABLE_LDAP_SERVICE`). The password is sealed at rest with the same KEK pattern as client secrets (AES-256-GCM via `sealText` in `lib/crypto.js`) and is never returned to the wire or echoed in the admin UI. It is configured from the admin page at `/admin/idp/ldap`; see [`./configuration.md`](./configuration.md). ### Denials Every failure increments the per-IP counter (see [Rate limiting](#rate-limiting-and-lockout)); a success clears it. | Condition | Result | |-----------|--------| | Source IP is currently locked out | `InvalidCredentialsError` (no auth attempted) | | Anonymous bind: no DN, zero-length DN, or empty credentials | `InvalidCredentialsError` | | Oversized credentials (length > 1024, `MAX_CRED_LEN`) | `InvalidCredentialsError` | | Cross-tenant / unknown-tenant bind DN (`resolveTenant` returns `deny`) | `InvalidCredentialsError` | | Service-account password mismatch (constant-time) | `InvalidCredentialsError` | | User not found, disabled, or wrong password | `InvalidCredentialsError` | Anonymous bind is, in LDAP terms, accepted by the protocol but here treated as an authentication failure: a bind with no DN or empty password is denied outright, and the search handler separately refuses any anonymous-bound connection (see below). ## Search The search handler is `lib/ldap/search.js`. It requires an authenticated (non-anonymous) bind: a connection whose bind DN equals `cn=anonymous` is rejected with `InsufficientAccessRightsError` before any work. ### Entries returned Users are returned as `inetOrgPerson` entries and the effective groups as `groupOfNames` entries. Group membership reuses the same identity model as OIDC (`groups.effectiveGroups` = role-as-group plus custom groups), so all three protocols draw from one source of truth. A user entry (`userEntry`) carries: | Attribute | Value | |-----------|-------| | `objectclass` | `inetOrgPerson`, `organizationalPerson`, `person`, `top` | | `uid` | user email | | `cn` | user name (or email) | | `sn` | user name (or email) | | `mail` | user email | | `displayname` | user name (or email) | | `memberof` | array of the user's group DNs | A group entry carries `objectclass` `groupOfNames` plus `top`, the group `cn`, and a `member` array of the member user DNs. Group entries are built from the same membership data that fills `memberOf`, accumulated as the handler walks the users. Entry attribute keys are emitted lowercase. The handler also normalizes the requested attribute list to lowercase in place: `ldapjs`'s `SearchResponse.send()` compares the lowercased entry key against the requested list without lowercasing that list, so a mixed-case request such as `memberOf` would otherwise drop our lowercase `memberof` key. This makes attribute selection effectively case-insensitive. > Search strategy: a simple equality filter on `uid`/`mail` (the common > search-then-bind "find this user" query) is answered with a targeted > `User.findOne({ email })` lookup -- correct even for a directory larger than > the result cap. Broader or complex filters load users into memory and filter > with `req.filter.matches(...)`, capped at `LDAP_MAX_SEARCH_RESULTS` entries; > exceeding the cap returns `sizeLimitExceeded` rather than exhausting the heap. > A production build would translate arbitrary LDAP filters into targeted SQL. ## Denial-of-service guards | Guard | Constant | Value | Where | |-------|----------|-------|-------| | Inbound byte cap, reset per parsed message | `LDAP_MAX_MSG_BYTES` | 262144 (256 KiB) | `lib/ldap/vendor.js` | | Search-filter nesting depth | `LDAP_MAX_FILTER_DEPTH` | 32 | `lib/ldap/search.js` | | Search result cap (then `sizeLimitExceeded`) | `LDAP_MAX_SEARCH_RESULTS` | 2000 | `lib/ldap/search.js` | | Per-connection idle timeout (destroy on silence) | `LDAP_IDLE_TIMEOUT_MS` | 30000 (30 s) | `lib/ldap/vendor.js` | | Per-IP failed-bind lockout | `MAX_FAILS` / `WINDOW_MS` | 10 fails / 5 min | `lib/ldap/harden.js` | ### Per-connection-but-per-message byte cap The BER parser has no size limit, so a message that declares a multi-gigabyte length would buffer unboundedly. `createHardenedServer` in `vendor.js` wraps `ldapjs`'s `createServer` with a `connectionRouter` that counts inbound bytes and calls `conn.destroy()` once the running total exceeds `LDAP_MAX_MSG_BYTES`. The counter is reset on each parsed-message boundary (`conn.parser` emits `message`). The cap is therefore per message, not a connection-lifetime quota: the goal is to bound a single unbounded BER message, while still allowing a legitimate long-lived connection to perform many sequential operations that each fit the cap but would cumulatively exceed it. (The router must call `server.newConnection(conn)` or `ldapjs` wires up no parser.) ### Filter-depth cap `filterDepth` in `search.js` walks the parsed filter tree iteratively (a stack-based walk, not recursion, following `.clauses` / `.filters`) and returns the maximum nesting depth. If that exceeds `LDAP_MAX_FILTER_DEPTH` (32), the search is rejected with `OperationsError` before any database work, guarding the recursive filter parser against pathologically nested input. ### Rate limiting and lockout The LDAP port is outside Saltcorn's web-login throttling, so `harden.js` maintains an in-memory per-IP failure counter. After `MAX_FAILS` (10) failures within a `WINDOW_MS` (5 minute) window, `isLocked(ip)` returns true and binds from that IP are denied without an authentication attempt. A successful bind clears the counter (`recordSuccess`); the window resets on expiry or success. The map is in-memory and per-process: it is not replicated across a cluster and is cleared on restart. ## Cluster single-binder, retry, and the loud UNAVAILABLE warning Only the cluster primary binds the listener. `startLdap` returns silently in forked workers (`isPrimary` check), so the workers do not race the port and spew `EADDRINUSE`. A module-level `started` flag makes the start idempotent across the per-tenant `onLoad` calls, so the listener is bound once per process. The listener also sets `server.maxConnections = 256`. Binding goes through `listenWithRetry`. On a transient bind failure (`EADDRINUSE` or `EACCES` -- for example the prior process's socket lingering across a fast restart) it retries up to `LDAP_BIND_MAX_ATTEMPTS` (5) times with a linear backoff of `LDAP_BIND_RETRY_BASE_MS * attempt` (500 ms times the attempt number), logging each retry: ``` [saltcorn-idp] LDAP : not yet bindable (); retry /5 in ms ``` After the final attempt it fails loudly -- LDAP authentication being unavailable is an operational problem, not a benign condition -- and resets `started` so a later plugin reload can retry: ``` [saltcorn-idp] WARNING: LDAP enabled on : but could NOT bind after attempt(s) (): LDAP authentication is UNAVAILABLE ``` (`ldapjs`'s `Server.listen()` signals success only through the listen callback, not a `listening` event on the wrapper, and that callback is re-registered on each retry; a `settled` guard ensures the success and give-up effects each run exactly once.) ## Multi-tenancy The listener runs at process level in the default tenant context, so each bind and search must establish its own tenant context. The tenant is taken from the DN by `tenantFromDn` in `lib/ldap/tenant.js`, which captures the `dc=` component immediately preceding the `dc=saltcorn,dc=local` base (lowercased). `resolveTenant(token)` then maps that token to a decision: - a falsy token, or one equal to the default schema, resolves to the default context (no wrap); - in a single-tenant deployment, any explicit tenant token is denied -- a crafted multi-tenant DN cannot smuggle in a tenant; - in a multi-tenant deployment, the token is validated against the live tenant set (`getAllTenants()`); a known tenant runs inside `runWithTenant()`, and an unknown tenant is denied so a crafted DN cannot reach another tenant's schema. ### Cross-tenant deny On bind, a `deny` decision yields `InvalidCredentialsError`. On search, the handler resolves the tenant of the search base and the tenant of the bound connection separately and requires them to match; if the bound connection's tenant differs from the search base's tenant, the search is rejected with `InsufficientAccessRightsError`. This prevents reading another tenant's directory across a single bound connection. ## Vendoring chokepoint `lib/ldap/vendor.js` is the single place that `require()`s `ldapjs`. Centralizing the dependency there makes it the one upgrade, audit, and maintenance point, and keeps the bind and search handlers off a direct `ldapjs` import (they use the re-exported `InvalidCredentialsError`, `InsufficientAccessRightsError`, `OperationsError`, and `ProtocolError`). All hardening lives in this plugin's own code at layers it controls -- the per-message byte cap here, the per-IP lockout in `harden.js`, and the filter-depth guard in `search.js` -- so no `ldapjs` fork is needed. `ldapjs` is pinned (the caret dropped) rather than forked; see [`../VENDORING.md`](../VENDORING.md) and [`./security.md`](./security.md). ## Example: bind and search over loopback With `SALTCORN_IDP_LDAP_PORT=1636` set on a single-tenant instance (the default host is loopback), a user can bind and search with OpenLDAP's client tools. The listener is LDAPS only, so use the `ldaps://` scheme. The development certificate is self-signed, so a client will need to disable certificate verification (here via `LDAPTLS_REQCERT=never`). ```sh # Simple bind + subtree search for one user, requesting mail and memberOf. LDAPTLS_REQCERT=never ldapsearch -H ldaps://127.0.0.1:1636 \ -D "uid=admin@local,ou=people,dc=saltcorn,dc=local" \ -w 'AdminP@ss1' \ -b "ou=people,dc=saltcorn,dc=local" \ "(uid=admin@local)" mail memberOf cn ``` A matching entry comes back as an `inetOrgPerson` with `mail` set to the user's email and `memberOf` listing the user's group DNs (for example `cn=role:admin,ou=groups,dc=saltcorn,dc=local`). Listing groups instead: ```sh LDAPTLS_REQCERT=never ldapsearch -H ldaps://127.0.0.1:1636 \ -D "uid=admin@local,ou=people,dc=saltcorn,dc=local" \ -w 'AdminP@ss1' \ -b "ou=groups,dc=saltcorn,dc=local" \ "(objectclass=groupOfNames)" cn member ``` On a multi-tenant Postgres instance the tenant goes in the DN; for tenant `t1` the bind DN and base become `uid=admin@t1.local,ou=people,dc=t1,dc=saltcorn,dc=local` and `ou=people,dc=t1,dc=saltcorn,dc=local` respectively.