sc-idp/docs/ldap.md
2026-06-01 16:40:54 -05:00

16 KiB

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 and ./saml.md. For the cross-cutting security posture see ./security.md, and for the rationale behind pinning (not forking) ldapjs see ../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://<host>:<port> base dc=saltcorn,dc=local (tenant = dc=<tenant>,<base>)

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 <host> (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=<email>,ou=people,dc=saltcorn,dc=local     (a user)
cn=<group>,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=<email>,ou=people,dc=<tenant>,dc=saltcorn,dc=local
cn=<group>,ou=groups,dc=<tenant>,dc=saltcorn,dc=local

A falsy tenant yields the bare default base, so single-tenant DNs are unchanged. See 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.

Denials

Every failure increments the per-IP counter (see Rate limiting); 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).

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 <host>:<port> not yet bindable (<code>); retry <n>/5 in <ms>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 <host>:<port> but could NOT bind after <n> attempt(s) (<code>): 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=<tenant> 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(<tenant>), 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 and ./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).

# 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:

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.