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

4.8 KiB

Dependency ownership and security posture

This plugin embeds an LDAP server and a SAML stack. This document records how we "own" those dependencies and where the security guards live, so the analysis is not re-done every time someone reads a stale "TODO: vendor a fork" comment.

ldapjs: pinned dependency, NOT a source copy

We adopt ldapjs 3.0.7 (MIT) as a PINNED dependency rather than copying its source into the tree. A full copy would pull the ldapjs package plus 8 @ldapjs/* sub-packages (asn1, attribute, change, controls, dn, filter, messages, protocol) -- ~184 library files of ASN.1/BER protocol code we did not write and cannot meaningfully re-audit on every upstream change. Copying it buys nothing, because our security guards do not live inside the BER parser; they live at the connection/handler layer, which is OUR code.

Ownership is enforced through a single chokepoint:

  • lib/ldap/vendor.js is the ONLY file that require("ldapjs"). It re-exports the error classes the handlers use and exposes createHardenedServer(), which installs a per-connection inbound byte cap (LDAP_MAX_MSG_BYTES, default 256 KiB) via the ldapjs connectionRouter hook -- the BER parser itself has no message-size limit, so a declared multi-GB message would otherwise buffer unbounded. This is the upgrade/audit point for the dependency.
  • lib/ldap/search.js walks the parsed search filter and rejects nesting deeper than LDAP_MAX_FILTER_DEPTH (default 32) before any DB work -- the filter parser recurses without a depth bound.
  • lib/ldap/harden.js does per-IP failed-bind lockout (the LDAP port is outside Saltcorn's web-login throttling).
  • The listener is loopback-bound (127.0.0.1) and LDAPS-only, and binds only in the cluster primary process (lib/ldap/server.js).

To verify the single-chokepoint invariant:

grep -rn "require(['\"]ldapjs" lib    # must match ONLY lib/ldap/vendor.js

Upgrading ldapjs / @ldapjs

  1. Bump the pinned ldapjs version in package.json and npm install.
  2. Re-check that connectionRouter/newConnection still exist with the same contract (server uses options.connectionRouter in place of newConnection; the router must call server.newConnection(conn)).
  3. Re-check the filter node shape used by the depth walk (.clauses, with .filters as an alias on and/or/not).
  4. Run node test/ldapGate.js (16 checks incl. the byte-cap and filter-depth guards) against a running MAIN instance.

SAML XML stack: pinned + hardened config, NOT forked

The SAML XML stack is samlify 2.13.1 + @xmldom/xmldom 0.8.13 + xml-crypto 6.1.2 + @authenio/samlify-node-xmllint 2.0.0 (node-xmllint WASM libxml2). We pin it and assert the safe defaults from our own code rather than copying/forking it.

XXE posture (verified in source, contradicting any "TODO" comment):

  • samlify ships an XXE-safe DOMParser by default: it builds the parser with throwing error/fatalError handlers (samlify api.js). We RE-ASSERT this from our code by calling saml.setDOMParserOptions({}) next to saml.setSchemaValidator(...) in lib/saml/idp.js, so a future samlify default change cannot silently re-enable entity expansion.
  • @xmldom/xmldom 0.8.x seeds the entity map only with the 5 predefined XML entities and never resolves external/SYSTEM entities -- no classic external-entity XXE surface.
  • node-xmllint validates against the 4 BUNDLED SAML XSDs only; no external schema fetch.
  • Defence in depth: lib/saml/routes.js decodeRequest() rejects any inbound SAML message containing a <!DOCTYPE or <!ENTITY declaration (HTTP 400) before it reaches the parser.

Signature posture:

  • lib/saml/idp.js asserts at load that xml-crypto >= XML_CRYPTO_MIN (6.1.2), failing closed otherwise (the 2025 SAML-signature CVEs are patched there).
  • Inbound AuthnRequests are signature-verified for SPs registered with a signing cert + want_authn_requests_signed (per-SP, so conforming-but-unsigned SPs are not locked out). samlify's signature-wrapping (XSW) defenses run on that path.

Pin / CVE log

  • xml-crypto >= 6.1.2 (npm overrides): floor for the 2025 SAML signature verification CVEs. A load-time assertion in lib/saml/idp.js enforces the same floor at runtime.
  • @xmldom/xmldom >=0.8.13 <0.9 (npm overrides): the XXE-relevant parser; the range avoids a hard pin breaking transitive resolution while keeping the no-external-entity 0.8.x behavior.
  • ldapjs 3.0.7, samlify 2.13.1, @authenio/samlify-node-xmllint 2.0.0: pinned exact (dropped the caret) so the security-relevant chain cannot float.

Network exposure

The LDAP listener binds 127.0.0.1 only and is LDAPS-only (no plaintext, no StartTLS). OIDC/SAML are served over the host's HTTP(S) stack. The plugin makes no outbound network calls during request handling.