@blamejs/core 0.16.16 → 0.16.18

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.16.x
10
10
 
11
+ - v0.16.18 (2026-07-12) — **Refuse a stale FIDO metadata BLOB on the operator-fetch path, harden the GraphQL DoS-shape guard against escaped strings and malformed queries, parse POSIX leading-space octal in tar headers, and fix the FIDO certified-level, self-update 304, and swap-maxBytes paths — surfaced by covering previously-untested error and adversarial branches.** Covering untested error and adversarial branches across four security-relevant primitives surfaced seven defects, fixed at the root. The most serious: b.auth.fidoMds3.fetch() reimplemented the metadata-BLOB validation inline and omitted the stale-BLOB refusal the internal path enforced — so a signed-but-expired BLOB (nextUpdate in the past) was accepted and cached, letting an attacker who serves an ancient correctly-signed BLOB freeze an operator's revoked/compromised-authenticator list at a time of their choosing. Both paths now route through one _verifyBlobWithRoots source of truth, so no check can be present on one and missing on the other. The GraphQL query-shape guard (the pre-schema depth/alias DoS walker) miscounted on a string literal containing an escaped quote and on a brace-unbalanced malformed query, weakening the depth/alias limits it exists to enforce. The FIDO certified-level resolver lexically compared status reports and let an undated later report (a decertification or downgrade) lose to an earlier dated one, so a now-decertified authenticator could still read as certified. A tar reader misparsed POSIX leading-space-padded octal header fields to 0 (a size misparse desynced the block walker and rejected archives other tars extract). self-update's documented 304 If-None-Match fast-path was dead (it threw on every unchanged conditional poll), and selfUpdate.swap read an opts.maxBytes it never declared, so it could refuse a large binary that selfUpdate.verify accepted. **Fixed:** *b.auth.fidoMds3 certified-level honors an undated later decertification or downgrade* — _certifiedLevel compared status reports lexically and coerced a missing effectiveDate to an empty string, which sorts before any real date — so a later report with no effectiveDate (a NOT_FIDO_CERTIFIED decertification, or a downgrade/upgrade) always lost to an earlier dated grant, and certifiedLevel froze at the stale, higher historical value. A step-up policy reading certifiedLevel >= N could therefore accept a now-decertified authenticator. Reports without a comparable date now fall back to array order (append order = chronological), so a later decertification/downgrade wins. · *b.archive.read.tar parses POSIX leading-space-padded octal header fields* — A tar numeric header field (mode / uid / gid / size / mtime / checksum) that is left-padded with ASCII spaces — POSIX-legal, and emitted by star / BSD / Java / Perl tars — was misparsed to 0 because the octal reader treated a leading space as a field terminator. A misparsed size desynced the 512-byte block walker (it read the file body as the next header) and rejected an archive other tars extract cleanly. The reader now skips leading-space padding before reading octal digits. · *b.selfUpdate.poll returns a no-update result on a 304 Not Modified* — The documented If-None-Match / ETag fast-path was unreachable: poll delegated HTTP status handling to the HTTP client, which rejects every non-2xx (304 included) as an error before poll could inspect the status. A conditional poll that correctly received a 304 threw selfupdate/poll-failed instead of returning { available: false, statusCode: 304 }. The 304 (and the non-2xx) branch is now reached, so ETag-conditional polling works as documented. · *b.selfUpdate.swap accepts the maxBytes it re-reads under* — swap re-reads the newly-installed bytes to re-hash them (closing the verify→swap window) under an opts.maxBytes cap, but its option schema never declared maxBytes — so a caller who passed it was refused with selfupdate/bad-opts and swap always used the fixed 1 GiB default, which could refuse a large binary that selfUpdate.verify (which does declare maxBytes) accepted. swap now declares maxBytes (validated like verify's), so the two caps match; rollback, which re-reads nothing, still does not accept it. **Security:** *b.auth.fidoMds3.fetch refuses a stale (expired) metadata BLOB* — The operator-facing fetch path (which trusts caCertificate roots) reimplemented the metadata-BLOB parse and verify inline and omitted the stale-BLOB refusal that the default-roots path enforced, so a BLOB whose nextUpdate is already in the past was accepted and cached even though FIDO MDS3 §3.1.7 says it must not be trusted. An attacker serving an ancient, correctly-signed BLOB could pin an operator to a revoked/compromised-authenticator list frozen at that time. The JWS + x5c-chain verify, payload-shape checks, and stale-BLOB refusal now live in one _verifyBlobWithRoots helper that both the operator-fetch and default-roots paths route through, so the checks can never drift apart again. · *b.guardGraphql hardens the pre-schema DoS-shape walker against escaped strings and malformed queries* — The query-shape walker that enforces depth and alias limits before the query reaches a schema miscounted in two ways: it treated an escaped quote inside a valid GraphQL string literal as the string's terminator (desyncing its in-string state), and it popped its depth-indexed alias counter on every closing brace even for a brace-unbalanced malformed query (underflowing the counter). Both weakened the depth/alias caps the guard exists to enforce against a DoS-shaped query. The walker now tracks string escapes correctly and guards the alias-counter stack against imbalance.
12
+
13
+ - v0.16.17 (2026-07-12) — **Reject INI float literals that overflow to Infinity, return a verdict (not an exception) when a reverse-DNS lookup faults, wrap an async redirect-hook rejection like a sync throw, and refuse a fractional --max-rows — four defects surfaced by covering previously-untested error branches.** Covering previously-untested error and adversarial branches across four primitives surfaced four genuine defects, each fixed at the root. b.parsers.ini.parse coerced a value like `x = 1e999` straight to ±Infinity — its integer and hex branches already reject out-of-range numbers, but the float branch had no finiteness guard, so an overflowing float slipped through and could poison a downstream size cap or timeout; it is now rejected with ini/value-out-of-range. b.mail.iprev.verify threw an unhandled exception when the forward-confirm DNS lookup returned an error code outside the handful it enumerated (EREFUSED / ENOTIMP / …), even though its reverse-lookup path and every sibling (SPF/DKIM/DMARC/ARC) return a verdict for such faults; it now returns a temperror verdict, and — like those siblings — accepts an operator opts.dnsLookup resolver so the confirm path is resolvable offline. b.httpClient wrapped a synchronous onRedirect hook throw into a REDIRECT_ABORTED error but let an async hook rejection escape unwrapped; both now abort the redirect identically. And the blamejs audit verify-chain --max-rows flag accepted a fractional value (2.5), which truncated the chain walk mid-row and reported a nonsensical fractional count; it now requires a whole positive integer, matching its own error message. **Fixed:** *b.parsers.ini.parse rejects an overflowing float instead of coercing to Infinity* — A float literal that exceeds the representable range (e.g. `x = 1e999`) coerced to ±Infinity. The integer and hex coercion branches already reject out-of-range numbers via Number.isSafeInteger, but the float branch returned Number(raw) with no finiteness check — so an Infinity could flow into a downstream size cap or timeout, a denial-of-service vector. The float branch now rejects a non-finite result with ini/value-out-of-range; a large-but-finite float (1e308) and underflow (1e-999 → 0) still parse. · *b.mail.iprev.verify returns a temperror verdict on an un-enumerated reverse/forward DNS fault* — The forward-confirm DNS lookup's error handler enumerated a few transient codes and threw for anything else, so a resolver returning EREFUSED / ENOTIMP / EBADRESP produced an unhandled exception from the public API rather than a verdict. The reverse-lookup path and every sibling result type (SPF / DKIM / DMARC / ARC) return a verdict for a DNS-derived fault; the forward path now does too (temperror). iprev.verify also gains an operator opts.dnsLookup resolver, matching the dnsLookup contract the other types already honor, so the forward-confirm path is resolvable offline. · *b.httpClient aborts a redirect on an async onRedirect hook rejection* — A synchronous throw from the onRedirect hook was wrapped into a REDIRECT_ABORTED error, but an async hook that rejected let the rejection escape unwrapped — inconsistent handling for the same operator control point. An async onRedirect rejection now aborts the redirect with REDIRECT_ABORTED, identical to the synchronous throw. · *blamejs audit verify-chain --max-rows requires a whole positive integer* — The --max-rows flag validated only that the value was finite and >= 1, so a fractional value (2.5) was accepted and passed to the chain walk, where it truncated the verification mid-row and reported a fractional rowsVerified count — despite the flag's own error message promising a positive integer. It now rejects a non-integer value, matching the sibling --steps flag.
14
+
11
15
  - v0.16.16 (2026-07-12) — **Build the data-subject-request ticket store's SQL through the shared b.sql query builder instead of hand-assembled statements, and add a static check that keeps db-handle primitives composing b.sql.** A maintainability change with no behavior difference for operators. The b.dsr ticket store built its reads and writes by concatenating table and column names into SQL strings passed to db.prepare, re-implementing the identifier quoting and sealed-field handling that b.sql — the same builder b.db.from() uses — already provides. That hand-rolled shape is how b.tenant.quota's storage query drifted from the query builder and accrued a run of parity defects fixed in 0.16.15 (reserved-word names, schema-qualified names, sealed-column filtering). The store's DML now composes b.sql (its schema DDL, which is not a b.sql concern, stays as direct statements), so its SQL cannot diverge from the builder. A new codebase-patterns check flags any db-handle primitive that passes an inline SELECT / INSERT / UPDATE / DELETE string literal to db.prepare / runSql, directing it to compose b.sql instead, so this class of drift cannot recur. **Changed:** *b.dsr ticket store composes b.sql for its reads and writes* — The data-subject-request ticket store (insert / get / list / update / delete / purge and the legacy re-seal backfill) now builds its DML with the b.sql query builder — sql.select / insert / update / delete(table, { dialect, quoteName }).…toSql() — and prepares the resulting statement, rather than concatenating identifiers into SQL strings by hand. This removes a hand-rolled identifier-quoting surface that could drift from what b.db.from() accepts. Schema provisioning (CREATE TABLE / INDEX, ALTER, PRAGMA) is not a b.sql concern and remains as direct statements. One behavior change: on a store backed by a vault, a ticket payload is AEAD-sealed and base64-encoded (~4/3 expansion) before it is bound, and the bound cell must fit the query builder's 64 MiB per-value ceiling — so the payload is now capped at an expansion-safe plaintext size (~48 MiB) and a larger ticket is refused with dsr/ticket-too-large (route large access/portability exports through chunked storage rather than one giant sealed cell). Plaintext stores keep the full 64 MiB limit. When a vault is first enabled on a table that already holds an over-cap legacy plaintext row, the one-time re-seal backfill still migrates that row's subject columns and derived hashes — so it stays findable by subject lookup and erasable by the data-subject erasure purge — and leaves only the over-cap payload plaintext (still under the read ceiling, DB-encrypted at rest, and removed when the row is erased), rather than failing provisioning with a query-builder error. **Detectors:** *Static check: db-handle primitives must compose b.sql for DML* — A new codebase-patterns check flags any primitive holding a db handle that runs DML by passing an inline SELECT / INSERT / UPDATE / DELETE string literal to db.prepare / runSql — the shape that lets a query drift from b.sql's identifier quoting and sealed-field rewrite (the b.tenant.quota storage defect class). It directs authors to build the query with b.sql and prepare the resulting string. DDL and PRAGMA (not b.sql verbs) and queries already built through a b.sql variable are out of scope.
12
16
 
13
17
  - v0.16.15 (2026-07-11) — **Restore break-glass certificate key escrow, hand a failed production-security assertion its real diagnostic message, and make tenant storage-byte quotas actually enforce — three defects surfaced by broadening test coverage and fixed at the root.** Three primitives had defects that only a hostile or previously-untested path reached. b.cert key escrow — the optional break-glass path that seals a renewed private key to an operator's offline recipient — never worked: writeEscrow called a b.crypto method that does not exist, so any certificate configured with keyEscrow threw the moment renewal tried to seal the key. It now seals via b.crypto.encrypt (ML-KEM-1024, plus the P-384 hybrid leg when the recipient supplies an ecPublicKey) and the operator recovers the key offline with b.crypto.decrypt; the recipient accepts an ML-KEM-1024 public-key PEM string or a { publicKey, ecPublicKey } pair from b.crypto.generateEncryptionKeyPair(). b.security.assertProduction constructed its error with the code and message transposed, so a failed production-security assertion threw with a bare token (BAD_OPT / ASSERT_FAILED) as its .message and buried the human-readable explanation in .code — operators now get the full diagnostic where they read it. And b.tenant.quota storage-byte accounting was broken several ways: the per-tenant byte sum issued a query the builder rejects, so snapshot / assert / list always threw once a storage cap was set; it read rows through the auto-unsealing ORM, so a sealed column was measured as its small decrypted plaintext rather than the larger on-disk vault envelope (letting sealed-column tenants slip under the cap); when the tenant identifier itself was a sealed column, the plaintext lookup matched no rows at all and the cap silently counted zero; and BLOB columns (handed back as Uint8Array by node:sqlite) were stringified before measuring, roughly tripling their counted size and refusing writes far below the real cap. All are fixed — the sum now filters a sealed tenant id by its derived-hash blind index, reads the raw stored rows, and measures true on-disk byte lengths — so storage quotas enforce at the configured limit. **Fixed:** *b.cert break-glass key escrow seals the renewed key instead of throwing* — A certificate configured with keyEscrow forwarded the private key to writeEscrow, which called a b.crypto.encryptEnvelope method that does not exist — so escrow threw on every renewal and the break-glass recovery path was unusable. It now seals the key to the operator's offline recipient with b.crypto.encrypt: ML-KEM-1024 always, plus a P-384 hybrid leg when the recipient carries an ecPublicKey. The recipient accepts an ML-KEM-1024 public-key PEM string or a { publicKey, ecPublicKey } pair from b.crypto.generateEncryptionKeyPair(); the sealed key is never decrypted by the framework and is recovered offline with b.crypto.decrypt and the matching private key(s). · *b.security.assertProduction throws with the diagnostic in .message* — SecurityAssertError was constructed with its code and message arguments transposed, so a failed production-security assertion surfaced a bare token (BAD_OPT / ASSERT_FAILED) as its .message while the explanatory text — including the per-assertion failure list — landed in .code. Operators catching the error now read the full diagnostic in .message and the stable token in .code, as documented. · *b.tenant.quota enforces storage-byte caps at the configured limit* — The per-tenant storage-bytes accounting had several defects. It issued a query the query builder rejects (a literal '*' column), so snapshot / assert / list threw as soon as a storage cap was configured — the storage half of tenant quotas never ran against a real database. It read rows through the ORM, which auto-unseals sealed columns, so a sealed cell was measured as its small decrypted plaintext rather than the much larger vault envelope actually on disk — a tenant whose data lives in sealed columns could sail under the cap. When the tenant identifier column itself was sealed, the plaintext lookup compared against the on-disk envelope and matched no rows, so the cap silently counted zero for those tenants. And BLOB columns, which node:sqlite returns as a Uint8Array rather than a Node Buffer, were stringified before measuring: String(uint8array) is the decimal-joined bytes, roughly a 3x overcount that refused writes well below the real cap. The sum now filters a sealed tenant identifier by its derived-hash blind index (as the query builder does), reads the raw stored rows (no unseal), and counts text as its UTF-8 byte length and typed-array views by their true byte length, so a storage cap — including data in sealed columns — enforces at the limit operators set.
@@ -175,10 +175,20 @@ function _writeString(buf, value, offset, width) {
175
175
 
176
176
  function _readOctal(buf, offset, width) {
177
177
  // Read an octal-encoded field. Terminator may be space or NUL.
178
+ //
179
+ // POSIX permits numeric fields to be LEFT-padded with spaces (as well as
180
+ // zeros) — star, BSD tar, and several Java/Perl tar libraries emit them,
181
+ // and GNU tar / libarchive skip them. Treating a leading space as a
182
+ // terminator silently misreads such a field as 0 (a parser-differential:
183
+ // the reader would disagree with every other tar on the same bytes, and a
184
+ // misread size desyncs the block walker). Skip leading-space padding first;
185
+ // a space or NUL AFTER the digits still terminates the field.
186
+ var i = 0;
187
+ while (i < width && buf[offset + i] === 0x20) i += 1; // ASCII space (0x20) leading padding per POSIX numeric field
178
188
  var s = "";
179
- for (var i = 0; i < width; i += 1) {
189
+ for (; i < width; i += 1) {
180
190
  var c = buf[offset + i];
181
- if (c === 0x20 || c === 0) break; // ASCII space (0x20) + NUL (0x00) field terminators
191
+ if (c === 0x20 || c === 0) break; // ASCII space (0x20) + NUL (0x00) field terminators after digits
182
192
  if (c < 0x30 || c > 0x37) { // ASCII '0' (0x30) .. '7' (0x37) octal digits
183
193
  throw new TarError("archive-tar/bad-octal",
184
194
  "non-octal byte 0x" + c.toString(16) + " at offset " + (offset + i)); // radix=16 for diagnostic hex format
@@ -344,12 +344,16 @@ function _parseNextUpdate(s) {
344
344
  return d;
345
345
  }
346
346
 
347
- // Internal verify-blob helper used by both fetch (live HTTP) and the
348
- // fetch-with-injected-body test path. Operator-facing surface goes
349
- // through fetch().
350
- function _verifyAndParseBlob(token) {
347
+ // Single source of truth for the BLOB-trust decision: JWS + x5c-chain verify
348
+ // against the supplied trust roots, payload-shape validation, AND the
349
+ // stale-BLOB refusal. fetch() (operator-supplied roots via caCertificate) and
350
+ // _verifyAndParseBlob (default vendored roots) BOTH route here so no check can
351
+ // ever be present on one path but missing on the other. The stale-refusal used
352
+ // to live only in this helper's caller-inlined twin, so the operator-facing
353
+ // fetch path silently accepted (and cached) a signed-but-expired BLOB; keeping
354
+ // one body closes that class.
355
+ function _verifyBlobWithRoots(token, rootPems) {
351
356
  var jws = _parseJws(token);
352
- var rootPems = _resolveRoots(undefined);
353
357
  var chain = _validateChain(jws.header.x5c, rootPems);
354
358
  _verifyJws(jws, chain[0]);
355
359
  var payload = jws.payload;
@@ -366,19 +370,19 @@ function _verifyAndParseBlob(token) {
366
370
  throw new FidoMds3Error("fido-mds3/bad-payload",
367
371
  "BLOB payload 'nextUpdate' missing or not YYYY-MM-DD: " + payload.nextUpdate);
368
372
  }
369
- // Stale-BLOB refusal FIDO MDS3 §3.1.7 says clients SHOULD refresh
370
- // by nextUpdate; a BLOB whose nextUpdate is already in the past is
371
- // not safe to trust even though its cert chain still validates.
372
- // Pre-v0.9.2 the staleness was floored to MIN_CACHE_TTL_MS in
373
- // _ttlFromNextUpdate but the BLOB itself was still served from
374
- // cache; an attacker serving an ancient signed-but-expired BLOB
375
- // could keep operators on a revoked-authenticator-list-frozen-at-X.
376
- // Refuse at parse time so neither fetch nor cache lookup honors it.
373
+ // Stale-BLOB refusal (FIDO MDS3 section 3.1.7): clients SHOULD refresh by
374
+ // nextUpdate; a BLOB whose nextUpdate is already in the past is not safe to
375
+ // trust even though its cert chain still validates. Flooring the staleness to
376
+ // MIN_CACHE_TTL_MS in _ttlFromNextUpdate is not enough on its own -- the BLOB
377
+ // itself must be refused, or an attacker serving an ancient signed-but-
378
+ // expired BLOB keeps operators pinned to a revoked-authenticator list frozen
379
+ // at that time. Refuse at parse time so neither fetch nor a cache lookup ever
380
+ // honors it.
377
381
  if (nextUpdate.getTime() < Date.now()) {
378
382
  throw new FidoMds3Error("fido-mds3/blob-stale",
379
383
  "BLOB payload nextUpdate \"" + payload.nextUpdate +
380
- "\" is in the past refusing to trust a stale metadata BLOB " +
381
- "(FIDO MDS3 §3.1.7)");
384
+ "\" is in the past -- refusing to trust a stale metadata BLOB " +
385
+ "(FIDO MDS3 section 3.1.7)");
382
386
  }
383
387
  return {
384
388
  entries: payload.entries,
@@ -388,6 +392,13 @@ function _verifyAndParseBlob(token) {
388
392
  };
389
393
  }
390
394
 
395
+ // Internal verify-blob helper used by tests to exercise the verifier against
396
+ // the DEFAULT vendored MDS3 trust roots without standing up a real HTTPS
397
+ // endpoint. Operator-facing surface goes through fetch().
398
+ function _verifyAndParseBlob(token) {
399
+ return _verifyBlobWithRoots(token, _resolveRoots(undefined));
400
+ }
401
+
391
402
  // ---- public surface ----
392
403
 
393
404
  /**
@@ -481,39 +492,22 @@ async function fetch(opts) { // allow:raw-outbound-http-framework-internal —
481
492
  }
482
493
  var token = rsp.body.toString("ascii").trim();
483
494
 
484
- var jws = _parseJws(token);
485
- var chain = _validateChain(jws.header.x5c, rootPems);
486
- _verifyJws(jws, chain[0]);
487
- var payload = jws.payload;
488
- if (!payload || !Array.isArray(payload.entries)) {
489
- throw new FidoMds3Error("fido-mds3/bad-payload",
490
- "BLOB payload missing 'entries' array");
491
- }
492
- if (typeof payload.no !== "number" || !isFinite(payload.no)) {
493
- throw new FidoMds3Error("fido-mds3/bad-payload",
494
- "BLOB payload missing or non-numeric 'no'");
495
- }
496
- var nextUpdate = _parseNextUpdate(payload.nextUpdate);
497
- if (!nextUpdate) {
498
- throw new FidoMds3Error("fido-mds3/bad-payload",
499
- "BLOB payload 'nextUpdate' missing or not YYYY-MM-DD: " + payload.nextUpdate);
500
- }
501
- var record = {
502
- entries: payload.entries,
503
- no: payload.no,
504
- nextUpdate: nextUpdate,
505
- url: url,
506
- legalHeader: payload.legalHeader,
507
- };
495
+ // Route the trust decision through the shared verifier so this operator-
496
+ // facing path enforces the identical checks (chain verify, payload shape,
497
+ // AND the stale-BLOB refusal) as the internal helper -- fetch previously
498
+ // reimplemented the parse inline and omitted the stale check, silently
499
+ // accepting and caching an ancient signed-but-expired BLOB.
500
+ var record = _verifyBlobWithRoots(token, rootPems);
501
+ record.url = url;
508
502
  // Re-assert TTL based on the BLOB's nextUpdate (overrides the
509
503
  // wrap-call's safe-minimum seed).
510
- try { await c.set(cacheKey, record, _ttlFromNextUpdate(nextUpdate)); }
504
+ try { await c.set(cacheKey, record, _ttlFromNextUpdate(record.nextUpdate)); }
511
505
  catch (_e) { /* cache.set best-effort */ }
512
506
  try { audit().safeEmit({
513
507
  action: "auth.fido_mds3.fetch",
514
508
  outcome: "success",
515
- metadata: { url: url, no: payload.no, entries: payload.entries.length,
516
- nextUpdate: payload.nextUpdate },
509
+ metadata: { url: url, no: record.no, entries: record.entries.length,
510
+ nextUpdate: record.nextUpdate.toISOString().slice(0, 10) },
517
511
  }); } catch (_e) { /* audit best-effort */ }
518
512
  return record;
519
513
  }, MIN_CACHE_TTL_MS);
@@ -577,18 +571,35 @@ function lookupAaguid(blob, aaguid) {
577
571
  function _certifiedLevel(statusReports) {
578
572
  if (!Array.isArray(statusReports)) return { level: 0, plus: false };
579
573
  var latest = null;
580
- var latestDate = null;
574
+ var latestDate = null; // null == no comparable date (missing / malformed)
581
575
  for (var i = 0; i < statusReports.length; i++) {
582
576
  var sr = statusReports[i];
583
577
  // Only certification-status reports move the level: a level grant, or its
584
578
  // explicit revocation (NOT_FIDO_CERTIFIED). Other statuses (UPDATE_AVAILABLE,
585
- // REVOKED, ) are handled elsewhere and must not be read as a level. The
586
- // status length is bounded before the regex test below FIDO status tokens
579
+ // REVOKED, etc.) are handled elsewhere and must not be read as a level. The
580
+ // status length is bounded before the regex test below -- FIDO status tokens
587
581
  // are short enums; bounding input before any .test() is the convention.
588
582
  if (!sr || typeof sr.status !== "string" || sr.status.length > 64) continue;
589
583
  if (!CERT_LEVEL_RE.test(sr.status) && sr.status !== "NOT_FIDO_CERTIFIED") continue;
590
- var d = typeof sr.effectiveDate === "string" ? sr.effectiveDate : "";
591
- if (latest === null || d >= latestDate) {
584
+ // Only a well-formed calendar date is comparable. Reuse _parseNextUpdate
585
+ // (which already validates YYYY-MM-DD AND rejects impossible dates such as
586
+ // 2026-02-31) and compare epoch ms. A missing / malformed effectiveDate is
587
+ // "no comparable date" (null) and falls back to array order (append order,
588
+ // which the spec defines as chronological): the report later in the array
589
+ // is the more recent one. Coercing a missing date to "" and running it
590
+ // through a lexical compare made an undated report lose to EVERY earlier
591
+ // dated report, so a later decertification / downgrade / upgrade whose
592
+ // effectiveDate was absent never superseded the earlier grant -- freezing
593
+ // certifiedLevel at a stale value (an authenticator-assurance bypass: a
594
+ // decertified or downgraded authenticator kept reporting its prior level to
595
+ // step-up / risk policy).
596
+ var parsed = typeof sr.effectiveDate === "string" ? _parseNextUpdate(sr.effectiveDate) : null;
597
+ var d = parsed ? parsed.getTime() : null;
598
+ if (latest === null) { latest = sr; latestDate = d; continue; }
599
+ // sr is later in array order than the current latest. A missing/malformed
600
+ // date on EITHER side means array order decides -> sr (later) wins. Both
601
+ // dated -> the newer-or-equal date wins (sr, being later, takes ties).
602
+ if (d === null || latestDate === null || d >= latestDate) {
592
603
  latest = sr; latestDate = d;
593
604
  }
594
605
  }
package/lib/cli.js CHANGED
@@ -713,7 +713,14 @@ async function _runAudit(args, ctx) {
713
713
  var tableV = args.flags.table ? String(args.flags.table) : "audit_log";
714
714
  var maxRows = args.flags["max-rows"];
715
715
  var maxRowsN = maxRows === undefined ? undefined : Number(maxRows);
716
- if (maxRowsN !== undefined && (!Number.isFinite(maxRowsN) || maxRowsN < 1)) {
716
+ // Positive-INTEGER validation, mirroring `migrate down --steps`: reject
717
+ // NaN/Infinity, < 1, AND any fractional value. The message promises "a
718
+ // positive integer", so a value like 2.5 must be refused here at the
719
+ // entry point rather than passed to verifyChain, where a fractional
720
+ // maxRows truncates the walk mid-chain and reports a nonsensical
721
+ // fractional rowsVerified (Math.min(rows.length, 2.5)).
722
+ if (maxRowsN !== undefined &&
723
+ (!Number.isFinite(maxRowsN) || maxRowsN < 1 || Math.floor(maxRowsN) !== maxRowsN)) {
717
724
  _writeLine(ctx.stderr, "blamejs audit verify-chain: --max-rows must be a positive integer");
718
725
  return 2;
719
726
  }
@@ -165,7 +165,8 @@ function _measureQueryShape(query) {
165
165
  var depth = 0;
166
166
  var inString = false;
167
167
  var inComment = false;
168
- var aliasCounts = [0]; // per-depth alias counter
168
+ var escapeNext = false; // inside a string, the previous char was an unescaped backslash
169
+ var aliasStack = [0]; // per-selection-set alias counter (top of stack = current set)
169
170
  for (var i = 0; i < query.length; i += 1) {
170
171
  var c = query.charAt(i);
171
172
  if (inComment) {
@@ -173,7 +174,18 @@ function _measureQueryShape(query) {
173
174
  continue;
174
175
  }
175
176
  if (inString) {
176
- if (c === '"' && query.charAt(i - 1) !== "\\") inString = false;
177
+ // Escaped-quote handling must track the backslash RUN, not just the
178
+ // single preceding char. `"\\"` is a complete string (its backslash is
179
+ // itself escaped), so a naive `charAt(i - 1) !== "\\"` test reads the
180
+ // closing quote as escaped, leaves the walker stuck in-string, and
181
+ // blinds it to every following brace / colon — silently under-counting
182
+ // depth and alias breadth so a depth-bomb or alias-bomb rides through as
183
+ // shape-clean (a fail-open DoS-measurement bypass on valid GraphQL).
184
+ // Toggle on each backslash: a closing quote ends the string only after
185
+ // an EVEN run (escapeNext false).
186
+ if (escapeNext) { escapeNext = false; continue; }
187
+ if (c === "\\") { escapeNext = true; continue; }
188
+ if (c === '"') inString = false;
177
189
  continue;
178
190
  }
179
191
  if (c === '"') { inString = true; continue; }
@@ -181,28 +193,35 @@ function _measureQueryShape(query) {
181
193
  if (c === "{") {
182
194
  depth += 1;
183
195
  if (depth > maxDepth) maxDepth = depth;
184
- aliasCounts.push(0);
196
+ aliasStack.push(0);
185
197
  } else if (c === "}") {
186
198
  // Capture the current selection-set's alias count before popping
187
199
  // — otherwise we lose the per-block max when the block closes.
188
- var current = aliasCounts[aliasCounts.length - 1] || 0;
200
+ var current = aliasStack[aliasStack.length - 1] || 0;
189
201
  if (current > maxAliases) maxAliases = current;
190
202
  depth -= 1;
191
- aliasCounts.pop();
203
+ // Never pop the base counter: an unbalanced leading `}` would otherwise
204
+ // desync the stack from `depth`, and a subsequent `depth`-indexed
205
+ // increment lands on an absent slot (`undefined + 1 === NaN`), poisoning
206
+ // every later comparison so an alias-bomb reads as clean.
207
+ if (aliasStack.length > 1) aliasStack.pop();
192
208
  if (depth < 0) depth = 0;
193
209
  } else if (c === ":") {
194
- // Alias indicator — `alias: field`. Increment the current depth's
195
- // counter when the char before `:` looks like an identifier.
210
+ // Alias / argument indicator — `alias: field` / `field(arg: value)`.
211
+ // Increment the CURRENT selection-set's counter (top of stack) when the
212
+ // char before `:` looks like an identifier. Using the stack top rather
213
+ // than a `depth` index keeps counting correct even when a malformed
214
+ // brace run has decoupled `depth` from the stack length.
196
215
  var prev = i > 0 ? query.charAt(i - 1) : "";
197
- if (/[A-Za-z0-9_]/.test(prev) && depth > 0) {
198
- aliasCounts[depth] += 1;
216
+ if (/[A-Za-z0-9_]/.test(prev) && aliasStack.length > 1) {
217
+ aliasStack[aliasStack.length - 1] += 1;
199
218
  }
200
219
  }
201
220
  }
202
221
  // Final sweep covers any unclosed selection-sets (operator-supplied
203
222
  // syntactically-invalid queries).
204
- for (var ai = 0; ai < aliasCounts.length; ai += 1) {
205
- if (aliasCounts[ai] > maxAliases) maxAliases = aliasCounts[ai];
223
+ for (var ai = 0; ai < aliasStack.length; ai += 1) {
224
+ if (aliasStack[ai] > maxAliases) maxAliases = aliasStack[ai];
206
225
  }
207
226
  return { maxDepth: maxDepth, maxAliases: maxAliases };
208
227
  }
@@ -1358,14 +1358,21 @@ function _requestWithRedirects(opts, hopsLeft) {
1358
1358
  headersStripped: headersStripped,
1359
1359
  method: nextMethod,
1360
1360
  });
1361
+ function _redirectAborted(e) {
1362
+ return Promise.reject(_makeError(opts.errorClass, "REDIRECT_ABORTED",
1363
+ "onRedirect hook refused redirect: " + ((e && e.message) || String(e)), true));
1364
+ }
1361
1365
  try {
1362
1366
  var hookResult = onRedirect(hookEvent);
1363
1367
  if (hookResult && typeof hookResult.then === "function") {
1364
- return hookResult.then(function () { return _continueFollow(); });
1368
+ // An async hook's rejection must abort the follow with the SAME
1369
+ // REDIRECT_ABORTED shape a sync throw produces — otherwise an
1370
+ // operator who awaits inside the hook (or returns a rejected
1371
+ // Promise) silently gets an un-coded raw rejection instead.
1372
+ return hookResult.then(function () { return _continueFollow(); }, _redirectAborted);
1365
1373
  }
1366
1374
  } catch (e) {
1367
- return Promise.reject(_makeError(opts.errorClass, "REDIRECT_ABORTED",
1368
- "onRedirect hook refused redirect: " + ((e && e.message) || String(e)), true));
1375
+ return _redirectAborted(e);
1369
1376
  }
1370
1377
  }
1371
1378
  return _continueFollow();
package/lib/mail-auth.js CHANGED
@@ -197,7 +197,7 @@ async function _safeResolveMx(qname, operatorLookup) {
197
197
  return entries.map(function (e) { return e.exchange; });
198
198
  }
199
199
 
200
- async function _safeReverse(ip) {
200
+ async function _safeReverse(ip, operatorLookup) {
201
201
  // PTR query against the reverse-arpa name. IPv4: a.b.c.d.in-addr.arpa
202
202
  // (reversed octets); IPv6: nibble-reversed under ip6.arpa.
203
203
  var qname = _ipToReverseArpa(ip);
@@ -206,6 +206,30 @@ async function _safeReverse(ip) {
206
206
  err.code = "ENOTFOUND";
207
207
  throw err;
208
208
  }
209
+ // Operator-supplied resolver: the callback receives the reverse-arpa
210
+ // qname (the same name a real resolver queries) and returns the
211
+ // documented flat PTR-name array. Threading it here makes the iprev
212
+ // forward-confirm path operator-mockable, matching the dnsLookup
213
+ // contract every other type in this file already honors.
214
+ if (operatorLookup) {
215
+ var resp = await operatorLookup(qname, "PTR");
216
+ if (!Array.isArray(resp) || resp.length === 0) {
217
+ var perr = new Error("no PTR records for " + ip);
218
+ perr.code = "ENODATA";
219
+ throw perr;
220
+ }
221
+ var names = [];
222
+ for (var pi = 0; pi < resp.length; pi += 1) {
223
+ var nm = String(resp[pi]).replace(/\.$/, "");
224
+ if (nm.length > 0) names.push(nm);
225
+ }
226
+ if (names.length === 0) {
227
+ var perr2 = new Error("no PTR records for " + ip);
228
+ perr2.code = "ENODATA";
229
+ throw perr2;
230
+ }
231
+ return names;
232
+ }
209
233
  var r = await _getDefaultResolver().query(qname, "PTR");
210
234
  var out = [];
211
235
  for (var i = 0; i < r.rrs.length; i += 1) {
@@ -2864,13 +2888,22 @@ function dmarcBuildAggregateReport(report, opts) {
2864
2888
  // Authentication-Results header so downstream policies can react.
2865
2889
  //
2866
2890
  // Surface:
2867
- // await b.mail.iprev.verify(ip)
2891
+ // await b.mail.iprev.verify(ip, opts?)
2868
2892
  // → { result: "pass"|"fail"|"permerror"|"temperror",
2869
2893
  // ptr, forward, fcrdns, ip }
2870
2894
  //
2871
- // Returns "permerror" on bad-shape input (not an IP literal); returns
2872
- // "temperror" on ENODATA / ENOTFOUND / lookup failure (the receiver
2873
- // retries on transient DNS faults). Pure-DNS no operator state.
2895
+ // Returns "permerror" on bad-shape input (not an IP literal, or a PTR
2896
+ // whose rdata isn't a valid DNS name). A definitive negative answer
2897
+ // (no PTR record, or the PTR forward-resolves to a set that omits the
2898
+ // connecting IP) is a "fail"; a transient resolver fault (SERVFAIL,
2899
+ // timeout, or any non-negative error code) is a "temperror" the
2900
+ // receiver retries on. Every path RETURNS a verdict object — the
2901
+ // primitive never throws on a message- or DNS-derived fault.
2902
+ //
2903
+ // DNS defaults to b.network.dns.resolver; an operator `opts.dnsLookup`
2904
+ // callback overrides it (same shape as the rest of this file — the PTR
2905
+ // query receives the reverse-arpa qname; the forward query the PTR
2906
+ // name), so a receiver can unit-test the forward-confirm decision.
2874
2907
 
2875
2908
  // RFC 8601 §3 — PTR result shape. The PTR rdata is an FQDN (1*labels).
2876
2909
  // Reject answers that aren't shaped as a DNS name: non-strings,
@@ -2897,7 +2930,10 @@ function _isValidPtrName(name) {
2897
2930
  return true;
2898
2931
  }
2899
2932
 
2900
- async function iprevVerify(ip) {
2933
+ async function iprevVerify(ip, opts) {
2934
+ opts = opts || {};
2935
+ validateOpts(opts, ["dnsLookup"], "mail.iprev.verify");
2936
+ var dnsLookup = opts.dnsLookup;
2901
2937
  if (typeof ip !== "string" || ip.length === 0) {
2902
2938
  return { result: "permerror", ip: ip || null,
2903
2939
  ptr: null, forward: [], fcrdns: false,
@@ -2910,7 +2946,7 @@ async function iprevVerify(ip) {
2910
2946
  }
2911
2947
 
2912
2948
  var ptrs;
2913
- try { ptrs = await _safeReverse(ip); }
2949
+ try { ptrs = await _safeReverse(ip, dnsLookup); }
2914
2950
  catch (e) {
2915
2951
  var rcode = e && e.code;
2916
2952
  if (rcode === "ENOTFOUND" || rcode === "ENODATA") {
@@ -2942,7 +2978,7 @@ async function iprevVerify(ip) {
2942
2978
  var isV6 = net.isIPv6(ip);
2943
2979
  var forwardAddrs;
2944
2980
  try {
2945
- forwardAddrs = await _safeResolveA(ptr, isV6 ? 6 : 4);
2981
+ forwardAddrs = await _safeResolveA(ptr, isV6 ? 6 : 4, dnsLookup);
2946
2982
  } catch (e) {
2947
2983
  var fcode = e && e.code;
2948
2984
  if (fcode === "ENOTFOUND" || fcode === "ENODATA") {
@@ -2950,23 +2986,32 @@ async function iprevVerify(ip) {
2950
2986
  ptr: ptr, forward: [], fcrdns: false,
2951
2987
  explanation: "no forward record for PTR " + ptr };
2952
2988
  }
2953
- if (fcode === "ETIMEOUT" || fcode === "ESERVFAIL") {
2954
- return { result: "temperror", ip: ip,
2955
- ptr: ptr, forward: [], fcrdns: false,
2956
- explanation: "forward lookup transient failure: " + fcode };
2957
- }
2958
- // Anything else propagate as temperror; Node DNS surfaces some
2959
- // non-RFC error codes via the platform resolver. Permerror only
2960
- // for definitive negative answers above.
2961
- throw new MailAuthError("mail-auth/iprev-temperror",
2962
- "iprev.verify: forward lookup of " + ptr + " threw: " +
2963
- ((e && e.message) || String(e)));
2989
+ // Every non-negative fault (SERVFAIL, timeout, or any other
2990
+ // platform-resolver code) is transient — RETURN a temperror verdict,
2991
+ // mirroring the reverse-lookup catch above. Throwing here broke the
2992
+ // primitive's contract that every message/DNS-derived fault surfaces
2993
+ // as a verdict object: a caller of the documented API got an
2994
+ // exception on e.g. an EREFUSED forward fault. Only the definitive
2995
+ // negative answers above (no forward record) are a "fail".
2996
+ return { result: "temperror", ip: ip,
2997
+ ptr: ptr, forward: [], fcrdns: false,
2998
+ explanation: "forward lookup of " + ptr + " transient failure: " +
2999
+ (fcode || (e && e.message) || String(e)) };
2964
3000
  }
2965
3001
  var forward = Array.isArray(forwardAddrs) ? forwardAddrs.slice() : [];
2966
- var ipLc = ip.toLowerCase();
3002
+ // RFC 8601 §3 forward-confirm match. An IPv6 address has many equivalent
3003
+ // textual forms (compressed `2001:db8::1` vs expanded
3004
+ // `2001:0db8:0:0:0:0:0:1`); the connecting literal and the AAAA rdata
3005
+ // routinely differ. Compare the CANONICAL form (fixed-width hex nibbles)
3006
+ // so an equivalent-but-differently-written IPv6 address still confirms.
3007
+ // IPv4 has a single canonical dotted form, so a lowercased string compare
3008
+ // is exact there.
3009
+ var ipCanon = isV6 ? ipUtils.expandIpv6Hex(ip) : ip.toLowerCase();
2967
3010
  var fcrdns = false;
2968
3011
  for (var i = 0; i < forward.length; i += 1) {
2969
- if (String(forward[i]).toLowerCase() === ipLc) { fcrdns = true; break; }
3012
+ var fwdStr = String(forward[i]);
3013
+ var fwdCanon = isV6 ? ipUtils.expandIpv6Hex(fwdStr) : fwdStr.toLowerCase();
3014
+ if (ipCanon && fwdCanon && fwdCanon === ipCanon) { fcrdns = true; break; }
2970
3015
  }
2971
3016
  return {
2972
3017
  result: fcrdns ? "pass" : "fail",
@@ -132,7 +132,15 @@ function _coerceValue(raw) {
132
132
  return n;
133
133
  }
134
134
  if (/^-?\d+\.\d+([eE][+-]?\d+)?$/.test(raw) || /^-?\d+[eE][+-]?\d+$/.test(raw)) {
135
- return Number(raw);
135
+ var f = Number(raw);
136
+ // Float overflow (e.g. 1e999) coerces to ±Infinity — the same
137
+ // never-silently-coerce refusal the integer/hex branches enforce with
138
+ // Number.isSafeInteger. An Infinity slipping into a downstream size cap
139
+ // or timeout is a live DoS, so an out-of-range float is rejected here.
140
+ if (!Number.isFinite(f)) {
141
+ throw _err("ini/value-out-of-range", "float exceeds representable range: " + raw);
142
+ }
143
+ return f;
136
144
  }
137
145
  return _unquote(raw);
138
146
  }
@@ -378,6 +378,15 @@ async function poll(opts) {
378
378
  allowedHosts: opts.allowedHosts,
379
379
  allowedProtocols: opts.allowedProtocols,
380
380
  allowInternal: opts.allowInternal,
381
+ // poll() owns status handling — the branches below distinguish a 304
382
+ // If-None-Match "fast no-update" hit from a real non-2xx refusal and a
383
+ // 2xx feed to parse. Without always-resolve, httpClient.request rejects
384
+ // EVERY non-2xx (304 included) as HTTP_ERROR before poll can inspect
385
+ // res.statusCode, which made the documented conditional-poll fast-path
386
+ // and the selfupdate/poll-non-2xx branch dead code — a conditional poll
387
+ // that correctly received a 304 threw selfupdate/poll-failed instead of
388
+ // reporting "no update".
389
+ responseMode: "always-resolve",
381
390
  errorClass: SelfUpdateError,
382
391
  });
383
392
  } catch (e) {
@@ -630,6 +639,15 @@ function _validateSwapOpts(opts, label) {
630
639
  "selfUpdate.swap: opts.hashAlgo must be one of " + ALLOWED_HASH_ALGS.join(", "));
631
640
  }
632
641
  };
642
+ // swap re-reads the from-bytes to re-hash them (closing the verify->swap
643
+ // window); its cap must be declarable so it matches the maxBytes an
644
+ // operator passed to selfUpdate.verify for the same asset — otherwise swap
645
+ // would refuse a large binary that verify accepted. Optional; defaults to
646
+ // the same C.BYTES.gib(1) cap the body applies.
647
+ schema.maxBytes = function (value) {
648
+ numericBounds.requirePositiveFiniteIntIfPresent(value,
649
+ "selfUpdate.swap: opts.maxBytes", SelfUpdateError, "selfupdate/bad-max-bytes");
650
+ };
633
651
  }
634
652
  schema.to = { rule: "required-string", code: "selfupdate/bad-to",
635
653
  label: "selfUpdate." + label + ": opts.to" };
@@ -696,6 +714,8 @@ async function _safeRollback(backupTo, to, hadOriginal) {
696
714
  * backupTo: string, // required — backup path for the existing `to`
697
715
  * expectedHash: string, // required — the hash selfUpdate.verify returned
698
716
  * hashAlgo: string, // sha3-512 (default) | sha-256 | sha-512 | shake256
717
+ * maxBytes: number, // from-bytes re-hash cap (default 1 GiB) — set to
718
+ * // the same value passed to selfUpdate.verify
699
719
  *
700
720
  * @example
701
721
  * var v = await b.selfUpdate.verify({ assetPath, signaturePath, pubkeyPem });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.16.16",
3
+ "version": "0.16.18",
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:4536f3b2-ce54-4f57-930f-5250f9a34d41",
5
+ "serialNumber": "urn:uuid:1786ee2a-259a-4966-b764-11772b484854",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-12T04:34:36.482Z",
8
+ "timestamp": "2026-07-12T08:00:39.980Z",
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.16.16",
22
+ "bom-ref": "@blamejs/core@0.16.18",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.16.16",
25
+ "version": "0.16.18",
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.16.16",
29
+ "purl": "pkg:npm/%40blamejs/core@0.16.18",
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.16.16",
57
+ "ref": "@blamejs/core@0.16.18",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]