94 lines
4.8 KiB
Markdown
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.
|