@blamejs/core 0.15.55 → 0.15.57

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.15.x
10
10
 
11
+ - v0.15.57 (2026-06-29) — **The RFC 9421 HTTP-message-signature verifier now enforces a required-coverage floor by default, refusing a signature whose covered set omits `@method` / `@target-uri` (and `content-digest` for bodied requests).** b.crypto.httpSig.verify built the signature base entirely from the covered-component list carried in the message's own Signature-Input header and never checked that the signature actually covered the security-relevant parts of the request (RFC 9421 §3.2). A signature covering only @authority therefore verified even after the method, target URI, or body were changed — so a captured signature could be replayed across a different method and path, or a request body swapped, under an otherwise-valid signature. verify now refuses a signature whose covered set omits a required component, returning { valid: false, reason: "missing-required-component", missing: [...] } before the cryptographic check. By default (security-on, not opt-in) it requires @method and @target-uri on every request, plus content-digest when the request carries a body; an operator can pass requiredComponents to assert an exact set, or requiredComponents: [] to waive the floor (the signature itself is still verified). **Security:** *httpSig.verify enforces a required-coverage floor (RFC 9421 §3.2)* — The verifier trusted the covered-component list from the message's Signature-Input header without requiring that any particular component be covered, so an under-covered signature (e.g. covering only @authority) verified while the method, target URI, and body were free to change — a captured signature replayed across method+path, or a swapped body, passed verification. verify now refuses a signature missing a required component with reason "missing-required-component" (and a missing[] list) before the crypto check. The default floor is @method + @target-uri on every request plus content-digest for bodied requests; requiredComponents overrides it explicitly, and requiredComponents: [] waives the floor for callers that intentionally sign a narrower set (the signature itself is still verified). **Migration:** *Signers must cover @method + @target-uri (and content-digest for bodied requests)* — If you verify HTTP message signatures with b.crypto.httpSig.verify, signatures whose covered set omits @method or @target-uri (or content-digest on a request with a body) now fail with reason "missing-required-component". Broaden the signer's covered set to include them (recommended), pass requiredComponents to assert the exact components your application requires, or pass requiredComponents: [] to keep the prior behavior of accepting whatever the signer covered. Verifying a fully-covered signature is unchanged.
12
+
13
+ - v0.15.56 (2026-06-29) — **Break-glass and dual-control wildcard scope matching is now segment-aware, and the JMAP cross-tenant gate validates every account-id argument (including `fromAccountId`), closing two authorization gaps.** Two authorization gates under-checked their input. b.breakGlass and b.dualControl matched a wildcard scope/role by stripping a trailing "*" and doing a raw string-prefix comparison (requireScope.indexOf(prefix) === 0), which ignores ":" segment boundaries — so a partial-segment grant like "phi:a*" satisfied a required "phi:admin", letting a holder break-glass-unseal or approve a flow they were not scoped for. Both now match through the canonical, segment-aware b.permissions.match, so only an exact scope or a whole-segment wildcard ("phi:*") satisfies the requirement. Separately, the JMAP server's cross-tenant gate validated only a method call's destination accountId, not the fromAccountId that /copy methods (Email/copy, CalendarEvent/copy — RFC 8620 §5.4) read their SOURCE account from — so an actor could name a foreign fromAccountId and copy another tenant's data. The gate now validates every account-id argument the call names. **Security:** *Break-glass / dual-control wildcard scope matching is segment-aware* — b.breakGlass grant (requireScope) and b.dualControl approval (approverRoles) matched a wildcard-shaped actor scope/role by stripping its trailing "*" and testing requireScope.indexOf(prefix) === 0 — a raw string prefix that ignores the ":" segment structure. A partial-segment grant therefore over-matched: "phi:a*" satisfied "phi:admin" (and "p*" satisfied any "phi:..."), so an actor with a narrower grant could break-glass-unseal sealed data or approve a dual-control flow they were not authorized for. Both sites now match through b.permissions.match, where "*" must occupy a whole ":"-delimited segment — "phi:*" still satisfies "phi:admin", but "phi:a*" does not. Actor scopes/roles flow from operator-trusted assignment, so this tightens a defense-in-depth gate rather than a request-controlled bypass. · *JMAP cross-tenant gate validates every account-id argument, not only the destination* — The JMAP server rejects a method call that names an accountId outside the actor's enumerated set (RFC 8620 §3.6.1). It checked only accountId, but /copy methods (Email/copy, CalendarEvent/copy — RFC 8620 §5.4) also carry fromAccountId, the SOURCE account they read from. An actor enumerated for their own destination account could therefore name a foreign fromAccountId and have the operator's copy handler read another tenant's messages. The gate now validates every account-id argument (any *AccountId) against the actor's enumerated accounts before the handler runs, so a foreign source account is refused with accountNotFound. **Detectors:** *Scope/role wildcard matching must use b.permissions.match* — A codebase-patterns detector flags the hand-rolled wildcard idiom (a trailing-"*" check plus a slice(0,-1) strip plus a raw indexOf(prefix)===0 prefix match), so a new authorization gate cannot reintroduce a segment-unaware scope match instead of routing through b.permissions.match.
14
+
11
15
  - v0.15.55 (2026-06-29) — **`b.fileUpload` enforces per-upload ownership: acceptChunk / finalize / status / cancelUpload now refuse a caller who is not the upload's owner, closing an IDOR.** A file-upload manager recorded the owner of each upload at init() but never checked it again: acceptChunk, finalize, status, and cancelUpload looked the upload up by the caller-supplied uploadId alone. The only gate was the coarse "fileUpload.<op>" permission scope, which says whether an actor may perform the operation at all — not whether they own the specific upload. So any caller holding the (commonly shared) fileUpload.finalize / cancel / status / accept scope could finalize, cancel, read the status of, or push chunks into another actor's in-flight upload by its uploadId (an insecure-direct-object-reference, CWE-639). Each lifecycle method now compares the calling actor against the stored owner (actor.id || actor.userId, captured at init) and refuses a non-owner. Operator admin tooling that must act across actors opts in with create({ allowCrossActor: true }); when a permissions instance is wired, cross-actor access additionally requires the new "fileUpload.admin" scope, so the escape hatch is itself gated. Uploads created without an actor are owned by the anonymous identity and unreachable by a named actor (fail-closed). **Security:** *File-upload lifecycle methods enforce per-upload ownership (IDOR fix)* — acceptChunk, finalize, status, and cancelUpload previously authorized only against the coarse fileUpload.<op> capability scope, never against the upload's recorded owner, so a caller with that scope could act on any actor's upload by guessing or enumerating its uploadId. Each method now refuses a caller whose identity (actor.id || actor.userId) does not match the owner stored at init() — raising fileUpload OWNERSHIP_VIOLATION — so an upload is reachable only by the actor that created it. Cross-actor admin tooling opts in via create({ allowCrossActor: true }) plus the new fileUpload.admin permission scope (required when permissions are wired); list({ scopeToActor: false }) remains the deliberate cross-actor listing view. Operators whose workflows legitimately hand an upload between actors must set allowCrossActor and grant fileUpload.admin to the acting principal. **Detectors:** *fileUpload lifecycle methods must enforce ownership* — A codebase-patterns detector flags any fileUpload lifecycle method that loads an upload by a request-supplied uploadId without calling the ownership check before acting, so a newly added method cannot silently reintroduce the IDOR.
12
16
 
13
17
  - v0.15.54 (2026-06-28) — **SAML federation-metadata (MDQ) verification binds the signature to the consumed EntityDescriptor and refuses signature-wrapping, closing an IdP trust-anchor takeover.** b.auth.saml.fetchMdq validates signed federation metadata with the same XML-dsig verifier the assertion path uses, but discarded the verified reference and never bound it to the EntityDescriptor the operator consumes — unlike verifyResponse, which already enforces that the signature covers the element it trusts. Combined with a first-child Signature lookup, an attacker controlling or interposing on the MDQ source could wrap a genuinely federation-signed EntityDescriptor under a forged container (or bury it in an EntityDescriptor whose own ID and signing certificate are attacker-chosen): the signature still validated over the buried element, fetchMdq returned the whole document, and the operator extracted the attacker's certificate as the IdP signing key — a full SAML authentication bypass (the CVE-2024-45409 / ruby-saml signature-wrapping class). fetchMdq now requires the metadata root to be a single EntityDescriptor, binds the verified reference to that root's ID (mirroring verifyResponse), confirms the signed entityID is the one requested, and refuses a duplicate top-level Signature. **Security:** *fetchMdq binds the federation signature to the consumed EntityDescriptor (XML signature-wrapping defense)* — On the MDQ trust-bootstrap path, fetchMdq verified the federation signature but discarded the reference it covered, so the signature did not have to cover the EntityDescriptor whose signing certificate the operator subsequently trusts. An attacker with a malicious or interposed MDQ responder could pair a genuine federation signature over a buried entity with a forged outer/sibling EntityDescriptor carrying their own signing certificate; fetchMdq accepted and returned the wrapped document, and the operator installed the attacker certificate as the IdP trust anchor — forging arbitrary assertions thereafter. fetchMdq now refuses a non-EntityDescriptor root (the EntitiesDescriptor wrapper), refuses a duplicate top-level Signature, binds the verified reference to the document-root EntityDescriptor's ID, and confirms the signed entityID matches the requested one — mirroring the wrapping defenses verifyResponse already applies to assertions. New error codes: auth-saml/mdq-not-entity-descriptor, auth-saml/mdq-duplicate-signature, auth-saml/mdq-entity-mismatch (plus the existing auth-saml/signed-different-element for a buried-reference root). Verification of legitimately signed single-entity metadata is unchanged.
@@ -58,6 +58,7 @@ var vault = lazyRequire(function () { return require("./vault"); });
58
58
 
59
59
  var lockout = lazyRequire(function () { return require("./auth/lockout"); });
60
60
  var passkey = lazyRequire(function () { return require("./auth/passkey"); });
61
+ var permissions = lazyRequire(function () { return require("./permissions"); });
61
62
 
62
63
  // Errors — all 14 codes documented in the spec. `permanent: true`
63
64
  // means caller's input is bad (config-time / call-site reject);
@@ -1055,16 +1056,14 @@ async function grant(opts) {
1055
1056
  : []);
1056
1057
  var scopeOk = false;
1057
1058
  for (var sci = 0; sci < actorScopes.length; sci += 1) {
1058
- if (actorScopes[sci] === policy.requireScope) { scopeOk = true; break; }
1059
- // Wildcard support: "phi:*" matches "phi:admin" and "phi:read".
1059
+ // Segment-aware scope match via the canonical b.permissions.match: an
1060
+ // exact scope, or a wildcard whose "*" occupies a WHOLE colon segment
1061
+ // ("phi:*" → "phi:admin"), satisfies the requirement. A raw string prefix
1062
+ // ("phi:admin".indexOf("phi:a") === 0) wrongly let a partial-segment
1063
+ // scope ("phi:a*") glass-unseal a different value.
1060
1064
  if (typeof actorScopes[sci] === "string" &&
1061
- actorScopes[sci].length > 0 &&
1062
- actorScopes[sci].charAt(actorScopes[sci].length - 1) === "*") {
1063
- var prefix = actorScopes[sci].slice(0, -1);
1064
- if (typeof policy.requireScope === "string" &&
1065
- policy.requireScope.indexOf(prefix) === 0) {
1066
- scopeOk = true; break;
1067
- }
1065
+ permissions().match(actorScopes[sci], policy.requireScope)) {
1066
+ scopeOk = true; break;
1068
1067
  }
1069
1068
  }
1070
1069
  if (!scopeOk) {
@@ -38,6 +38,7 @@ var C = require("./constants");
38
38
  var { defineClass } = require("./framework-error");
39
39
 
40
40
  var audit = lazyRequire(function () { return require("./audit"); });
41
+ var permissions = lazyRequire(function () { return require("./permissions"); });
41
42
 
42
43
  var DualControlError = defineClass("DualControlError", { alwaysPermanent: true });
43
44
  var _err = DualControlError.factory;
@@ -222,22 +223,15 @@ function create(opts) {
222
223
  if (!actor || !Array.isArray(actor.roles)) return false;
223
224
  for (var i = 0; i < approverRoles.length; i++) {
224
225
  var required = approverRoles[i];
225
- // Wildcard match — actor's "security:*" satisfies a required
226
- // "security:officer" (matching the b.permissions.match
227
- // semantics elsewhere in the framework). Without this, an
228
- // operator with a wildcard-shaped role can't approve dual-
229
- // control flows even when b.permissions would consider the
230
- // role assignment satisfied.
231
226
  for (var j = 0; j < actor.roles.length; j++) {
232
- var actorRole = actor.roles[j];
233
- if (actorRole === required) return true;
234
- if (typeof actorRole === "string" &&
235
- actorRole.length > 0 &&
236
- actorRole.charAt(actorRole.length - 1) === "*") {
237
- var prefix = actorRole.slice(0, -1);
238
- if (typeof required === "string" && required.indexOf(prefix) === 0) {
239
- return true;
240
- }
227
+ // Segment-aware role match via the canonical b.permissions.match: an
228
+ // exact role, or a wildcard whose "*" occupies a WHOLE colon segment
229
+ // ("security:*" "security:officer"), satisfies the requirement. A raw
230
+ // string prefix ("security:officer".indexOf("security:o") === 0)
231
+ // wrongly let a partial-segment role ("security:o*") approve a flow.
232
+ if (typeof actor.roles[j] === "string" &&
233
+ permissions().match(actor.roles[j], required)) {
234
+ return true;
241
235
  }
242
236
  }
243
237
  }
@@ -55,8 +55,13 @@
55
55
  * }, {
56
56
  * keyResolver: function (keyid, alg) { return publicKeyPem; },
57
57
  * toleranceMs: b.constants.TIME.minutes(5),
58
+ * // requiredComponents (RFC 9421 §3.2): components the covered set MUST
59
+ * // include, else verify refuses with reason "missing-required-component".
60
+ * // Omitted → secure default: @method + @target-uri, plus content-digest
61
+ * // when the request has a body. [] explicitly waives the coverage floor.
62
+ * requiredComponents: ["@method", "@target-uri", "content-digest"],
58
63
  * });
59
- * // → { valid, label, keyid, alg, covered, reason? }
64
+ * // → { valid, label, keyid, alg, covered, reason?, missing? }
60
65
  */
61
66
 
62
67
  var nodeCrypto = require("node:crypto");
@@ -549,6 +554,13 @@ function verify(msg, opts) {
549
554
  throw _err("BAD_OPT",
550
555
  "httpSig.verify: keyResolver(keyid, alg) → publicKeyPem required");
551
556
  }
557
+ // requiredComponents (RFC 9421 §3.2): the verifier MUST refuse a signature
558
+ // that does not cover the components the application requires. undefined →
559
+ // the secure default (computed below, once the body is known); an explicit
560
+ // array overrides; an explicit [] waives the coverage floor (the signature
561
+ // itself still has to verify — only the floor is waived).
562
+ validateOpts.optionalNonEmptyStringArray(opts.requiredComponents,
563
+ "httpSig.verify: requiredComponents", HttpSigError, "BAD_OPT");
552
564
  var toleranceMs = typeof opts.toleranceMs === "number" ? opts.toleranceMs : DEFAULT_TOLERANCE_MS;
553
565
  var clockSkewMs = typeof opts.clockSkewMs === "number" ? opts.clockSkewMs : DEFAULT_CLOCK_SKEW_MS;
554
566
  var nowMs = opts.now ? opts.now() : Date.now();
@@ -597,6 +609,40 @@ function verify(msg, opts) {
597
609
  // mandates that verifiers re-run the digest over the body — a stale
598
610
  // header from a proxy would otherwise verify trivially.
599
611
  var coveredLower = parsedInput.covered.map(function (c) { return c.split(";")[0].toLowerCase(); }); // allow:bare-split-on-quoted-header-token-grammar — same as sign() above: covered items are RFC 9421 §2.1 component-ids, token grammar
612
+
613
+ // RFC 9421 §3.2 — refuse a signature whose covered set omits a component the
614
+ // application requires, BEFORE and INDEPENDENT of the crypto check. Without
615
+ // this the verifier acts on a request whose method / target-uri / body were
616
+ // never signed: an attacker who under-covers (e.g. only @authority) can change
617
+ // them freely under an otherwise-valid signature. Default floor (security-on,
618
+ // not opt-in per the framework's defaults policy): @method + @target-uri
619
+ // always, plus content-digest when the message carries a body. An explicit
620
+ // requiredComponents overrides; an explicit [] waives the floor.
621
+ // The required-coverage comparison PRESERVES component parameters: a required
622
+ // `@query-param;name="tenant"` must not be satisfied by a covered
623
+ // `@query-param;name="other"`, so only the component NAME before ";" is
624
+ // case-folded and the parameter suffix is kept verbatim. (coveredLower above
625
+ // keeps the bare name for the param-free content-digest gate.)
626
+ function _componentKey(c) {
627
+ var semi = c.indexOf(";");
628
+ return semi === -1 ? c.toLowerCase() : c.slice(0, semi).toLowerCase() + c.slice(semi);
629
+ }
630
+ var requiredComponents;
631
+ if (opts.requiredComponents !== undefined && opts.requiredComponents !== null) {
632
+ requiredComponents = opts.requiredComponents.map(_componentKey);
633
+ } else {
634
+ requiredComponents = ["@method", "@target-uri"];
635
+ if (m.body != null) requiredComponents.push("content-digest");
636
+ }
637
+ var coveredKeys = parsedInput.covered.map(_componentKey);
638
+ var missingComponents = [];
639
+ for (var rci = 0; rci < requiredComponents.length; rci += 1) {
640
+ if (coveredKeys.indexOf(requiredComponents[rci]) === -1) missingComponents.push(requiredComponents[rci]);
641
+ }
642
+ if (missingComponents.length > 0) {
643
+ return { valid: false, reason: "missing-required-component", missing: missingComponents };
644
+ }
645
+
600
646
  if (coveredLower.indexOf("content-digest") !== -1) {
601
647
  if (m.body == null) {
602
648
  return { valid: false, reason: "content-digest-no-body" };
@@ -395,18 +395,28 @@ function create(opts) {
395
395
  description: "Method '" + methodName + "' not implemented on this server" }, clientId]);
396
396
  continue;
397
397
  }
398
- // Cross-tenant gate (RFC 8620 §3.6.1): if the call names an accountId,
399
- // it MUST be one the actor is enumerated for. Rejected BEFORE the
400
- // operator handler runs so a forged/foreign accountId never reaches
401
- // the backend. Calls without an accountId (account-agnostic methods)
402
- // pass through unchanged.
403
- if (resolvedArgs && typeof resolvedArgs === "object" &&
404
- resolvedArgs.accountId !== undefined && resolvedArgs.accountId !== null) {
405
- var callAccountId = resolvedArgs.accountId;
406
- if (typeof callAccountId !== "string" || !permittedAccounts[callAccountId]) {
398
+ // Cross-tenant gate (RFC 8620 §3.6.1): EVERY account-id argument the call
399
+ // names MUST be one the actor is enumerated for not only `accountId`
400
+ // but also `fromAccountId` on /copy methods (Email/copy, CalendarEvent/copy
401
+ // RFC 8620 §5.4), whose SOURCE account is read from. Checking only the
402
+ // destination accountId let a /copy name a foreign fromAccountId and copy
403
+ // (read) another tenant's data. Rejected BEFORE the operator handler runs.
404
+ // Account-agnostic methods (no *AccountId arg) pass through unchanged.
405
+ if (resolvedArgs && typeof resolvedArgs === "object") {
406
+ var argKeys = Object.keys(resolvedArgs);
407
+ var deniedAccountId; var deniedHit = false;
408
+ for (var aki = 0; aki < argKeys.length && !deniedHit; aki += 1) {
409
+ if (!/[Aa]ccountId$/.test(argKeys[aki])) continue; // accountId, fromAccountId, ...
410
+ var accVal = resolvedArgs[argKeys[aki]];
411
+ if (accVal === undefined || accVal === null) continue;
412
+ if (typeof accVal !== "string" || !permittedAccounts[accVal]) {
413
+ deniedHit = true;
414
+ deniedAccountId = typeof accVal === "string" ? accVal : null;
415
+ }
416
+ }
417
+ if (deniedHit) {
407
418
  _emit("mail.server.jmap.account_not_found",
408
- { method: methodName, accountId: typeof callAccountId === "string" ? callAccountId : null,
409
- clientId: clientId }, "denied");
419
+ { method: methodName, accountId: deniedAccountId, clientId: clientId }, "denied");
410
420
  methodResponses.push(["error",
411
421
  { type: "urn:ietf:params:jmap:error:accountNotFound",
412
422
  description: "accountId is not accessible to this actor" }, clientId]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.15.55",
3
+ "version": "0.15.57",
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:c04e6464-14f6-4e3e-9612-897f574eb1e2",
5
+ "serialNumber": "urn:uuid:2f36daef-8dad-4626-a48e-1ad3ed3e44ba",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-06-29T08:12:12.873Z",
8
+ "timestamp": "2026-06-29T10:15:11.278Z",
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.55",
22
+ "bom-ref": "@blamejs/core@0.15.57",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.15.55",
25
+ "version": "0.15.57",
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.55",
29
+ "purl": "pkg:npm/%40blamejs/core@0.15.57",
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.55",
57
+ "ref": "@blamejs/core@0.15.57",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]