@blamejs/core 0.15.61 → 0.15.62

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.62 (2026-06-29) — **ARC evaluation now reads each hop's instance with the same strict parser the signature checks use, so a crafted ARC-Authentication-Results header can no longer forge the upstream auth-results surfaced to downstream policy.** b.mail.arc.evaluate returns finalAr — the most recent hop's ARC-Authentication-Results, the receiver's view of the upstream authentication results — which operators may key downstream decisions on. The instance tag (i=) on each ARC header was parsed by three different regexes: the indexing pass that drives the AMS/AS signature checks required i= with no surrounding space and at most three digits, while the finalAr extraction and the AMS header-retention test accepted a looser form (a space around =, unbounded digits). When a sealer signs without covering ARC-Authentication-Results in its AMS h= (permitted by RFC 8617 and supported by the verifier), an attacker holding no key could append a second ARC-Authentication-Results written so the strict pass ignored it while the loose pass consumed it — forging finalAr on a chain that still verified as pass. All ARC instance reads now route through one strict parser, so the evaluation surfaces the same hop the signatures validated. The release also repairs the b.mail.arc.sign excludeAarFromAms option (it was read but rejected by option validation, so the documented opt-out was unreachable) and routes the ARC-Seal signature's b= stripping through the shared tag-aware helper. **Fixed:** *ARC finalAr is read from the strictly-indexed hop, not a looser rescan* — b.mail.arc.evaluate extracted finalAr (and validated the per-hop AMS header retention) with a regex that accepted ARC instance tags the signature-indexing pass rejected — a space around i= or more than three digits. A sealer that omits ARC-Authentication-Results from its AMS h= leaves the AAR outside signature coverage; an attacker could then inject a second ARC-Authentication-Results whose instance the strict crypto pass skipped but the finalAr pass accepted, presenting attacker-chosen upstream auth-results on a chain that still reported pass. Every ARC instance read now goes through a single strict parser, so finalAr is always the AAR the chain's signatures actually covered. · *b.mail.arc.sign accepts excludeAarFromAms again* — The excludeAarFromAms option was read when building the AMS h= list but was missing from the function's option allow-list, so passing it raised an unknown-option error — the documented opt-out could not be used. It is now accepted. · *ARC-Seal b= stripping uses the shared tag-aware helper* — The ARC-Seal verification stripped the signature's b= value with a regex that could mis-zero a value containing b= inside another tag; it now uses the same tag-aware stripper as DKIM, so canonicalization matches the signer in every case. **Detectors:** *ARC instance parsing must use the shared strict reader* — A check fails the build if any ARC instance (i=) parsing regex is added outside the single shared reader, preventing a future divergence between the signature-indexing pass and the finalAr / header-retention passes.
12
+
11
13
  - v0.15.61 (2026-06-29) — **The local and Redis job queues fence completion, failure, and lease extension on the lease the caller actually holds, so a worker finishing after its lease expired can no longer disturb a job another worker has since taken over.** On the local and Redis queue backends, complete(), fail(), and extendLease() identified a job only by its id. When a worker's lease expired, the sweep returned the job to the ready set and another worker leased and began running it; if the original worker then finished late, its complete() could mark the new worker's in-progress job done (and double-fire a cron repeat or re-release flow children), and its fail() could re-queue or dead-letter a job the new worker was still executing. Each lease now carries the job's attempts value (incremented once per lease), and complete(), fail(), and extendLease() act only when that value still matches — so a call from a worker that no longer holds the lease changes nothing. The generic consumer threads this automatically; the SQS backend already bound these actions to the message's receipt handle and is unchanged. **Fixed:** *Local and Redis queues bind complete/fail/extendLease to the held lease* — A long-running handler whose lease expired and was swept could have its job re-leased to a second worker; when the first worker finished, complete() marked the second worker's in-progress job done — double-firing a cron-recurring job's next enqueue and re-releasing its flow dependents — while fail() re-queued or dead-lettered the job the second worker was still running (re-executing or discarding in-flight work). The backends now fence each of these calls on the leased attempts value, which is bumped once per lease; only the worker that holds the current lease can complete, fail, or extend it. A stale call returns without mutating the queue. This brings the local and Redis backends to parity with the SQS backend, which already bound these actions to the message receipt handle.
12
14
 
13
15
  - v0.15.60 (2026-06-29) — **`requireStepUp` binds the elevation grant to the authenticated principal, refusing a grant minted for a different user (cross-user step-up replay).** The b.middleware.requireStepUp gate accepts an operator-issued step-up elevation grant from the X-Step-Up-Grant header and verifies it with b.auth.stepUp.grant.verify. An elevation grant carries the subject it was minted for (payload.sub), but the middleware verified only the grant's signature, expiry, and scope — never that the grant's subject matched the request's authenticated principal. A grant minted for one user (and then leaked through a shared cache, a log line, a referrer, or a shared device) therefore satisfied the step-up requirement for ANY other authenticated user who presented it, elevating their session to the granted assurance level without ever completing a step-up ceremony. requireStepUp now passes the resolved principal as the grant's required subject, so the grant satisfies step-up only for the user it was issued to. The principal is resolved from whichever shape the authenticator populated — a session's req.user.id / req.user.userId, or the JWT subject (req.user.claims.sub / req.user.sub) set by bearerAuth with an external verifier — so a grant legitimately minted for a JWT subject still binds. A request with no resolvable principal cannot bind the grant and falls through to the claims-based challenge. **Security:** *Step-up elevation grants are bound to the authenticated principal* — requireStepUp's grant path called b.auth.stepUp.grant.verify with only the grant scope, not the subject, so any holder of a valid, unexpired, scope-matching elevation grant passed the step-up gate regardless of which user the request was authenticated as — a leaked or shared grant elevated a different user's session (cross-user step-up replay). The grant already binds a subject at mint time and the verifier supports a subject check; the middleware now supplies the request's principal as the required subject, refusing a grant whose subject does not match. The principal is read from whichever field the authenticator set — a session's id/userId or the JWT subject (claims.sub / sub) from bearerAuth's external verifier — so a grant minted for any of those binds correctly. A request with no authenticated principal cannot bind a grant and is handled by the normal claims-based step-up challenge. The grant verifier's signature/expiry/scope/jti-revocation checks are unchanged.
@@ -177,7 +177,7 @@ function sign(opts) {
177
177
  validateOpts(opts, [
178
178
  "rfc822", "instance", "authservId", "domain", "selector",
179
179
  "privateKey", "algorithm", "cv", "authResults",
180
- "headersToSign", "timestamp", "audit",
180
+ "headersToSign", "timestamp", "audit", "excludeAarFromAms",
181
181
  ], "mail.arc.sign");
182
182
 
183
183
  validateOpts.requireNonEmptyString(opts.rfc822, "sign: rfc822",
package/lib/mail-auth.js CHANGED
@@ -1456,6 +1456,25 @@ function _parseHeaderLines(headerSection) {
1456
1456
  // limit how far an attacker can push junk headers.
1457
1457
  var ARC_MAX_HOPS = 50; // RFC 8617 §5.1.2 chain ceiling
1458
1458
 
1459
+ // Parse the ARC instance tag (i=) from an ARC header value (RFC 8617
1460
+ // §4.2.1). Strict by construction: the tag must follow a value boundary
1461
+ // (start / ";" / "," / whitespace), use no space around "=", and be 1-3
1462
+ // digits (instances are bounded to [1,50]). EVERY ARC-header instance read
1463
+ // — the indexing pass that drives the AMS/AS crypto checks, the AMS h=
1464
+ // retention test, the per-hop d= extraction, and the finalAr surfacing —
1465
+ // routes through this one parser so they can never disagree about which hop
1466
+ // a header belongs to. A looser parser in one place (e.g. allowing "i = 1"
1467
+ // with a space, or unbounded digits) lets an attacker inject an
1468
+ // ARC-Authentication-Results that the strict crypto pass ignores while a
1469
+ // permissive pass consumes it, forging the upstream auth-results surfaced as
1470
+ // finalAr on a chain that still verifies pass — with no signing key.
1471
+ function _arcInstanceOf(value) {
1472
+ var m = String(value).match(/(?:^|[;,\s])i=(\d{1,3})\b/);
1473
+ if (!m) return null;
1474
+ var inst = parseInt(m[1], 10);
1475
+ return (isFinite(inst) && inst >= 1) ? inst : null;
1476
+ }
1477
+
1459
1478
  async function arcVerify(rfc822, opts) {
1460
1479
  if (typeof rfc822 !== "string" || rfc822.length === 0) {
1461
1480
  throw new MailAuthError("mail-auth/arc-bad-input",
@@ -1491,13 +1510,11 @@ async function arcVerify(rfc822, opts) {
1491
1510
  var value = khv.value.trim();
1492
1511
  if (name !== "arc-seal" && name !== "arc-message-signature" &&
1493
1512
  name !== "arc-authentication-results") continue;
1494
- // ARC hop instance per RFC 8617 §4.2.1 — bounded to 3 digits; the
1495
- // spec doesn't define a hard ceiling but operational use never
1496
- // exceeds 50 hops, and a 999-hop limit prevents pathological
1497
- // header values from chewing the verifier.
1498
- var iMatch = value.match(/(?:^|[;,\s])i=(\d{1,3})\b/);
1499
- var inst = iMatch ? parseInt(iMatch[1], 10) : null;
1500
- if (inst === null || !isFinite(inst) || inst < 1) continue;
1513
+ // ARC hop instance per RFC 8617 §4.2.1 — parsed by the shared strict
1514
+ // reader so the index this drives (and the AMS/AS crypto checks keyed on
1515
+ // it) matches every other instance read in the verifier/evaluator.
1516
+ var inst = _arcInstanceOf(value);
1517
+ if (inst === null) continue;
1501
1518
  if (inst > maxInstanceSeen) maxInstanceSeen = inst;
1502
1519
  var slotKey = inst + ":" + name;
1503
1520
  if (seenSlot[slotKey]) { duplicate = true; continue; }
@@ -1790,7 +1807,9 @@ async function _verifyArc(rfc822, hop, allHops, kind, dnsLookup, dkim) {
1790
1807
  }
1791
1808
  // Current AS with b= emptied. RFC 8617 §5.1.2: canonicalization
1792
1809
  // includes the AS header with `b=` value stripped + no trailing CRLF.
1793
- var asUnsigned = sigValue.replace(/(\bb=)[^;]*/i, "$1");
1810
+ // Use the shared tag-aware stripper (the `\bb=...` regex it replaced
1811
+ // mis-zeroed a value containing `b=` inside another tag, e.g. `d=ab=x`).
1812
+ var asUnsigned = dkim._stripBTagValue(sigValue);
1794
1813
  canonicalized += _canonRelaxedHeader("ARC-Seal", asUnsigned).replace(/\r\n$/, "");
1795
1814
 
1796
1815
  // Verify the AS signature.
@@ -1824,8 +1843,8 @@ async function _verifyAmsViaDkim(rfc822, hop, sigValue, tags, dkim, dnsLookup) {
1824
1843
  // canonicalizes it via h=). Pre-v0.8.17 stripped every AAR
1825
1844
  // unconditionally, breaking verification on chains that
1826
1845
  // included AAR in h= (Microsoft + Google interop).
1827
- var instMatch = /\bi\s*=\s*(\d+)/.exec(khv.value);
1828
- if (!instMatch || parseInt(instMatch[1], 10) !== hop.instance) continue;
1846
+ var aarInst = _arcInstanceOf(khv.value);
1847
+ if (aarInst === null || aarInst !== hop.instance) continue;
1829
1848
  }
1830
1849
  rebuilt.push(line);
1831
1850
  }
@@ -1980,12 +1999,14 @@ async function arcEvaluate(rfc822, opts) {
1980
1999
  var name = khv.key;
1981
2000
  var value = khv.value.trim();
1982
2001
  if (name === "arc-seal") {
1983
- var iMatch = value.match(/(?:^|[;,\s])i=(\d+)/); // allow:regex-no-length-cap — header bounded by RFC 5322 998
2002
+ var sealInst = _arcInstanceOf(value);
1984
2003
  var dMatch = value.match(/(?:^|[;,\s])d=([^\s;]+)/); // allow:regex-no-length-cap — header bounded by RFC 5322 998
1985
- if (iMatch && dMatch) hopDomains[parseInt(iMatch[1], 10)] = dMatch[1].toLowerCase();
2004
+ if (sealInst !== null && dMatch) hopDomains[sealInst] = dMatch[1].toLowerCase();
1986
2005
  } else if (name === "arc-authentication-results") {
1987
- var arIMatch = value.match(/\bi\s*=\s*(\d+)/); // allow:regex-no-length-cap header bounded by RFC 5322 998
1988
- if (arIMatch) hopAr[parseInt(arIMatch[1], 10)] = value;
2006
+ // Same strict instance reader as the indexing pass finalAr must be the
2007
+ // AAR the crypto pass actually indexed, never one a looser parser admits.
2008
+ var arInst = _arcInstanceOf(value);
2009
+ if (arInst !== null) hopAr[arInst] = value;
1989
2010
  }
1990
2011
  }
1991
2012
 
package/lib/mail-dkim.js CHANGED
@@ -1323,6 +1323,7 @@ module.exports = {
1323
1323
  _canonHeaderRelaxedForTest: _canonHeaderRelaxed,
1324
1324
  _canonBodyRelaxedForTest: _canonBodyRelaxed,
1325
1325
  _canonBodySimpleForTest: _canonBodySimple,
1326
+ _stripBTagValue: _stripBTagValue, // RFC 6376 §3.5 — tag-aware b= zeroing; shared by the ARC seal verifier (internal cross-module helper)
1326
1327
  _stripBTagValueForTest: _stripBTagValue,
1327
1328
  // The header-block parser that produces the { name, value } pairs fed to the
1328
1329
  // canonicalizers. Exposed so a golden-vector test can pin its byte-exact
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.15.61",
3
+ "version": "0.15.62",
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:6303169a-86d7-4e03-92ca-da9cee671ab1",
5
+ "serialNumber": "urn:uuid:5422e8bb-3202-439f-86f2-ed91f656a2ba",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-06-29T17:23:08.793Z",
8
+ "timestamp": "2026-06-29T19:28:45.676Z",
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.61",
22
+ "bom-ref": "@blamejs/core@0.15.62",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.15.61",
25
+ "version": "0.15.62",
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.61",
29
+ "purl": "pkg:npm/%40blamejs/core@0.15.62",
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.61",
57
+ "ref": "@blamejs/core@0.15.62",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]