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

351 lines
16 KiB
Markdown

# 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://<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](#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 <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`](../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.