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

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/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, 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.

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.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); 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_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); 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 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):

  • 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 returns InvalidCredentialsError and records a failure for lockout (lib/ldap/bind.js, bind.js).
  • Search: an anonymous-bound connection (cn=anonymous) is denied with InsufficientAccessRightsError before 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_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).
  • 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.