@blamejs/core 0.13.12 → 0.13.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.13.x
10
10
 
11
+ - v0.13.14 (2026-05-27) — **DNSSEC chain validation now bounds KeyTrap (CVE-2023-50387) amplification with hard caps.** b.network.dns.dnssec.verifyChain tried every DNSKEY whose 16-bit key tag matched an RRSIG, with no cap on how many candidates or total signature verifications a single response could drive. A hostile zone publishing many DNSKEYs sharing one key tag (plus matching RRSIGs) could force O(keys x signatures) full public-key verifications from one query — the KeyTrap denial-of-service (CVE-2023-50387). Validation is now bounded by non-configurable caps that match the BIND / Unbound mitigations: at most 4 same-tag candidate keys are tried per RRSIG, at most 64 DNSKEYs per zone link and 16 DS records per delegation are accepted, the chain is at most 128 links deep, and the whole response is held to a signature-validation budget that scales with chain depth (so a legitimate deep delegation is never false-rejected while bounded collisions stay bounded); exceeding any of these refuses the response rather than performing the work. Separately, a domain name that encodes to more than 255 octets is now refused at canonicalization (RFC 1035 §2.3.4), which also bounds the NSEC3 closest-encloser label enumeration, and the NSEC3 iteration ceiling is lowered from 500 to 150 to match the BIND 9.16.33+ / Unbound 1.17.1 fix for the sibling CVE-2023-50868. **Security:** *`verifyChain` caps colliding-key fan-out and total signature validations (KeyTrap / CVE-2023-50387)* — A zone advertising many same-key-tag DNSKEYs and RRSIGs can no longer drive unbounded public-key verifications. New refusals: `dnssec/too-many-colliding-keys` (>4 same-tag candidates per RRSIG), `dnssec/too-many-dnskeys` (>64 DNSKEYs per zone link), `dnssec/too-many-ds` (>16 DS records per delegation), `dnssec/too-many-links` (chain deeper than 128), and `dnssec/validation-budget-exceeded` (signature validations beyond the depth-scaled budget). The caps are intentionally non-configurable — they sit well above any legitimate zone, and the budget scales with chain depth so deep delegations validate normally. · *Domain-name octet cap + lower NSEC3 iteration ceiling* — A name that canonicalizes to more than 255 octets is refused (`dnssec/bad-name`, RFC 1035 §2.3.4), which bounds the per-label NSEC3 closest-encloser enumeration (CVE-2023-50868 class). The default NSEC3 iteration ceiling drops from 500 to 150, matching the BIND 9.16.33+ / Unbound 1.17.1 post-CVE defaults (RFC 9276 recommends 0).
12
+
13
+ - v0.13.13 (2026-05-27) — **Archive extraction-path verification now refuses Windows reserved names, NTFS data streams, and trailing-dot/space per segment.** b.guardFilename.verifyExtractionPath (the per-entry gate b.archive.read.zip.extract / b.safeArchive run on every extracted file) checked traversal, absolute paths, drive-letter and UNC prefixes, null bytes, PATH_MAX overflow, and realpath containment — but not the per-segment Windows write-target hazards the disk validate / sanitize paths already reject. An archive entry named CON, NUL.txt, subdir/LPT1, file.txt:hidden, or secret.txt. stayed inside the extraction root, so the containment and realpath checks passed it, yet on Windows it would resolve to a device, write a hidden NTFS stream, or (after Windows strips the trailing dot/space) overwrite a sibling file. These are now refused: any path segment that collides with a Windows reserved device name, uses NTFS alternate-data-stream syntax (name:stream), or carries a trailing dot or leading/trailing whitespace. The checks are platform-unconditional — a verifier running on Linux still refuses names that are only dangerous on the Windows host that ultimately extracts the archive — with a per-check opt-out (reservedNamePolicy / adsPolicy / leadingTrailingPolicy: "allow") for Linux-only targets. **Security:** *`verifyExtractionPath` refuses per-segment Windows extraction hazards (reserved names / NTFS ADS / trailing dot-space)* — Closes a within-root write-target-redirection gap: an extracted entry could stay inside the destination yet, on Windows, resolve to a device (`CON` / `NUL` / `COM1` / `LPT1`), write a hidden alternate data stream (`file.txt:payload`), or overwrite a sibling after Windows strips a trailing dot/space (`config.`). The verification gate now rejects all three per path segment. Refusal is platform-unconditional (the verifier may run on a different OS than the extractor); set `reservedNamePolicy` / `adsPolicy` / `leadingTrailingPolicy` to `"allow"` to opt a check out on a Linux-only target. Single-entry, name-only residuals — 8.3 short-name aliasing, case-insensitive cross-entry collisions, and archive symlink/hardlink entry-target validation — remain the extract orchestrator's responsibility (it owns the case-folded seen-set and the link-target gate).
14
+
11
15
  - v0.13.12 (2026-05-27) — **Inbound MX listener now runs the connection-level gate cascade it documented — HELO identity, DNS blocklist, and greylisting.** b.mail.server.mx.create documented helo / rbl / greylist gate options, but the listener never invoked them — an operator who wired them got silent acceptance of mail those gates would have rejected. They are now wired into the live SMTP state machine: the HELO-identity gate evaluates at HELO/EHLO and refuses a spoofed or malformed identity with 550; the DNS-blocklist gate evaluates the connecting IP once per connection and refuses a listed source with 554; the greylisting gate defers a first-seen (ip, sender, recipient) tuple with a 450 tempfail so legitimate senders retry and pass. Each gate is skipped when the operator doesn't wire it. Because these gates do DNS and store lookups, the per-connection command pump was reworked to process commands asynchronously and strictly in arrival order, so pipelined commands (RFC 2920) cannot overtake a gate still resolving and the existing SMTP-smuggling and STARTTLS-stripping defenses are unchanged. The message-authentication gate (SPF/DKIM/DMARC alignment via b.guardEnvelope) needs the inbound SPF + DKIM verification results as inputs; that inbound-auth pipeline lands as a follow-up, and the documentation no longer implies that gate is active today. **Added:** *HELO-identity / RBL / greylist gates wired into `b.mail.server.mx`* — When wired, `opts.helo` (FCrDNS / HELO-shape / self-name checks) refuses a bad HELO identity at HELO/EHLO with 550; `opts.rbl` refuses a connecting IP found on a DNS blocklist with 554 (evaluated once per connection); `opts.greylist` defers a first-seen (ip, sender, recipient) tuple with 450 4.7.1. Their verdicts surface on the `rcpt_to` event (`rblListed`, `greylist`) and the `helo` event (`heloVerdict`), with dedicated `helo_gate_refused` / `rbl_refused` / `greylist_deferred` audit events. A gate the operator doesn't supply is skipped, never synthesized. **Changed:** *MX command pump processes commands asynchronously and in arrival order* — Gate evaluation involves DNS and store lookups, so the per-connection command pump now awaits each command before the next. Pipelined commands are serialized so a gate resolving cannot let a later command answer ahead of an earlier one; reply ordering, the bare-LF SMTP-smuggling refusal, and the STARTTLS-stripping defense are unchanged. No change to the listener's external behaviour when no gates are wired. **Deprecated:** *SPF/DKIM/DMARC-alignment gate documentation corrected to match what is active* — The `envelope` (SPF/DKIM/DMARC alignment) and `dmarc` gate options were documented as wireable but require inbound SPF + DKIM verification results the listener does not yet produce. They are removed from the documented option set until the inbound-authentication pipeline (composing `b.mail.spf` + `b.mail.dmarc` + DKIM verification) lands; run those checks on the delivered message via the agent handoff in the meantime.
12
16
 
13
17
  - v0.13.11 (2026-05-27) — **Test-suite reliability: replaced fixed-delay waits in the rate-limiter and scheduler suites with condition polling.** No runtime behaviour changes. The rate-limiter, scheduler, and websocket-channel test suites waited for asynchronous work to settle by draining a fixed number of event-loop ticks before asserting. Under heavily parallel CI that budget was occasionally too short, so an assertion read state before the async work (a cluster-backend counter update, a scheduler tick-claim) had landed — an intermittent failure unrelated to the code under test. Those waits now poll the observable condition (helpers.waitUntil) and exit as soon as it holds, with a generous upper bound, so they pass quickly on fast machines and reliably under load. A build gate is added so the fixed-tick-drain shape cannot be reintroduced. **Fixed:** *Flaky fixed-budget waits in the rate-limiter / scheduler / sandbox test suites made contention-tolerant* — The rate-limit-cluster and scheduler-exactly-once suites drained a fixed count of event-loop ticks before asserting on asynchronously-updated state; under contended CI the budget could expire before the work settled, producing intermittent failures. They now wait on the actual observable condition (a written response, a settled counter). The sandbox suite's success-path cases gave the worker a 5 s execution budget that cold worker-thread startup under heavily parallel Windows CI could just exceed; those are raised to the framework's 10 s ceiling. Affects test code only — no change to shipped framework behaviour. The unused tick-drain helper in the websocket-channel suite was removed. **Detectors:** *Build gate rejects the fixed-tick-drain wait shape in tests* — A new test-suite lint rule flags the counted microtask/tick-drain idiom (reassigning a promise to its own `.then()` in a loop to wait a fixed number of ticks), the sibling of the existing fixed-`setTimeout`-sleep rule. A single event-loop yield is unaffected; only the drain-as-wait shape is rejected, directing the wait to condition polling instead.
@@ -91,6 +91,20 @@ var WIN_RESERVED_NAMES = Object.freeze([
91
91
  "CLOCK$", "CONFIG$",
92
92
  ]);
93
93
 
94
+ // Windows folds the superscript digits U+00B9 / U+00B2 / U+00B3 to
95
+ // 1 / 2 / 3 when matching COM/LPT device names, so a superscript-digit
96
+ // form resolves to the same device. Built from numeric codepoints so
97
+ // the source stays pure-ASCII (guard-family rule).
98
+ var _SUPERSCRIPT_DIGIT_MAP = (function () {
99
+ var m = {};
100
+ m[String.fromCharCode(0xB9)] = "1";
101
+ m[String.fromCharCode(0xB2)] = "2";
102
+ m[String.fromCharCode(0xB3)] = "3";
103
+ return m;
104
+ })();
105
+ var _SUPERSCRIPT_DIGIT_RE = new RegExp("[" + String.fromCharCode(0xB9, 0xB2, 0xB3) + "]", "g"); // allow:dynamic-regex — superscript-digit codepoints from a numeric table
106
+
107
+
94
108
  // Path-traversal indicators (anchored matches on raw and percent-decoded
95
109
  // forms).
96
110
  var PATH_TRAVERSAL_RE = /(^|[/\\])\.\.($|[/\\])/;
@@ -243,7 +257,16 @@ function _normalizeNFC(s) {
243
257
  function _isWinReserved(name) {
244
258
  // Reserved-name check applies to the base (without extension) AND to
245
259
  // the entire leaf — both `CON` and `CON.txt` collide with the device.
246
- var upper = name.toUpperCase();
260
+ // Windows normalizes the superscript digits U+00B9 / U+00B2 / U+00B3
261
+ // to 1 / 2 / 3 when matching COM/LPT device names, so those superscript
262
+ // forms resolve to the same devices as COM1 / LPT3; fold them to ASCII
263
+ // before comparison so the spoofed forms are caught too. (Source stays
264
+ // pure-ASCII per the guard-family rule — the codepoints are escaped.)
265
+ // Fold COM/LPT superscript-digit spoofs to ASCII before matching
266
+ // (Windows treats them as the device). See _SUPERSCRIPT_DIGIT_* below.
267
+ var upper = name.toUpperCase().replace(_SUPERSCRIPT_DIGIT_RE, function (ch) {
268
+ return _SUPERSCRIPT_DIGIT_MAP[ch] || ch;
269
+ });
247
270
  for (var i = 0; i < WIN_RESERVED_NAMES.length; i += 1) {
248
271
  var r = WIN_RESERVED_NAMES[i];
249
272
  if (upper === r) return true;
@@ -952,6 +975,29 @@ var PATH_MAX_BYTES = 4096;
952
975
  * resolved absolute path on success; throws `GuardFilenameError` on
953
976
  * any refusal.
954
977
  *
978
+ * Per-segment Windows-extraction hazards are refused too — these are
979
+ * within-root write-target redirections / collisions that the
980
+ * containment + realpath checks structurally cannot see, so they need
981
+ * a name-level check the disk `validate` / `sanitize` paths already
982
+ * carry: a Windows reserved device name (`CON` / `NUL` / `COM1` / …,
983
+ * which resolves to the device), NTFS alternate-data-stream syntax
984
+ * (`name:stream`, which writes a hidden stream of the base file), and a
985
+ * trailing dot / leading-or-trailing whitespace (`secret.txt.`, which
986
+ * Windows strips so the entry overwrites an existing sibling). The
987
+ * checks are platform-unconditional — the verifier may run on Linux
988
+ * while extraction happens on Windows — and each has an opt-out for
989
+ * Linux-only targets (`reservedNamePolicy` / `adsPolicy` /
990
+ * `leadingTrailingPolicy: "allow"`), mirroring `validate`.
991
+ *
992
+ * Out of this primitive's scope (single-entry, name-only): 8.3 short-name
993
+ * aliasing (`PROGRA~1`), case-insensitive cross-entry collision
994
+ * (`Readme.txt` vs `README.TXT` on a case-preserving FS), and archive
995
+ * symlink/hardlink ENTRY-target validation. The first two are cross-entry
996
+ * properties and the third needs the entry's declared link target, which
997
+ * this function never sees — they belong to the extract orchestrator
998
+ * (`b.archive.read.zip.extract` / `b.safeArchive`), which owns the
999
+ * case-folded seen-set and the link-target gate.
1000
+ *
955
1001
  * Companion to `b.guardArchive.checkExtractionPath` (the string-only
956
1002
  * portable gate the guard-archive primitive keeps fs-free for use as
957
1003
  * a posture cascade member). `verifyExtractionPath` deliberately
@@ -964,8 +1010,13 @@ var PATH_MAX_BYTES = 4096;
964
1010
  * Operators rolling their own extract loop call it per entry.
965
1011
  *
966
1012
  * @opts
967
- * followSymlinks: boolean, // default false — symlink in the
1013
+ * followSymlinks: boolean, // default false — symlink in the
968
1014
  * // resolved path refuses unless set
1015
+ * reservedNamePolicy: string, // "allow" opts out of the Windows
1016
+ * // reserved-device-name segment check
1017
+ * adsPolicy: string, // "allow" opts out of the NTFS-ADS check
1018
+ * leadingTrailingPolicy: string, // "allow" opts out of the trailing-dot /
1019
+ * // leading-or-trailing-whitespace check
969
1020
  *
970
1021
  * @example
971
1022
  * var resolved = b.guardFilename.verifyExtractionPath(
@@ -1023,19 +1074,53 @@ function verifyExtractionPath(entryName, extractionRoot, opts) {
1023
1074
  throw new GuardFilenameError("filename.extraction-unc",
1024
1075
  "verifyExtractionPath: entryName starts with a UNC prefix");
1025
1076
  }
1026
- // `..` segment refuses — walk path components.
1077
+ // `..` segment refuses — walk path components. The same walk also
1078
+ // refuses per-segment Windows-extraction hazards the disk `validate`
1079
+ // / `sanitize` paths already catch but that string-containment +
1080
+ // realpath agreement cannot see, because they're WITHIN-root
1081
+ // collisions / write-target redirections rather than boundary
1082
+ // escapes. These checks are platform-UNCONDITIONAL: the verifier may
1083
+ // run on Linux while the archive is extracted on Windows, so a name
1084
+ // that's only dangerous on Windows must still be refused here.
1085
+ // Operators on a Linux-only target opt out per check, mirroring
1086
+ // `validate`'s policy vocabulary.
1027
1087
  var segs = normalized.split("/");
1028
1088
  for (var si = 0; si < segs.length; si += 1) {
1029
- if (segs[si] === ".." || segs[si] === "..\\" || segs[si] === "..%2f" || segs[si] === "..%5c") {
1089
+ var seg = segs[si];
1090
+ if (seg === ".." || seg === "..\\" || seg === "..%2f" || seg === "..%5c") {
1030
1091
  throw new GuardFilenameError("filename.extraction-traversal",
1031
1092
  "verifyExtractionPath: entryName contains .. segment");
1032
1093
  }
1033
1094
  // URL-encoded variants — explicit refusal so operators don't
1034
1095
  // need to percent-decode before passing the entry name in.
1035
- if (/%2e%2e/i.test(segs[si]) || /%c0%ae/i.test(segs[si])) {
1096
+ if (/%2e%2e/i.test(seg) || /%c0%ae/i.test(seg)) {
1036
1097
  throw new GuardFilenameError("filename.extraction-traversal-encoded",
1037
1098
  "verifyExtractionPath: entryName contains encoded .. segment");
1038
1099
  }
1100
+ if (seg === "" || seg === ".") continue; // separators / current-dir — nothing to name-check
1101
+ // Windows reserved device name (CON / NUL / COM1 / LPT1 / …): on
1102
+ // Windows the segment resolves to the device, redirecting the write.
1103
+ if (opts.reservedNamePolicy !== "allow" && _isWinReserved(seg)) {
1104
+ throw new GuardFilenameError("filename.extraction-reserved-name",
1105
+ "verifyExtractionPath: entryName segment " + JSON.stringify(seg) +
1106
+ " collides with a Windows reserved device name");
1107
+ }
1108
+ // NTFS alternate data stream (name:stream): on Windows the write
1109
+ // lands on a hidden stream of the base file, not a normal file.
1110
+ if (opts.adsPolicy !== "allow" && /:[^:\\/]+$/.test(seg)) {
1111
+ throw new GuardFilenameError("filename.extraction-ntfs-ads",
1112
+ "verifyExtractionPath: entryName segment " + JSON.stringify(seg) +
1113
+ " uses NTFS alternate-data-stream syntax (name:stream)");
1114
+ }
1115
+ // Trailing dot / leading-or-trailing whitespace: Windows silently
1116
+ // strips these, so `secret.txt.` or `secret.txt ` collides with an
1117
+ // existing sibling — an in-root overwrite the containment check
1118
+ // cannot see.
1119
+ if (opts.leadingTrailingPolicy !== "allow" && /^\s|\s$|\.$/.test(seg)) {
1120
+ throw new GuardFilenameError("filename.extraction-leading-trailing",
1121
+ "verifyExtractionPath: entryName segment " + JSON.stringify(seg) +
1122
+ " has leading/trailing whitespace or a trailing dot (Windows strips it)");
1123
+ }
1039
1124
  }
1040
1125
  // Resolve the destination path against the root via path.resolve
1041
1126
  // (string-level computation; no fs hits).
@@ -101,7 +101,16 @@ function _canonicalName(name) {
101
101
  parts.push(Buffer.from([lab.length]), lab);
102
102
  }
103
103
  parts.push(Buffer.from([0]));
104
- return Buffer.concat(parts);
104
+ var wire = Buffer.concat(parts);
105
+ // RFC 1035 §2.3.4 — a domain name is at most 255 octets on the wire.
106
+ // Enforcing it here also bounds the per-label count (and thus the NSEC3
107
+ // closest-encloser candidate enumeration, CVE-2023-50868 class), since
108
+ // each label costs at least 2 octets.
109
+ if (wire.length > 255) { // allow:raw-byte-literal — RFC 1035 total-name octet cap
110
+ throw new DnssecError("dnssec/bad-name",
111
+ "dnssec: name '" + name + "' encodes to " + wire.length + " octets, exceeds RFC 1035 cap of 255");
112
+ }
113
+ return wire;
105
114
  }
106
115
 
107
116
  function _u16(n) { return Buffer.from([(n >> 8) & 0xff, n & 0xff]); } // allow:raw-byte-literal — 16-bit big-endian split
@@ -340,7 +349,28 @@ var BASE32HEX = "0123456789ABCDEFGHIJKLMNOPQRSTUV"; // allow:raw-byte-l
340
349
  var TYPE_DS = 43; // allow:raw-byte-literal — IANA RR type DS
341
350
  var TYPE_CNAME = 5;
342
351
  var NSEC3_HASH_SHA1 = 1; // RFC 5155 §5 — the only registered NSEC3 hash
343
- var DEFAULT_MAX_NSEC3_ITERATIONS = 500; // allow:raw-time-literal — DoS ceiling on iterated SHA-1 (RFC 9276 wants 0; deployed zones still use >0)
352
+ var DEFAULT_MAX_NSEC3_ITERATIONS = 150; // allow:raw-time-literal — DoS ceiling on iterated SHA-1; matches BIND 9.16.33+/Unbound 1.17.1 post-CVE-2023-50868 (RFC 9276 wants 0; deployed zones still use >0)
353
+
354
+ // KeyTrap (CVE-2023-50387) amplification caps. A hostile zone can publish
355
+ // many DNSKEYs sharing one 16-bit key tag and many RRSIGs, forcing a
356
+ // validator into O(keys x sigs) full signature verifications from a single
357
+ // query. Bound both factors: the colliding-candidate fan-out per RRSIG, and
358
+ // the total signature-validation work. Matches the BIND
359
+ // `max-key-tag-collisions` + Unbound validation-budget mitigations.
360
+ //
361
+ // The per-response budget SCALES with declared chain depth so a legitimate
362
+ // deep delegation isn't false-rejected: a valid N-link chain does ~2N-1
363
+ // signature verifies (1 root DNSKEY + parent-DS + child-DNSKEY per child),
364
+ // so the budget is links.length * MAX_VALIDATIONS_PER_LINK (= 2 RRSIGs/link
365
+ // x MAX_COLLIDING_KEYS candidates), which always covers the legitimate work
366
+ // while still bounding the bounded-collision amplification. Chain length
367
+ // itself is capped (a delegation can't be deeper than a DNS name's label
368
+ // count, RFC 1035), so the scaled budget can't be inflated arbitrarily.
369
+ var MAX_COLLIDING_KEYS = 4; // allow:raw-byte-literal — same-tag DNSKEY candidates tried per RRSIG
370
+ var MAX_VALIDATIONS_PER_LINK = 8; // allow:raw-byte-literal — 2 RRSIGs/link x MAX_COLLIDING_KEYS; budget = links.length x this
371
+ var MAX_CHAIN_LINKS = 128; // allow:raw-byte-literal — max delegation depth (>= RFC 1035 max label count)
372
+ var MAX_DNSKEYS_PER_ZONE = 64; // allow:raw-byte-literal — DNSKEY RRset size cap per zone link
373
+ var MAX_DS_RECORDS = 16; // allow:raw-byte-literal — DS RRset size cap (parent-supplied)
344
374
 
345
375
  // RFC 4648 §7 base32hex decode (no padding, case-insensitive) — the
346
376
  // label encoding of an NSEC3 owner-name hash.
@@ -757,12 +787,31 @@ function _keysByTag(dnskeys, tag) {
757
787
  // returning the key that validated. A wrong colliding key yields
758
788
  // `dnssec/bad-signature` — that is not terminal, the next candidate is
759
789
  // tried; any other error (expired, alg) is terminal. RFC 4035 §5.3.1.
760
- function _verifyRrsetWithAnyKey(rrsetBase, rrsig, candidates, noKeyCode, noKeyMsg) {
790
+ function _verifyRrsetWithAnyKey(rrsetBase, rrsig, candidates, noKeyCode, noKeyMsg, budget) {
761
791
  if (candidates.length === 0) throw new DnssecError(noKeyCode, noKeyMsg);
792
+ // KeyTrap (CVE-2023-50387): refuse an absurd same-tag fan-out outright —
793
+ // legitimate zones have 1-2 keys per tag; hundreds is an amplification
794
+ // attack, not a real collision.
795
+ if (candidates.length > MAX_COLLIDING_KEYS) {
796
+ throw new DnssecError("dnssec/too-many-colliding-keys",
797
+ "dnssec.verifyChain: " + candidates.length + " DNSKEYs share key tag " +
798
+ rrsig.keyTag + " (cap " + MAX_COLLIDING_KEYS +
799
+ ") — refused as a KeyTrap (CVE-2023-50387) amplification vector");
800
+ }
762
801
  var lastErr = null;
763
802
  for (var i = 0; i < candidates.length; i++) {
764
803
  var kp = _dnskeyParts(candidates[i]);
765
804
  if (kp.algorithm !== rrsig.algorithm) { lastErr = new DnssecError("dnssec/alg-mismatch", "dnssec.verifyChain: candidate key algorithm does not match the RRSIG"); continue; }
805
+ // Per-response signature-validation budget — bound the total expensive
806
+ // pubkey verifies across the whole chain walk, not just per RRSIG.
807
+ if (budget) {
808
+ if (budget.remaining <= 0) {
809
+ throw new DnssecError("dnssec/validation-budget-exceeded",
810
+ "dnssec.verifyChain: per-response signature-validation budget " +
811
+ "exhausted — refused as a KeyTrap (CVE-2023-50387) amplification vector");
812
+ }
813
+ budget.remaining -= 1;
814
+ }
766
815
  try {
767
816
  verifyRrset(Object.assign({}, rrsetBase, { rrsig: rrsig, dnskey: { algorithm: kp.algorithm, publicKey: kp.publicKey } }));
768
817
  return candidates[i];
@@ -795,6 +844,20 @@ function _verifyRrsetWithAnyKey(rrsetBase, rrsig, candidates, noKeyCode, noKeyMs
795
844
  * passes to <code>verifyRrset</code> / <code>verifyDenial</code> for the
796
845
  * actual answer.
797
846
  *
847
+ * KeyTrap (CVE-2023-50387) amplification is bounded with non-configurable
848
+ * caps: at most 4 same-tag DNSKEY candidates are tried per RRSIG, at most
849
+ * 64 DNSKEYs per zone link and 16 DS records per delegation are accepted,
850
+ * the chain is at most 128 links deep, and the whole response is held to a
851
+ * signature-validation budget that scales with chain depth (so a
852
+ * legitimate deep delegation always fits while bounded collisions stay
853
+ * bounded). A hostile zone publishing many colliding keys / signatures is
854
+ * refused with <code>dnssec/too-many-colliding-keys</code> /
855
+ * <code>dnssec/too-many-dnskeys</code> / <code>dnssec/too-many-ds</code> /
856
+ * <code>dnssec/too-many-links</code> /
857
+ * <code>dnssec/validation-budget-exceeded</code> rather than driving
858
+ * O(keys x sigs) verifications. (NSEC3 iteration counts are separately
859
+ * capped at 150 per RFC 9276 / the CVE-2023-50868 fix.)
860
+ *
798
861
  * @opts
799
862
  * {
800
863
  * links: [ { // ordered root-first
@@ -816,14 +879,35 @@ function verifyChain(opts) {
816
879
  validateOpts.requireObject(opts, "dnssec.verifyChain", DnssecError);
817
880
  validateOpts(opts, ["links", "trustAnchors", "at"], "dnssec.verifyChain");
818
881
  if (!Array.isArray(opts.links) || opts.links.length === 0) throw new DnssecError("dnssec/bad-arg", "dnssec.verifyChain: opts.links must be a non-empty array");
882
+ // Cap delegation depth — a real chain can't be deeper than a DNS name's
883
+ // label count (RFC 1035), and the per-response validation budget below
884
+ // scales with this, so it must be bounded.
885
+ if (opts.links.length > MAX_CHAIN_LINKS) {
886
+ throw new DnssecError("dnssec/too-many-links",
887
+ "dnssec.verifyChain: " + opts.links.length + " chain links (cap " +
888
+ MAX_CHAIN_LINKS + ") — refused as an amplification vector");
889
+ }
819
890
  var anchors = opts.trustAnchors !== undefined ? opts.trustAnchors : DEFAULT_ROOT_ANCHORS;
820
891
  if (!Array.isArray(anchors) || anchors.length === 0) throw new DnssecError("dnssec/bad-arg", "dnssec.verifyChain: opts.trustAnchors must be a non-empty array");
821
892
 
893
+ // KeyTrap budget shared across every signature-validation in this
894
+ // response, scaled to the declared chain depth so a legitimate deep
895
+ // delegation (2N-1 verifies) always fits while bounded collisions stay
896
+ // bounded. Chain length is capped above, so this can't be inflated.
897
+ var budget = { remaining: opts.links.length * MAX_VALIDATIONS_PER_LINK };
898
+
822
899
  var trustedKeys = null, path = [];
823
900
  for (var i = 0; i < opts.links.length; i++) {
824
901
  var link = opts.links[i];
825
902
  if (!link || typeof link.zone !== "string" || link.zone === "") throw new DnssecError("dnssec/bad-link", "dnssec.verifyChain: links[" + i + "].zone is required");
826
903
  if (!Array.isArray(link.dnskeys) || link.dnskeys.length === 0) throw new DnssecError("dnssec/bad-link", "dnssec.verifyChain: links[" + i + "].dnskeys must be a non-empty array");
904
+ // KeyTrap: bound the DNSKEY RRset size per zone so a giant key set
905
+ // can't blow up the key-tag scan / candidate fan-out.
906
+ if (link.dnskeys.length > MAX_DNSKEYS_PER_ZONE) {
907
+ throw new DnssecError("dnssec/too-many-dnskeys",
908
+ "dnssec.verifyChain: links[" + i + "] has " + link.dnskeys.length +
909
+ " DNSKEYs (cap " + MAX_DNSKEYS_PER_ZONE + ") — refused as a KeyTrap (CVE-2023-50387) amplification vector");
910
+ }
827
911
  if (!link.dnskeyRrsig || typeof link.dnskeyRrsig !== "object") throw new DnssecError("dnssec/bad-link", "dnssec.verifyChain: links[" + i + "].dnskeyRrsig is required");
828
912
 
829
913
  // 1. The DNSKEY RRset is self-signed by one of its own keys (trying
@@ -832,7 +916,8 @@ function verifyChain(opts) {
832
916
  { name: link.zone, type: "DNSKEY", rdatas: link.dnskeys, at: opts.at },
833
917
  link.dnskeyRrsig,
834
918
  _keysByTag(link.dnskeys, link.dnskeyRrsig.keyTag),
835
- "dnssec/chain-no-signing-key", "dnssec.verifyChain: no DNSKEY in '" + link.zone + "' verifies the DNSKEY RRSIG"
919
+ "dnssec/chain-no-signing-key", "dnssec.verifyChain: no DNSKEY in '" + link.zone + "' verifies the DNSKEY RRSIG",
920
+ budget
836
921
  );
837
922
 
838
923
  // 2. Establish trust in the signing key.
@@ -851,11 +936,19 @@ function verifyChain(opts) {
851
936
  if (!Array.isArray(link.dsRdatas) || link.dsRdatas.length === 0 || !link.dsRrsig || typeof link.dsRrsig !== "object") {
852
937
  throw new DnssecError("dnssec/bad-link", "dnssec.verifyChain: links[" + i + "] needs dsRdatas + dsRrsig (DS served by the parent)");
853
938
  }
939
+ // Bound the parent-supplied DS RRset — the DS-match loop below
940
+ // iterates it, and an oversize set is an amplification vector.
941
+ if (link.dsRdatas.length > MAX_DS_RECORDS) {
942
+ throw new DnssecError("dnssec/too-many-ds",
943
+ "dnssec.verifyChain: links[" + i + "] has " + link.dsRdatas.length +
944
+ " DS records (cap " + MAX_DS_RECORDS + ") — refused as an amplification vector");
945
+ }
854
946
  _verifyRrsetWithAnyKey(
855
947
  { name: link.zone, type: "DS", rdatas: link.dsRdatas, at: opts.at },
856
948
  link.dsRrsig,
857
949
  _keysByTag(trustedKeys, link.dsRrsig.keyTag),
858
- "dnssec/chain-no-parent-key", "dnssec.verifyChain: no trusted parent key verifies the DS RRSIG for '" + link.zone + "'"
950
+ "dnssec/chain-no-parent-key", "dnssec.verifyChain: no trusted parent key verifies the DS RRSIG for '" + link.zone + "'",
951
+ budget
859
952
  );
860
953
  var dsMatched = false;
861
954
  for (var d = 0; d < link.dsRdatas.length; d++) {
@@ -681,6 +681,14 @@ function parse(input, opts) {
681
681
  "toml/redefine");
682
682
  }
683
683
  t = sub[sub.length - 1];
684
+ // The array's last element must itself be a table to descend
685
+ // into. A plain VALUE array (e.g. `a = [3]` then `[a.s]`) has a
686
+ // scalar last element — descending would set a property on a
687
+ // number and throw a raw TypeError; refuse it cleanly instead.
688
+ if (t === null || typeof t !== "object" || Array.isArray(t)) {
689
+ throw _err("cannot descend into '" + seg +
690
+ "' — it is a value array, not an array of tables", "toml/redefine");
691
+ }
684
692
  continue;
685
693
  }
686
694
  if (typeof sub !== "object" || sub === null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.13.12",
3
+ "version": "0.13.14",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:cfe1e116-abd7-4631-96e5-8a2a865e2a16",
5
+ "serialNumber": "urn:uuid:7b7971af-a00b-4343-af83-3f8ef1a433dc",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-27T11:02:44.192Z",
8
+ "timestamp": "2026-05-27T13:56:08.465Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.13.12",
22
+ "bom-ref": "@blamejs/core@0.13.14",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.13.12",
25
+ "version": "0.13.14",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.13.12",
29
+ "purl": "pkg:npm/%40blamejs/core@0.13.14",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.13.12",
57
+ "ref": "@blamejs/core@0.13.14",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]