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.jsis the ONLY file thatrequire("ldapjs"). It re-exports the error classes the handlers use and exposescreateHardenedServer(), which installs a per-connection inbound byte cap (LDAP_MAX_MSG_BYTES, default 256 KiB) via the ldapjsconnectionRouterhook -- 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.jswalks the parsed search filter and rejects nesting deeper thanLDAP_MAX_FILTER_DEPTH(default 32) before any DB work -- the filter parser recurses without a depth bound.lib/ldap/harden.jsdoes 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
- Bump the pinned
ldapjsversion inpackage.jsonandnpm install. - Re-check that
connectionRouter/newConnectionstill exist with the same contract (server usesoptions.connectionRouterin place ofnewConnection; the router must callserver.newConnection(conn)). - Re-check the filter node shape used by the depth walk (
.clauses, with.filtersas an alias on and/or/not). - 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/fatalErrorhandlers (samlifyapi.js). We RE-ASSERT this from our code by callingsaml.setDOMParserOptions({})next tosaml.setSchemaValidator(...)inlib/saml/idp.js, so a future samlify default change cannot silently re-enable entity expansion. @xmldom/xmldom0.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-xmllintvalidates against the 4 BUNDLED SAML XSDs only; no external schema fetch.- Defence in depth:
lib/saml/routes.jsdecodeRequest()rejects any inbound SAML message containing a<!DOCTYPEor<!ENTITYdeclaration (HTTP 400) before it reaches the parser.
Signature posture:
lib/saml/idp.jsasserts at load thatxml-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(npmoverrides): floor for the 2025 SAML signature verification CVEs. A load-time assertion inlib/saml/idp.jsenforces the same floor at runtime.@xmldom/xmldom >=0.8.13 <0.9(npmoverrides): the XXE-relevant parser; the range avoids a hard pin breaking transitive resolution while keeping the no-external-entity 0.8.x behavior.ldapjs3.0.7,samlify2.13.1,@authenio/samlify-node-xmllint2.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.