@blamejs/core 0.15.6 → 0.15.7

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,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.15.x
10
10
 
11
+ - v0.15.7 (2026-06-13) — **Hardens the new URL canonicalizer against an IPv4-mapped allowlist bypass, enforces the OIDC azp authorized-party check, and closes a set of audited correctness gaps in retention, credential rehashing, the scheduler, and SD-JWT key binding.** This release deepens the URL/host canonicalizer shipped in 0.15.6 and clears a batch of audited correctness gaps. The canonicalizer now folds an IPv4-mapped IPv6 address (::ffff:1.2.3.4) to its embedded IPv4 and strips every trailing dot from a host, so a dual-stack peer can no longer slip past an operator's dotted-IPv4 allowlist and host./host.. no longer evade a host comparison. (NAT64 and 6to4 hosts are deliberately kept as IPv6 so canonicalizing then classifying agrees with the SSRF classifier, which treats them as reserved.) On the OIDC side, verifyIdToken now enforces OIDC Core 3.1.3.7: a multi-audience ID token must carry an azp (authorized party) and a present azp must equal the client_id, closing a confused-deputy hole where a token minted for a different client but listing this RP in its audience array verified clean. The rest are audited fixes: retention.complianceFloor now honors the active posture set via applyPosture (the documented inheritance was unimplemented); credentialHash.needsRehash now drives the advertised SHAKE256 length-rotation (raising the output length now triggers a rehash, upgrade-only); the task scheduler no longer lets a run abandoned by its watchdog clobber the next run's state or emit a stale success when its slow promise settles late; and the SD-JWT key-binding JWT compares its audience and nonce in constant time. **Fixed:** *retention.complianceFloor honors the active compliance posture* — `b.retention.complianceFloor` required an explicit posture and never read the active posture set by `applyPosture` (the `b.compliance.set` cascade), so the documented inheritance was unimplemented dead state. It now inherits the active posture when none is passed — `complianceFloor(candidateTtlMs)` uses the active posture, an explicit posture still overrides it — and `applyPosture(null)` now clears the active posture (it was a silent no-op, so `b.compliance.clear` could not reset it). · *credentialHash.needsRehash drives the SHAKE256 length-rotation* — `b.credentialHash.needsRehash` never compared the stored SHAKE256 digest length against the configured length, so raising the output length never triggered a rehash and the advertised length-rotation was a silent no-op. It now flags a digest shorter than the configured/default length for rehash (upgrade-only — a longer-than-target digest is never shortened, matching the Argon2 convention). · *The scheduler no longer lets a watchdog-abandoned run corrupt the next run* — `b.scheduler`'s watchdog force-clears a task's running flag after `maxJobMs` so a hung handler can't lock out future fires, and the next tick re-fires. The original slow promise then settled late and unconditionally overwrote the task's running / lastFinish / lastError state and emitted a `system.scheduler.task.success`|`failure` for a run the watchdog had already given up on — clobbering the new run and double-counting. Each run is now tagged with a generation that the watchdog and every fire bump; a settle whose tag is stale is ignored. **Security:** *The URL canonicalizer folds IPv4-mapped IPv6 addresses to IPv4* — `b.safeUrl.canonicalize` / `b.ssrfGuard.canonicalizeHost` now fold an IPv4-mapped IPv6 address (`::ffff:1.2.3.4`, the `::ffff:0:0/96` block) to its embedded IPv4 dotted form. Previously it canonicalized to an IPv6 string, so a dual-stack peer never unified with an operator's `1.2.3.4` allow/deny entry — an allowlist bypass. Only the IPv4-mapped block folds, because the SSRF classifier maps it to the embedded v4 verdict directly; NAT64 (`64:ff9b::/96`) and 6to4 (`2002::/16`) are deliberately kept as IPv6, since the classifier treats a NAT64 literal as reserved and folding it would turn a blocked verdict into an allowed public IPv4. The host canonicalizer also now strips every trailing dot, so `host`, `host.`, and `host..` collapse to one form. · *verifyIdToken enforces the OIDC azp (authorized party) check* — `b.auth.oauth`'s `verifyIdToken` validated only that its `client_id` was present in the token's `aud`, ignoring `azp`. Per OIDC Core 3.1.3.7, a multi-audience ID token must carry an `azp` and a present `azp` must equal the RP's `client_id`. Without it, a token whose authorized party is a different client — but whose `aud` array also lists this RP — verified clean (a confused-deputy / token-substitution hole). The verifier now rejects a multi-audience token with no `azp` (`auth-oauth/azp-required`) and any token whose `azp` is not the `client_id` (`auth-oauth/azp-mismatch`). A single-audience token with no `azp` (the common case) is unaffected. · *SD-JWT key-binding audience and nonce compare in constant time* — `b.auth.sdJwtVc.verify` compared the key-binding JWT's `aud` and `nonce` with a short-circuiting `!==`, while the adjacent `sd_hash` check already used a constant-time compare. The `nonce` is a verifier-issued replay-defense value, so a non-constant-time compare leaks a matching-prefix timing oracle; both checks now use the constant-time helper.
12
+
11
13
  - v0.15.6 (2026-06-12) — **Closes SAML and OIDC assertion-replay windows, bounds SSE memory under a slow client, restores at-least-once delivery for a crashed outbox publisher, makes sealed-column membership queries work, ships JOSE-conformant SD-JWT signatures, and adds a URL canonicalizer for SSRF-safe comparison.** A security and correctness release. On the identity surface: a SAML Response whose Bearer (or Holder-of-Key) SubjectConfirmation omits NotOnOrAfter is now rejected instead of accepted as fresh-forever, and the OIDC ID-token verifier no longer lets a caller disable expiry validation on a normal token - the exp bypass is restricted to back-channel-logout tokens and bounded by an issued-at freshness floor. SD-JWT-VC ES256/ES384 signatures are now emitted as JOSE raw r||s, so credentials this issuer signs verify in conformant wallets and verifiers. A new b.safeUrl.canonicalize (and b.ssrfGuard.canonicalizeHost) collapses obfuscated host and IP forms to one canonical string so allowlist and SSRF comparisons can't be bypassed by encoding tricks. On the reliability side: server-sent-event channels now cap their per-connection outbound buffer and evict a stalled client instead of growing the heap without bound; the outbox reclaims a job left in-flight by a publisher that crashed mid-delivery; the background worker pool no longer drops a task queued behind one that timed out; a retention preview no longer rewrites the whole database file; an equality / membership query on an encrypted column now hashes each candidate (membership queries previously failed outright); and the on-read re-hash of a legacy lookup digest now runs on Postgres and MySQL handles, not only SQLite. **Fixed:** *SD-JWT-VC ES256 / ES384 signatures are JOSE-conformant* — `b.auth.sdJwtVc` signed and verified ES256 / ES384 credentials with node:crypto's default DER ECDSA encoding instead of the raw r||s (`ieee-p1363`) form JOSE and EUDI wallets require, so a credential this issuer signed was rejected by conformant verifiers and the library rejected conformant holders' key-binding JWTs. The issuer JWT and the holder KB-JWT now both sign and verify with `ieee-p1363`, matching the rest of the framework's JOSE signers. · *Membership queries on an encrypted column now work* — Querying an encrypted (sealed) column with `IN` / `$in` - `b.db.from(table).whereIn("email", [...])` or `b.db.collection(table).find({ email: { $in: [...] } })` - threw, because the sealed-field-to-derived-hash rewrite passed the whole candidate array to the hash lookup as a single value. Each candidate is now hashed individually and matched against both its active keyed digest and its legacy digest, so membership queries on an encrypted column return the right rows, including rows written before the lookup-hash default changed. · *A timing-out background task no longer drops the task queued behind it* — When a `b.workerPool` task timed out or its worker errored, the slot was returned to the idle pool and drained one moment before it was marked for recycling, so a task queued behind it could be dispatched to the worker about to be terminated - and came back as `workerpool/worker-exit` (or hung) instead of running. The slot is now marked recycling before the queue is drained, so the queued task waits for the replacement worker. · *The outbox recovers a job stranded by a crashed publisher* — `b.outbox` claims a row by flipping it to in-flight, but the claim scan only selected pending rows, so a publisher that crashed between claiming a row and recording delivery left that row in-flight forever - the event was silently dropped, breaking at-least-once delivery. The outbox now stamps each claim with a timestamp and, at the start of every poll, returns any in-flight row older than the claim lease (`claimReclaimMs`, default 5 minutes) to the pending pool so it is delivered. An existing outbox table gains the new column automatically. · *A retention preview no longer rewrites the whole database* — Previewing a retention rule with `b.retention` (`run(name, { dryRun: true })` or the `retention preview` command) ran a full database VACUUM for every candidate row under a regulated posture (gdpr / hipaa / and similar), because the per-row erase - which schedules the vacuum - ran before the dry-run check. A preview now computes what it would erase without touching the database; the vacuum runs only on a real erase. · *On-read lookup-digest upgrade runs on Postgres and MySQL* — When `b.cryptoField.unsealRow` re-hashed a legacy lookup digest to the current keyed form and persisted it, the UPDATE was always built for SQLite, so on a Postgres or MySQL handle the durable rewrite quoted identifiers for the wrong dialect and silently no-opped - the legacy digest stayed on disk and the migration never completed off SQLite. The rewrite now uses the handle's own dialect. **Security:** *SAML SubjectConfirmation without NotOnOrAfter is rejected* — `b.auth.saml` SP response verification treated the `NotOnOrAfter` attribute on a Bearer SubjectConfirmationData as optional: a confirmation that omitted it was accepted with no upper bound on the assertion's freshness - a captured assertion replayable indefinitely. SAML 2.0 Web Browser SSO Profile §4.1.4.2 requires Bearer confirmations to carry NotOnOrAfter. `verifyResponse` now rejects a confirmation that is missing NotOnOrAfter, has an unparseable value, or is expired; the Holder-of-Key path (Profile §3.1) is hardened the same way, including the previously-accepted unparseable-value case. · *ID-token expiry can no longer be disabled on a normal token* — `b.auth.oauth`'s `verifyIdToken` honored a `skipExpCheck` option with no constraint, so any caller could verify an expired - or replayed - ID token. That option exists only for OIDC Back-Channel Logout tokens, which carry no `exp`. It is now self-guarding: `verifyIdToken` rejects `skipExpCheck` (`auth-oauth/skip-exp-check-not-allowed`) on any token that lacks the back-channel-logout event claim, and even for a logout token it enforces an `iat` freshness floor (`auth-oauth/logout-token-stale`). The internal logout path is unaffected. · *Server-sent-event channels bound their outbound buffer* — An SSE channel wrote to the response with no regard for backpressure and no cap on buffered bytes, so a single stalled client could make the server buffer events until the heap was exhausted (a memory-exhaustion denial of service). Each channel now tracks its unflushed-byte count and, past a per-channel cap (`maxBufferedBytes`, default 1 MiB), closes the connection and throws `sse/backpressure` - evicting the slow consumer instead of buffering without limit. A client that keeps up is never affected. · *URL and host canonicalizer for SSRF-safe comparison* — New `b.safeUrl.canonicalize(url, opts?)` and `b.ssrfGuard.canonicalizeHost(host)` return the canonical, comparable form of a URL or host: scheme and host lowercased, IDN hosts emitted as their punycode A-label (a confusable / mixed-script host is rejected), every base of an IP literal (decimal, octal, hex, dotted, IPv4-mapped and zero-compressed IPv6) collapsed to one canonical address, default ports stripped, trailing-dot hosts normalized, and path percent-encoding normalized per RFC 3986. Use it to build host allowlists, deduplicate URLs, or compare a fetch target so an allowlist check can't be bypassed by encoding the same address a different way.
12
14
 
13
15
  - v0.15.5 (2026-06-12) — **Legal-hold and subject-restriction PII is sealed at rest, and a guard's compliance-posture forensic and runtime caps are applied on its default gate.** This release closes two data-protection gaps. The legal-hold registry and the subject-restriction flag stored their free-text fields - the legal basis, custodian, ticket citation, and restriction reason that link a data subject to a legal matter - in clear, because those local tables were written through a raw SQL path that bypassed the structured builder's at-rest sealing. Those columns are now sealed on write and unsealed on read, the same way the DSR ticket store already seals subject identifiers. Separately, a content guard built on the standard gate contract and gated with a compliance posture (for example b.guardCidr.gate({ compliancePosture: "hipaa" })) silently dropped that posture's forensic-snapshot cap and the profile's runtime cap, because the default gate passed the caller's raw options straight to the gate builder instead of resolving the profile and posture first - so a regulated-posture refusal carried no forensic evidence. The default gate now resolves the profile and posture before building the gate, matching the hand-written guard gates. **Security:** *Legal-hold and subject-restriction PII is sealed at rest* — `b.legalHold`'s `_blamejs_legal_hold` registry stored the hold reason, custodian, placed-by, and citation in clear, and `b.subject.restrict`'s `_blamejs_subject_restrictions` stored the restriction reason in clear - free text that ties a data subject to a litigation hold or an Art. 18 processing restriction. Those rows were written through a raw `sql.insert` + `prepare().run()` path that bypassed the structured builder's automatic field sealing (the subject-restrictions table even declared the field as sealed, but the raw write never applied it). Both now seal those columns on write and unseal on read through `cryptoField`, so the legal-basis and custodian text is encrypted at rest under the deployment's vault key. Pre-existing plaintext rows continue to read correctly (the unseal path passes through an already-plaintext value). · *A guard's default gate applies its compliance-posture forensic and runtime caps* — A guard built on `b.gateContract.defineGuard` with the standard gate (no bespoke gate) and gated with a compliance posture dropped that posture's `forensicSnippetBytes` cap and the profile's `maxRuntimeMs` cap: the default gate passed the caller's raw options straight to the gate builder, which reads those caps directly, but the values live on the resolved profile and posture, not the raw options. The effect was that a regulated-posture refusal captured no forensic snapshot (the cap defaulted to 0, i.e. disabled) and the check ran without the profile's runtime bound. The default gate now resolves the profile and posture before building the gate - matching the hand-written guard gates - so `gate({ compliancePosture: "hipaa" })` applies the posture's forensic cap and the profile's runtime cap as documented.
package/MIGRATING.md CHANGED
@@ -14,6 +14,18 @@ The framework has no `deprecate()`-marked surface awaiting removal.
14
14
 
15
15
  Listed newest-first.
16
16
 
17
+ ### v0.15.7 — `b.auth.oauth verifyIdToken — azp (authorized party) is now enforced`
18
+
19
+ verifyIdToken now applies OIDC Core 3.1.3.7: a multi-audience ID token (aud is an array with more than one entry) MUST carry an azp claim, and a present azp MUST equal the configured client_id. A token whose azp is a different client, or a multi-audience token with no azp, now throws (auth-oauth/azp-mismatch / auth-oauth/azp-required). Previously only `aud contains client_id` was checked, so a token authorized for a different party but also listing this RP verified clean.
20
+
21
+ No change for the common single-audience ID token with no azp. If your IdP issues multi-audience ID tokens, ensure it sets azp to your client_id (it should, per the spec) — otherwise verifyIdToken will now reject them. This is a security fix; a token that fails the new check was authorized for a different client.
22
+
23
+ ### v0.15.7 — `b.safeUrl.canonicalize — IPv4-mapped hosts fold to IPv4`
24
+
25
+ b.safeUrl.canonicalize / b.ssrfGuard.canonicalizeHost now fold an IPv4-mapped IPv6 host (::ffff:1.2.3.4) to its embedded IPv4 dotted form, and strip every trailing dot from a host. In 0.15.6 it canonicalized to an IPv6 string and only one trailing dot was stripped. NAT64 / 6to4 hosts stay IPv6.
26
+
27
+ No code change is needed — this makes a dual-stack / NAT64 peer unify with a dotted-IPv4 allow/deny entry as intended. If you persisted canonical host strings produced by 0.15.6 (e.g. as cache or dedup keys) and compare them against freshly-canonicalized hosts, recompute them: an IPv4-mapped host now yields the dotted IPv4 instead of the bracketed IPv6, and a multi-trailing-dot host yields the bare name.
28
+
17
29
  ### v0.15.6 — `b.auth.sdJwtVc — ES256 / ES384 signatures are now JOSE raw r||s, not DER`
18
30
 
19
31
  `b.auth.sdJwtVc` now signs and verifies ES256 / ES384 with `dsaEncoding: "ieee-p1363"` (raw r||s), the encoding JOSE / JWS and EUDI wallets require. Previously it used node:crypto's default DER ECDSA encoding, so a credential this issuer signed was rejected by conformant verifiers and the library rejected conformant holders' key-binding JWTs. The signature bytes change shape (64 bytes for ES256, 96 for ES384, no leading `0x30` SEQUENCE tag).
@@ -19,8 +19,12 @@
19
19
  * iat / exp / jti
20
20
  *
21
21
  * Tokens are revocable: revoke(jti) adds the jti to an in-process
22
- * deny-set checked by verify(). Operators with multi-node clusters
23
- * pass `revokedSet` opt to back the deny-set with their own KV.
22
+ * deny-set checked by verify(). Revocation is in-process only and does
23
+ * NOT propagate across cluster nodes a jti revoked on one node is not
24
+ * denied on another. On a multi-node deployment, keep grant TTLs short
25
+ * (the iat/exp window is the cross-node bound) and route revocation
26
+ * through a shared, externally-checked store of your own; this module
27
+ * does not yet accept a backing revoked-set.
24
28
  *
25
29
  * Token format: base64url(JSON-payload) + "." + base64url(HMAC).
26
30
  *
package/lib/auth/oauth.js CHANGED
@@ -1896,6 +1896,19 @@ function create(opts) {
1896
1896
  throw new OAuthError("auth-oauth/aud-mismatch",
1897
1897
  "ID token aud does not contain clientId '" + clientId + "'");
1898
1898
  }
1899
+ // OIDC Core §3.1.3.7: a multi-audience ID token MUST carry an azp
1900
+ // (authorized party), and a present azp MUST equal our client_id.
1901
+ // Without this, a token whose authorized party is a DIFFERENT client but
1902
+ // whose aud array also lists this RP would verify clean — a confused-deputy
1903
+ // / token-substitution hole.
1904
+ if (aud.length > 1 && typeof payload.azp !== "string") {
1905
+ throw new OAuthError("auth-oauth/azp-required",
1906
+ "ID token has multiple audiences but no azp (authorized party) claim");
1907
+ }
1908
+ if (payload.azp !== undefined && payload.azp !== clientId) {
1909
+ throw new OAuthError("auth-oauth/azp-mismatch",
1910
+ "ID token azp '" + payload.azp + "' is not clientId '" + clientId + "'");
1911
+ }
1899
1912
  if (vopts.nonce && !vopts.skipNonceCheck) {
1900
1913
  // Constant-time nonce compare — secret-shaped value matched
1901
1914
  // against attacker-controlled payload.
@@ -638,11 +638,14 @@ async function verify(presentation, opts) {
638
638
  jwtExternal._assertAlgKtyMatch(kbAlg, holderKey);
639
639
  var holderKeyObj = nodeCrypto.createPublicKey({ key: holderKey, format: "jwk" });
640
640
  var kbParsed = _verifyJwt(maybeKbJwt, holderKeyObj, kbAlg);
641
- if (opts.audience && kbParsed.payload.aud !== opts.audience) {
641
+ // Constant-time compares: the nonce is a verifier-issued replay-defense
642
+ // value, so a short-circuiting !== leaks a matching-prefix timing oracle.
643
+ // Matches the sd_hash check below (the framework's hash/token discipline).
644
+ if (opts.audience && !_timingSafeEqStr(kbParsed.payload.aud, opts.audience)) {
642
645
  throw new AuthError("auth-sd-jwt-vc/wrong-audience",
643
646
  "verify: KB-JWT aud mismatch");
644
647
  }
645
- if (opts.nonce && kbParsed.payload.nonce !== opts.nonce) {
648
+ if (opts.nonce && !_timingSafeEqStr(kbParsed.payload.nonce, opts.nonce)) {
646
649
  throw new AuthError("auth-sd-jwt-vc/wrong-nonce",
647
650
  "verify: KB-JWT nonce mismatch (replay defense)");
648
651
  }
package/lib/compliance.js CHANGED
@@ -594,6 +594,10 @@ function clear() {
594
594
  }
595
595
  STATE.posture = null;
596
596
  STATE.setAt = null;
597
+ // Cascade the reset the same way set() cascades the posture — otherwise a
598
+ // primitive that inherits the active posture (e.g. retention.complianceFloor)
599
+ // keeps applying the stale floor after the global posture was cleared.
600
+ _applyPostureCascade(null);
597
601
  }
598
602
 
599
603
  function _resetForTest() {
@@ -380,6 +380,15 @@ function needsRehash(envelope, opts) {
380
380
  try { return passwordModule().needsRehash(phc, opts && opts.params); }
381
381
  catch (_e) { return true; }
382
382
  }
383
+ if (decoded.algoId === C.CRED_HASH_IDS.SHAKE256) {
384
+ // Length-rotation: rehash when the stored digest is SHORTER than the
385
+ // configured/default output length. Upgrade-only (`<`, matching the Argon2
386
+ // needsRehash convention) — a longer-than-target digest is not actively
387
+ // shortened. Without this compare, raising the SHAKE256 length never
388
+ // triggered a rehash and the advertised rotation was a silent no-op.
389
+ var targetLength = (opts && opts.params && opts.params.length) || SHAKE256_DEFAULT_LENGTH;
390
+ if (decoded.payload.length < targetLength) return true;
391
+ }
383
392
  return false;
384
393
  }
385
394
 
package/lib/retention.js CHANGED
@@ -614,9 +614,16 @@ var COMPLIANCE_RETENTION_FLOOR_MS = Object.freeze({
614
614
  * // → 220752000000 (Sarbanes-Oxley §802 — 7 years)
615
615
  */
616
616
  function complianceFloor(posture, candidateTtlMs) {
617
+ // Optional posture: omit it to inherit the active posture recorded by
618
+ // applyPosture (the b.compliance.set cascade). A numeric first argument is
619
+ // taken as candidateTtlMs so complianceFloor(ttl) works; an explicit posture
620
+ // always overrides the active one.
621
+ if (typeof posture === "number") { candidateTtlMs = posture; posture = undefined; }
622
+ if (posture === undefined || posture === null) { posture = STATE.activePosture; }
617
623
  if (typeof posture !== "string") {
618
624
  throw new RetentionError("retention/bad-posture",
619
- "complianceFloor: posture must be a string, got " + JSON.stringify(posture));
625
+ "complianceFloor: posture must be a string (pass one, or set the active " +
626
+ "posture via applyPosture / b.compliance.set), got " + JSON.stringify(posture));
620
627
  }
621
628
  var floor = COMPLIANCE_RETENTION_FLOOR_MS[posture];
622
629
  if (floor === undefined) {
@@ -660,7 +667,14 @@ function complianceFloor(posture, candidateTtlMs) {
660
667
  * // → "hipaa"
661
668
  */
662
669
  function applyPosture(posture) {
663
- if (typeof posture !== "string" || posture.length === 0) return null;
670
+ if (typeof posture !== "string" || posture.length === 0) {
671
+ // Clear the active posture (the inverse of a set) so b.compliance.clear
672
+ // and operators can reset the inherited floor; complianceFloor then falls
673
+ // back to requiring an explicit posture again.
674
+ STATE.activePosture = null;
675
+ STATE.activeFloorMs = null;
676
+ return null;
677
+ }
664
678
  var floor = COMPLIANCE_RETENTION_FLOOR_MS[posture];
665
679
  STATE.activePosture = posture;
666
680
  STATE.activeFloorMs = (typeof floor === "number") ? floor : null;
package/lib/scheduler.js CHANGED
@@ -488,6 +488,10 @@ function create(opts) {
488
488
  lastError: null,
489
489
  running: false,
490
490
  runningSince: 0,
491
+ // Monotonic run tag. The watchdog and each fire bump it, so a run the
492
+ // watchdog abandoned can't clobber state / emit a stale settle event
493
+ // when its slow promise finally resolves.
494
+ runGeneration: 0,
491
495
  fires: 0,
492
496
  misses: 0, // skipped because previous run still in-flight
493
497
  nonLeaderSkips: 0,
@@ -540,6 +544,8 @@ function create(opts) {
540
544
  (maxJobMs / C.TIME.seconds(1)) + "s — forcing reset");
541
545
  } catch (_e) { /* logger best-effort */ }
542
546
  _emit("system.scheduler.task.watchdog", { name: task.name }, "failure");
547
+ // Supersede the abandoned run so its late settle is ignored.
548
+ task.runGeneration++;
543
549
  task.running = false;
544
550
  } else {
545
551
  task.misses++;
@@ -665,6 +671,10 @@ function create(opts) {
665
671
  task.runningSince = Date.now();
666
672
  task.lastRun = new Date().toISOString();
667
673
  var startedAt = Date.now();
674
+ // Tag this run. The settle handlers below only write back if the tag still
675
+ // matches — so a run the watchdog reset (or a newer fire) can't clobber the
676
+ // current run's state or emit a stale success/failure when it settles late.
677
+ var gen = (task.runGeneration = (task.runGeneration || 0) + 1);
668
678
 
669
679
  var promise;
670
680
  try {
@@ -678,6 +688,7 @@ function create(opts) {
678
688
  }
679
689
 
680
690
  Promise.resolve(promise).then(function (_v) {
691
+ if (task.runGeneration !== gen) return; // watchdog/newer fire superseded this run
681
692
  task.running = false;
682
693
  task.runningSince = 0;
683
694
  task.lastFinish = new Date().toISOString();
@@ -689,6 +700,7 @@ function create(opts) {
689
700
  viaJob: !!task.job,
690
701
  });
691
702
  }, function (e) {
703
+ if (task.runGeneration !== gen) return; // watchdog/newer fire superseded this run
692
704
  task.running = false;
693
705
  task.runningSince = 0;
694
706
  task.lastFinish = new Date().toISOString();
package/lib/ssrf-guard.js CHANGED
@@ -335,14 +335,32 @@ function canonicalizeHost(host) {
335
335
  return bare.toLowerCase();
336
336
  }
337
337
  if (family === 6) {
338
- return _ipv6BytesToString(_ipv6ToBytes(bare));
339
- }
340
- // Not an IP literal DNS name. Lowercase + strip a single trailing dot
341
- // (the root-label dot is DNS-equivalent but breaks string comparison).
342
- var name = bare.toLowerCase();
343
- if (name.length > 1 && name.charAt(name.length - 1) === ".") {
344
- name = name.slice(0, name.length - 1);
338
+ var v6bytes = _ipv6ToBytes(bare);
339
+ // An IPv4-mapped IPv6 address (::ffff:a.b.c.d, the ::ffff:0:0/96 block) IS
340
+ // the IPv4 address a.b.c.d for routing / access control classify() already
341
+ // re-classifies it by the embedded v4, and a dual-stack peer arriving on
342
+ // ::ffff:1.2.3.4 reaches the same host as 1.2.3.4. Fold it to the dotted
343
+ // IPv4 form so a dual-stack peer and an operator's IPv4 allowlist entry
344
+ // canonicalize equal. ONLY the IPv4-mapped block (::ffff:0:0/96) folds,
345
+ // because classify(::ffff:x) === classify(x) — its classify branch returns
346
+ // the embedded-v4 verdict with no reserved fallback, so folding can't change
347
+ // an SSRF verdict. NAT64 (64:ff9b::/96) and 6to4 (2002::/16) are NOT folded:
348
+ // classify treats a NAT64 literal as `classify(v4) || "reserved"`, so a
349
+ // public NAT64 address classifies as "reserved" while its embedded v4 is
350
+ // null — folding would flip a blocked verdict to an allowed public IPv4.
351
+ // classify still reaches the embedded v4 for the deny side; the canonical
352
+ // form keeps NAT64 / 6to4 as IPv6 so canonicalize-then-classify agrees with
353
+ // classify alone.
354
+ if (_ipv6PrefixMatch(IPV6_V4_MAPPED_PREFIX, C.BYTES.bytes(96), v6bytes)) {
355
+ return v6bytes[12] + "." + v6bytes[13] + "." + v6bytes[14] + "." + v6bytes[15];
356
+ }
357
+ return _ipv6BytesToString(v6bytes);
345
358
  }
359
+ // Not an IP literal — DNS name. Lowercase + strip ALL trailing dots: a
360
+ // hostname's trailing-dot count is not significant for identity (the root
361
+ // label is empty), so host / host. / host.. must collapse to one form or a
362
+ // trailing-dot count bypasses a host allow/deny comparison.
363
+ var name = bare.toLowerCase().replace(/\.+$/, "");
346
364
  return name;
347
365
  }
348
366
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.15.6",
3
+ "version": "0.15.7",
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:05cef77c-2364-4c63-8af3-3dd59f5dd56f",
5
+ "serialNumber": "urn:uuid:af262da2-b0f7-4f93-bb1e-8c6cb863a378",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-06-13T09:12:01.883Z",
8
+ "timestamp": "2026-06-13T17:27:38.127Z",
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.15.6",
22
+ "bom-ref": "@blamejs/core@0.15.7",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.15.6",
25
+ "version": "0.15.7",
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.15.6",
29
+ "purl": "pkg:npm/%40blamejs/core@0.15.7",
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.15.6",
57
+ "ref": "@blamejs/core@0.15.7",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]