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

94 lines
4.8 KiB
Markdown

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