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

396 lines
20 KiB
Markdown

# Security Posture
This document describes the threat model and security posture of the
`saltcorn-idp` plugin: how secrets are sealed at rest, how CSRF is handled, the
asymmetric-signing model, the XML External Entity (XXE) posture, dependency
pinning, and the concrete hardening already in the code (each tied to a source
file and line). It also summarizes the adversarial review the protocol surfaces
went through.
For the XML dependency analysis in full, see [`../VENDORING.md`](../VENDORING.md).
Related docs: [`./architecture.md`](./architecture.md),
[`./configuration.md`](./configuration.md), [`./oidc.md`](./oidc.md),
[`./ldap.md`](./ldap.md).
## Threat model
The plugin turns Saltcorn into an SSO Identity Provider over three protocol
surfaces, each with a distinct trust boundary:
| Surface | Transport | Authentication of caller | Trust assumption |
|---------|-----------|--------------------------|------------------|
| OIDC / OAuth2 | Host HTTP(S) stack | Browser session (interactive) + per-client credentials/PKCE | Relying parties are explicitly registered; redirect URIs are allow-listed |
| SAML 2.0 | Host HTTP(S) stack | Browser session; per-SP registry + optional AuthnRequest signature | Service providers are explicitly registered; ACS URLs are allow-listed |
| LDAP(S) | Dedicated LDAPS listener | LDAP simple bind (user bcrypt or service account) | Listener is loopback-bound by default; outside Saltcorn's web-login throttling |
Adversary capabilities assumed in scope:
- An unauthenticated network client able to reach the public `/idp/*` endpoints
and (if exposed) the LDAPS port.
- A registered-but-malicious relying party / service provider attempting to
steer assertions, redirects, or tokens beyond its allow-listed endpoints.
- A client sending malformed, oversized, or maliciously structured protocol
messages (XML bombs, deeply nested LDAP filters, multi-GB BER messages,
DN-injection payloads, XXE).
Out of scope: compromise of the host running Saltcorn, compromise of
`SALTCORN_SESSION_SECRET`, and the security of the upstream Saltcorn auth stack
itself. The plugin makes no outbound network calls during request handling
(see [`../VENDORING.md`](../VENDORING.md)).
## At-rest secret sealing (KEK)
All long-lived secrets the plugin persists -- OIDC signing private keys, the
SAML signing private key, confidential OIDC client secrets, and the LDAP
service-account password -- are sealed before they touch the database.
Implementation: [`lib/crypto.js`](../lib/crypto.js).
| Aspect | Value | Source |
|--------|-------|--------|
| Cipher | AES-256-GCM | `crypto.js` (`GCM_ALGORITHM`) |
| KEK derivation | HKDF-SHA256 from `SALTCORN_SESSION_SECRET` | `crypto.js` |
| KEK info string | `saltcorn-idp:at-rest:aes-gcm-key:v1` | `crypto.js` |
| KEK salt | `saltcorn-idp:at-rest:aes-gcm-salt:v1` | `crypto.js` |
| IV | 12 random bytes per seal | `crypto.js`, `crypto.js` |
| KEK length | 32 bytes (256-bit) | `crypto.js` |
| GCM auth tag | stored alongside ciphertext + IV | `crypto.js`, `crypto.js` |
The info string is domain-separated so the IdP's KEK differs from any sibling
plugin's even though both derive from the same session secret
(`crypto.js`). Ciphertext, IV, and tag are stored as hex strings via
`sealText`/`openText` because Saltcorn's SQLite layer JSON-stringifies values
and cannot hold raw Buffers (`crypto.js`, `crypto.js`).
The KEK source resolves in this order (`crypto.js`): the
`SALTCORN_SESSION_SECRET` environment variable, then Saltcorn's `session_secret`
config, otherwise a hard error (`SALTCORN_SESSION_SECRET not available; cannot
derive KEK`). The KEK is cached per process and keyed by a SHA-256 hash of the
secret, so a rotated secret never keeps serving a stale KEK -- if the secret
changes the KEK is re-derived (`crypto.js`).
### Secret-rotation behavior
Rotating `SALTCORN_SESSION_SECRET` changes the KEK, which means all previously
sealed data (signing keys, SAML key, client secrets, LDAP service password) can
no longer be decrypted. This is documented, intentional behavior
(`crypto.js`). Plan rotation accordingly: rotating the session secret
requires re-bootstrapping the IdP's sealed material.
## CSRF model
| Namespace | CSRF | Mechanism |
|-----------|------|-----------|
| `/idp/*` (OIDC, JWKS, SAML, machine endpoints) | Exempt | `noCsrf: true` on each route; `/idp/` added to Saltcorn's `disable_csrf_routes` config at load |
| `/admin/idp/*` (browser admin UI) | Protected | Saltcorn's standard CSRF guard; every admin POST form embeds a `_csrf` token |
The `/idp/` namespace holds only machine/OIDC/SAML endpoints that are never
driven by Saltcorn browser forms: oidc-provider manages its own CSRF/state, and
SAML messages are XML payloads validated server-side against the registered-SP
allow-list rather than via form tokens. The bypass is registered once at startup
by `ensureCsrfBypass()`, which appends `IDP_BASE_PATH + "/"` to the global
(root-state) `disable_csrf_routes` config if not already present
([`index.js`](../index.js)). Each SAML route is additionally declared with
`noCsrf: true` ([`lib/saml/routes.js`](../lib/saml/routes.js)).
Admin pages under `/admin/idp` stay CSRF-protected because they are
browser-mediated state changes (client registration, SP registration, group and
LDAP service-account management).
## Asymmetric signing (RS256 / JWKS) vs Saltcorn HS256
Saltcorn's own session/JWT handling uses a symmetric secret (HS256-style).
Federated SSO tokens cannot be verified by relying parties that hold only a
shared secret, so the IdP issues asymmetrically signed tokens whose public half
is published for verification.
| Use | Algorithm | Key material |
|-----|-----------|--------------|
| OIDC `id_token` / JWT signing | RS256 (RSA-2048) | Per-tenant keypair in `_idp_keys`; private half sealed at rest, public half published at `/idp/jwks` |
| SAML assertion signing | RS256 (RSA-2048) | Per-tenant self-signed cert in `_idp_saml`; private key sealed at rest |
| oidc-provider session/auth cookies | Derived from `SALTCORN_SESSION_SECRET` | Deterministic HKDF (`deriveSecretHex`) so cookie keys survive restarts |
Constants: `SIGNING_ALG = "RS256"` and `RSA_MODULUS_BITS = 2048`
([`lib/constants.js`](../lib/constants.js)). Public keys are exported as
JWKs with `kid`, `alg`, and `use: "sig"`, and the exporter rejects anything that
is not a complete RSA public key ([`lib/crypto.js`](../lib/crypto.js)).
The cookie-signing keys use a distinct HKDF info string and are not the same key
as the at-rest KEK ([`lib/crypto.js`](../lib/crypto.js)).
The asymmetric model means a relying party only ever holds the public JWK: a
leaked verification key cannot mint tokens, in contrast to a shared-secret
HS256 deployment.
## XXE posture
Inbound SAML XML is defended in depth; see [`../VENDORING.md`](../VENDORING.md)
(section "SAML XML stack") for the full dependency analysis. Summary:
- samlify ships an XXE-safe DOMParser by default (throwing `error`/`fatalError`
handlers). The plugin re-asserts this from its own code via
`saml.setDOMParserOptions({})` so a future samlify default change cannot
silently re-enable entity expansion (`lib/saml/idp.js`; VENDORING.md).
- `@xmldom/xmldom` 0.8.x seeds only the 5 predefined XML entities and never
resolves external/SYSTEM entities (VENDORING.md).
- `node-xmllint` validates only against the 4 bundled SAML XSDs; no external
schema fetch (VENDORING.md).
- Defence in depth at the edge: `decodeRequest()` rejects any inbound SAML
message containing a `<!DOCTYPE` or `<!ENTITY` declaration before it reaches
the parser, returning HTTP 400. The screen is the regex
`DTD_RE = /<!DOCTYPE|<!ENTITY/i`
([`lib/saml/routes.js`](../lib/saml/routes.js), enforced at
`lib/saml/routes.js`).
## Dependency pinning and overrides
The security-relevant dependency chain is pinned exact (the caret was dropped)
and the XML parser/signature libraries are floored via npm `overrides`. Details
and the CVE log are in [`../VENDORING.md`](../VENDORING.md).
| Dependency | Constraint | Reason |
|------------|-----------|--------|
| `ldapjs` | `3.0.7` (exact) | BER/ASN.1 parser; guards live at the handler layer, not in the parser |
| `samlify` | `2.13.1` (exact) | SAML 2.0 processing; XXE-safe defaults re-asserted |
| `@authenio/samlify-node-xmllint` | `2.0.0` (exact) | SAML XSD validation (bundled libxml2) |
| `@xmldom/xmldom` | `>=0.8.13 <0.9` (npm `overrides`) | Keeps the no-external-entity 0.8.x behavior without a hard pin breaking transitive resolution |
| `xml-crypto` | `>=6.1.2` (npm `overrides` + runtime assertion) | Floor for the 2025 SAML signature-verification CVEs |
The `xml-crypto` floor (`XML_CRYPTO_MIN = "6.1.2"`,
[`lib/constants.js`](../lib/constants.js)) is additionally asserted at module
load in `lib/saml/idp.js`, failing closed if the resolved version is below the
floor (VENDORING.md).
The `ldapjs` dependency is governed by a single-chokepoint invariant: only
[`lib/ldap/vendor.js`](../lib/ldap/vendor.js) is allowed to `require("ldapjs")`.
Verify with:
```
grep -rn "require(['\"]ldapjs" lib # must match ONLY lib/ldap/vendor.js
```
## Hardening in code
Each item below is enforced in the plugin's own code (the layer it controls),
not delegated to a dependency.
### SAML decompression-bomb caps
DEFLATE can expand roughly 1000:1, so an unbounded inflate is a memory bomb that
would crash the worker. The SAML decode path bounds work before any allocation
([`lib/saml/routes.js`](../lib/saml/routes.js)):
| Guard | Constant | Value | Enforced |
|-------|----------|-------|----------|
| Base64 size cap (pre-decode) | `SAML_MAX_MSG_B64_BYTES` | 64 KiB | `routes.js` |
| Inflated XML size cap (redirect binding) | `SAML_MAX_XML_BYTES` | 256 KiB | `routes.js` (`inflateRawSync(..., { maxOutputLength })`) |
| Decoded size cap (POST binding) | `SAML_MAX_XML_BYTES` | 256 KiB | `routes.js` |
Constant values: [`lib/constants.js`](../lib/constants.js). These handlers
are fully unauthenticated (no CSRF, no API-token gate), so the bound is applied
before the work, not after (`routes.js`).
### SLO hardening: authenticated, subject-bound, no-request-ACS open-redirect fix
Ending a session via a GET is state-changing, and the handler signs a
LogoutResponse it then POSTs somewhere -- so SLO is hardened against forged /
cross-site LogoutRequests on several axes (`lib/saml/routes.js` `sloHandler`):
- **Authenticated session required.** If `req.user` is absent, return HTTP 403
-- an unauthenticated request (or a forged cross-site GET) has no session to
end, so it cannot drive SLO.
- **Subject must match the session.** The LogoutRequest `NameID` must equal the
session user's email (case-insensitive); a mismatch returns HTTP 403, so one
SP cannot log out a *different* user.
- **Signature required for cert-registered SPs.** An SP that registered a signing
cert must sign its LogoutRequest (`wantLogoutRequestSigned`), verified like the
AuthnRequest path. Residual: an SP with no registered cert cannot be verified,
so a cross-site request can at most force the current user's OWN logout
(bounded by the two checks above); we do not reject unsigned SLO outright
because some SPs legitimately do not sign LogoutRequests.
- **Registered-ACS-only destination.** Reject if the SP has no registered ACS
(HTTP 403), mirroring the SSO empty-list rejection; deliver only to the SP's
first registered ACS. The `matchAcs(xml)` request value is deliberately not
used as the destination -- taking the destination from the request would let a
crafted LogoutRequest steer the signed response (and any RelayState) to an
attacker-chosen endpoint (open redirect + leak).
For contrast, the SSO handler does allow a request-named ACS, but only after
validating it against the allow-list with `acsAllowed()`
([`lib/saml/sps.js`](../lib/saml/sps.js)); an unrecognized ACS is rejected
with HTTP 403 ([`lib/saml/routes.js`](../lib/saml/routes.js)). Allow-list
matching is exact-string (no trailing-slash or scheme fuzzing,
`lib/saml/sps.js`). Login Responses are always delivered to the validated
ACS, never to `response.entityEndpoint` (`lib/saml/routes.js`).
Two related SAML guards on the same path:
- Parser-differential guard: the issuer parsed authoritatively by samlify must
equal the entityID the SP was looked up by, else HTTP 403
([`lib/saml/routes.js`](../lib/saml/routes.js)).
- Per-SP AuthnRequest signature verification: SPs registered with
`want_authn_requests_signed` use the signature-verifying IdP variant and have
the request bound to the exact signed octet string for the redirect binding
([`lib/saml/routes.js`](../lib/saml/routes.js)); conforming-but-unsigned
SPs are not locked out.
### LDAP per-message byte cap
The BER parser has no message-size limit, so a declared multi-GB message would
buffer unbounded. `createHardenedServer()` wraps ldapjs with a
`connectionRouter` that counts inbound bytes and destroys the connection if a
single message exceeds the cap
([`lib/ldap/vendor.js`](../lib/ldap/vendor.js)):
- Cap: `LDAP_MAX_MSG_BYTES = 256 * 1024` (256 KiB,
[`lib/constants.js`](../lib/constants.js)), enforced at `vendor.js`.
- The counter resets on each parsed-message boundary
(`conn.parser.on("message")`, `vendor.js`). It is deliberately per-message,
not a connection-lifetime quota: a per-connection counter would let an attacker
kill a legitimate long-lived connection after a few normal operations
(`vendor.js`).
### RFC 4514 DN escaping
User emails and group names are admin-controlled free text that may contain DN
special characters. Without escaping, the concatenated DN would be malformed and
ldapjs's `DN.fromString()` would throw while building the SearchEntry, aborting
the whole search. `escapeDnValue()` escapes on output
([`lib/ldap/dn.js`](../lib/ldap/dn.js)):
- Special-anywhere characters `\ " , + ; < > =` (`dn.js`).
- Positionally special leading `#`/space and trailing space (`dn.js`).
- NUL byte to `\00` (`dn.js`).
Applied to the result and `memberOf` DNs we emit via `userDn`/`groupDn`
(`dn.js`); inbound bind DNs are formatted by the client, not by us
(`dn.js`).
### Filter-depth cap
The LDAP filter parser recurses without a depth bound. The search handler walks
the parsed filter iteratively (stack-based, not recursive) and rejects nesting
deeper than the cap before any DB work
([`lib/ldap/search.js`](../lib/ldap/search.js), guard at `search.js`):
- Cap: `LDAP_MAX_FILTER_DEPTH = 32` ([`lib/constants.js`](../lib/constants.js)).
- Over-depth returns an `OperationsError` (`search.js`).
### Per-IP bind lockout
The LDAP port is outside Saltcorn's web-login throttling, so the plugin
rate-limits failed binds per source IP
([`lib/ldap/harden.js`](../lib/ldap/harden.js)):
| Parameter | Value | Source |
|-----------|-------|--------|
| Max failures per IP | `MAX_FAILS = 10` | `harden.js` |
| Window | `WINDOW_MS = 5 * 60 * 1000` (5 min) | `harden.js` |
`isLocked(ip)` returns true once the count reaches 10 within the 5-minute window;
the window resets on expiry or on a successful bind (`harden.js`). The bind
handler checks the lock first and records a failure on every denial (invalid DN,
oversized/empty credentials, cross-tenant, wrong password) and a success on a
good bind ([`lib/ldap/bind.js`](../lib/ldap/bind.js)). The store is an
in-memory `Map`, per process, cleared on restart (`harden.js`), and is not
replicated across a cluster.
### LDAP loopback-default bind and exposure warning
The LDAPS listener binds loopback by default and is LDAPS-only (no plaintext, no
StartTLS); it binds only in the cluster primary process.
| Setting | Default | Env var | Source |
|---------|---------|---------|--------|
| Bind host | `127.0.0.1` | `SALTCORN_IDP_LDAP_HOST` | [`lib/constants.js`](../lib/constants.js) |
| Listener enabled / port | (disabled unless set) | `SALTCORN_IDP_LDAP_PORT` | [`lib/constants.js`](../lib/constants.js) |
If the operator binds to a non-loopback host, the server logs an explicit
exposure warning at startup
([`lib/ldap/server.js`](../lib/ldap/server.js)):
```
[saltcorn-idp] NOTE: LDAP is bound to <host> (beyond loopback) -- it is
reachable from the network; ensure this is intended and firewalled appropriately.
```
Loopback is treated as `127.0.0.1`, `::1`, or `localhost`
([`lib/ldap/server.js`](../lib/ldap/server.js)). The single-listener,
primary-only binding is documented in [`../VENDORING.md`](../VENDORING.md).
### Anonymous-bind / anonymous-operation deny
- Bind: anonymous binds (no DN, or empty password) are denied, as are absurdly
long credentials (`MAX_CRED_LEN = 1024`). The denial returns
`InvalidCredentialsError` and records a failure for lockout
([`lib/ldap/bind.js`, `bind.js`](../lib/ldap/bind.js)).
- Search: an anonymous-bound connection (`cn=anonymous`) is denied with
`InsufficientAccessRightsError` before any work
([`lib/ldap/search.js`](../lib/ldap/search.js)).
- Passwords are verified against Saltcorn's stored bcrypt hash via
`user.checkPassword()` and never stored or echoed; disabled users are rejected
(`lib/ldap/bind.js`). The service-account password is compared with a
constant-time comparison (`crypto.timingSafeEqual`) to avoid a timing oracle
(`lib/ldap/bind.js`, `bind.js`).
### Cross-tenant deny
The tenant is derived from the bind DN base (`dc=<tenant>,...`) and all lookups
run inside `runWithTenant`. Cross-tenant access is denied on both operations:
- Bind: the tenant resolved from the bind DN is rejected if it resolves to a deny
(unknown tenant, or an explicit tenant in single-tenant mode)
([`lib/ldap/bind.js`](../lib/ldap/bind.js)).
- Search: the tenant of the search base must equal the tenant of the bound
connection; a mismatch (or a deny on the base) returns
`InsufficientAccessRightsError`
([`lib/ldap/search.js`](../lib/ldap/search.js)).
Tenant resolution and the DN-encoding scheme are described in
[`./ldap.md`](./ldap.md).
## Adversarial review
The protocol surfaces were built under a security-first directive: enumerate
abuse cases and write adversarial tests before coding each module. The
public-facing SAML and LDAP handlers went through two rounds of adversarial
review, which surfaced four HIGH-severity and one MEDIUM-severity issues; all
five were fixed, and the fixes are visible in the current code:
- SLO open redirect: the LogoutResponse destination could fall back to a
request-supplied ACS. Fixed by requiring a registered ACS and never using the
request-parsed URL ([`lib/saml/routes.js`](../lib/saml/routes.js)).
- SAML decompression bomb: unbounded inflate of a DEFLATE-compressed
SAMLRequest. Fixed with the base64 and inflated-XML caps
([`lib/saml/routes.js`](../lib/saml/routes.js)).
- LDAP BER message bomb: no parser size limit. Fixed with the per-message byte
cap ([`lib/ldap/vendor.js`](../lib/ldap/vendor.js)).
- LDAP filter bomb: unbounded recursive filter parsing. Fixed with the
filter-depth cap ([`lib/ldap/search.js`](../lib/ldap/search.js)).
- DN injection aborting searches (MEDIUM): unescaped admin-controlled values in
emitted DNs. Fixed with RFC 4514 escaping on output
([`lib/ldap/dn.js`](../lib/ldap/dn.js)).
Items dismissed during review were those judged not exploitable in the plugin's
trust model -- notably the decision NOT to vendor a `ldapjs` fork (the security
guards live in the plugin's owned connection/handler layer, not in the BER
parser, so a fork buys nothing) and NOT to fork the SAML XML stack (the XXE-safe
defaults are asserted from plugin code and floored via npm `overrides`). Both
decisions are recorded in [`../VENDORING.md`](../VENDORING.md).
## Dual control: safe defaults
The plugin defaults to the least-exposed posture, requiring an explicit operator
opt-in to widen it:
- LDAP is disabled unless `SALTCORN_IDP_LDAP_PORT` is set, and binds only to
loopback (`127.0.0.1`) unless `SALTCORN_IDP_LDAP_HOST` is changed; a
non-loopback bind logs an explicit network-exposure warning
([`lib/ldap/server.js`](../lib/ldap/server.js)).
- LDAP is LDAPS-only -- no plaintext and no StartTLS (VENDORING.md).
- SAML and OIDC only issue assertions/tokens to explicitly registered SPs/clients
with allow-listed ACS/redirect URIs.
- All persisted secrets are sealed at rest under a KEK derived from the session
secret.
For configuration knobs (env vars, multi-tenant issuer derivation, base_url
trust), see [`./configuration.md`](./configuration.md).