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).
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/User.findOne({ email })lookup -- correct even for a directory larger than the result cap. Broader or complex filters load users into memory and filter withreq.filter.matches(...), capped atLDAP_MAX_SEARCH_RESULTSentries; exceeding the cap returnssizeLimitExceededrather 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 insiderunWithTenant(<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.