351 lines
16 KiB
Markdown
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.
|