# 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 `= 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.