20 KiB
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.
Related docs: ./architecture.md,
./configuration.md, ./oidc.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).
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.
| 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). Each SAML route is additionally declared with
noCsrf: true (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). 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).
The cookie-signing keys use a distinct HKDF info string and are not the same key
as the at-rest KEK (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
(section "SAML XML stack") for the full dependency analysis. Summary:
- samlify ships an XXE-safe DOMParser by default (throwing
error/fatalErrorhandlers). The plugin re-asserts this from its own code viasaml.setDOMParserOptions({})so a future samlify default change cannot silently re-enable entity expansion (lib/saml/idp.js; VENDORING.md). @xmldom/xmldom0.8.x seeds only the 5 predefined XML entities and never resolves external/SYSTEM entities (VENDORING.md).node-xmllintvalidates 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<!DOCTYPEor<!ENTITYdeclaration before it reaches the parser, returning HTTP 400. The screen is the regexDTD_RE = /<!DOCTYPE|<!ENTITY/i(lib/saml/routes.js, enforced atlib/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.
| 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) 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 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):
| 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. 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.useris 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
NameIDmust 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); an unrecognized ACS is rejected
with HTTP 403 (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). - Per-SP AuthnRequest signature verification: SPs registered with
want_authn_requests_signeduse the signature-verifying IdP variant and have the request bound to the exact signed octet string for the redirect binding (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):
- Cap:
LDAP_MAX_MSG_BYTES = 256 * 1024(256 KiB,lib/constants.js), enforced atvendor.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):
- 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, guard at search.js):
- Cap:
LDAP_MAX_FILTER_DEPTH = 32(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):
| 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). 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 |
| Listener enabled / port | (disabled unless set) | SALTCORN_IDP_LDAP_PORT |
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):
[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). The single-listener,
primary-only binding is documented in ../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 returnsInvalidCredentialsErrorand records a failure for lockout (lib/ldap/bind.js,bind.js). - Search: an anonymous-bound connection (
cn=anonymous) is denied withInsufficientAccessRightsErrorbefore any work (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). - 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).
Tenant resolution and the DN-encoding scheme are described in
./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). - SAML decompression bomb: unbounded inflate of a DEFLATE-compressed
SAMLRequest. Fixed with the base64 and inflated-XML caps
(
lib/saml/routes.js). - LDAP BER message bomb: no parser size limit. Fixed with the per-message byte
cap (
lib/ldap/vendor.js). - LDAP filter bomb: unbounded recursive filter parsing. Fixed with the
filter-depth cap (
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).
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.
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_PORTis set, and binds only to loopback (127.0.0.1) unlessSALTCORN_IDP_LDAP_HOSTis changed; a non-loopback bind logs an explicit network-exposure warning (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.