@blamejs/core 0.15.54 → 0.15.56
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 +4 -0
- package/lib/break-glass.js +8 -9
- package/lib/dual-control.js +9 -15
- package/lib/file-upload.js +76 -3
- package/lib/mail-server-jmap.js +21 -11
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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.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.
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
11
15
|
- 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.
|
|
12
16
|
|
|
13
17
|
- v0.15.53 (2026-06-28) — **DKIM (and ARC) verification refuses a body-length-limited (`l=`) signature once content has been appended past the signed octets, closing the append-after-signature attack.** A DKIM signature with an `l=` body-length tag covers only the first `l=` octets of the canonicalized body, so anyone in the delivery path can append arbitrary unsigned content after the signed prefix and the body hash still matches. b.mail.dkim.verify honored such a signature and returned `pass`, with only a non-load-bearing warning that no consumer read — so b.mail.inbound.verify granted an aligned DMARC `pass` to a message whose delivered body diverged from the signed bytes (RFC 6376 §8.2). Verification now refuses an `l=` signature once the delivered body extends beyond the signed octets: the verified prefix no longer authenticates the appended content, so the result is a body-hash `fail` rather than a `pass`. Signatures whose `l=` exactly covers the body (no appended content) are unchanged, and an operator who must accept legacy `l=` senders can opt back in with verify({ acceptBodyLengthLimit: true }). ARC-Message-Signature verification, which reuses the same verifier, inherits the refusal unconditionally. **Security:** *DKIM verify refuses an l= signature with content appended past the signed octets* — The DKIM `l=` tag (RFC 6376 §3.5) limits the body hash to the first `l=` canonicalized octets, leaving any trailing body unsigned — the documented append-after-signature exposure (RFC 6376 §8.2). b.mail.dkim.verify hashed the prefix, matched `bh=`, and returned `pass` for the whole message, even though the recipient received appended content the signer never authenticated; the only signal was a warning string that b.mail.inbound.verify / dmarc.evaluate never inspected, so a forged trailer rode an aligned DMARC `pass`. verify now returns a body-hash `fail` when an `l=` signature leaves delivered body content beyond the signed octets (the framework already refuses `l=` at sign time). A signature whose `l=` covers the entire body still verifies; verify({ acceptBodyLengthLimit: true }) restores the prior accept-with-warning behavior for operators with legacy `l=` senders. · *ARC-Message-Signature verification inherits the same refusal* — ARC chain validation verifies each hop's ARC-Message-Signature through the same DKIM verifier, so an AMS carrying `l=` with appended content is now refused as a body-hash `fail` — failing the chain — rather than validating over a prefix. The ARC path does not expose the acceptBodyLengthLimit opt-out, so it is unconditionally fail-closed for an appended-content `l=`.
|
package/lib/break-glass.js
CHANGED
|
@@ -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
|
-
|
|
1059
|
-
//
|
|
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].
|
|
1062
|
-
|
|
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) {
|
package/lib/dual-control.js
CHANGED
|
@@ -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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
}
|
package/lib/file-upload.js
CHANGED
|
@@ -129,9 +129,12 @@
|
|
|
129
129
|
* Design posture:
|
|
130
130
|
*
|
|
131
131
|
* - **init() before any chunk**: explicit lifecycle. Init records
|
|
132
|
-
* createdAt + actor + metadata
|
|
133
|
-
*
|
|
134
|
-
*
|
|
132
|
+
* createdAt + actor + metadata in a per-upload sidecar; the stored owner
|
|
133
|
+
* (actor.id || actor.userId) is enforced on every subsequent acceptChunk /
|
|
134
|
+
* finalize / status / cancelUpload call, so one actor cannot act on another
|
|
135
|
+
* actor's upload by its uploadId. Operator admin tooling opts into
|
|
136
|
+
* cross-actor access via allowCrossActor (default off) plus the
|
|
137
|
+
* "fileUpload.admin" permission scope.
|
|
135
138
|
*
|
|
136
139
|
* - **Framework owns chunk lifecycle**, not final storage. Operator
|
|
137
140
|
* decides via `onFinalize` what to do with the assembled buffer
|
|
@@ -305,6 +308,16 @@ function _validateCreateOpts(opts) {
|
|
|
305
308
|
validateOpts.optionalObjectWithMethod(v, "check", label, FileUploadError, "BAD_OPT",
|
|
306
309
|
"must be a b.permissions instance (check fn)");
|
|
307
310
|
},
|
|
311
|
+
// allowCrossActor — escape hatch for operator admin tooling that must act on
|
|
312
|
+
// ANY actor's upload (ops dashboards, cleanup jobs). Default false: per-upload
|
|
313
|
+
// ownership is enforced so actor B cannot finalize / cancel / status /
|
|
314
|
+
// acceptChunk actor A's upload by guessing its uploadId. When true the
|
|
315
|
+
// ownership check is skipped; if a permissions instance is also wired the
|
|
316
|
+
// caller must additionally hold the "fileUpload.admin" scope, so cross-actor
|
|
317
|
+
// access stays gated.
|
|
318
|
+
allowCrossActor: function (v, label) {
|
|
319
|
+
validateOpts.optionalBoolean(v, label, FileUploadError, "BAD_OPT");
|
|
320
|
+
},
|
|
308
321
|
// contentSafety — extension-keyed gate map for per-extension content
|
|
309
322
|
// validation. Default behaviour: when undefined, the framework wires
|
|
310
323
|
// b.guardAll.byExtension({ profile: "strict" }) automatically so every
|
|
@@ -383,6 +396,7 @@ function _validateCreateOpts(opts) {
|
|
|
383
396
|
* audit: b.audit,
|
|
384
397
|
* observability: b.observability,
|
|
385
398
|
* permissions: b.permissions, // optional; gates init/accept/finalize/status/list/cancel
|
|
399
|
+
* allowCrossActor: boolean, // default false; admin escape hatch — bypasses per-upload ownership when the caller holds the "fileUpload.admin" scope
|
|
386
400
|
* fileType: b.fileType, // required when allowedFileTypes is non-empty
|
|
387
401
|
* contentSafety: Object | null, // ext→gate map; null = audited opt-out; undefined = b.guardAll.byExtension({ profile: "strict" })
|
|
388
402
|
* filenameSafety: Object | null, // gate; null = audited opt-out; undefined = b.guardFilename.gate({ profile: "strict" })
|
|
@@ -415,6 +429,7 @@ function create(opts) {
|
|
|
415
429
|
var onChunk = opts.onChunk || null;
|
|
416
430
|
var fileType = opts.fileType || null;
|
|
417
431
|
var permissions = opts.permissions || null;
|
|
432
|
+
var allowCrossActor = opts.allowCrossActor === true;
|
|
418
433
|
// ---- Default-on safety wiring ----
|
|
419
434
|
// contentSafety: undefined → wire b.guardAll.byExtension({ profile: "strict" })
|
|
420
435
|
// contentSafety: null → explicit opt-out, audit row emitted
|
|
@@ -547,6 +562,60 @@ function create(opts) {
|
|
|
547
562
|
}
|
|
548
563
|
}
|
|
549
564
|
|
|
565
|
+
// _checkOwnership — refuse when the calling actor does not own the upload.
|
|
566
|
+
// Owner identity is the same _actorKey derivation init() stored in
|
|
567
|
+
// meta.actorId (actor.id || actor.userId || "_anonymous"), so an anonymous
|
|
568
|
+
// upload is owned by "_anonymous" and a named actor cannot reach it (and vice
|
|
569
|
+
// versa) — fail-closed for the missing/legacy-meta case. The permission-scope
|
|
570
|
+
// check ("fileUpload.<op>") is a coarse capability ("may this actor finalize
|
|
571
|
+
// uploads AT ALL"); this is the per-resource authorization ("does this actor
|
|
572
|
+
// own THIS upload"). Both run on every lifecycle method, so one actor cannot
|
|
573
|
+
// act on another's upload by guessing its uploadId (IDOR / CWE-639).
|
|
574
|
+
//
|
|
575
|
+
// Escape hatch: allowCrossActor (create-time, default off) skips the ownership
|
|
576
|
+
// comparison for operator admin tooling; when permissions are also wired,
|
|
577
|
+
// "fileUpload.admin" is required so cross-actor access is itself gated.
|
|
578
|
+
function _checkOwnership(action, actor, meta) {
|
|
579
|
+
if (!meta) return; // not-found is handled by the caller's own UNKNOWN_UPLOAD path
|
|
580
|
+
var callerKey = _actorKey(actor);
|
|
581
|
+
if (meta.actorId === callerKey) return; // the owner always passes
|
|
582
|
+
// The caller is NOT the owner. Refuse unless the operator enabled
|
|
583
|
+
// cross-actor admin access at create() — and, when permissions are wired,
|
|
584
|
+
// the caller holds the "fileUpload.admin" scope (so the escape hatch is
|
|
585
|
+
// itself gated and a non-owner cannot ride allowCrossActor without it).
|
|
586
|
+
if (allowCrossActor) {
|
|
587
|
+
var adminOk = !permissions; // scope-less single-tenant operator: allowCrossActor alone suffices
|
|
588
|
+
if (permissions) {
|
|
589
|
+
try { adminOk = permissions.check(actor, "fileUpload.admin"); }
|
|
590
|
+
catch (_e) { adminOk = false; }
|
|
591
|
+
}
|
|
592
|
+
if (adminOk) return;
|
|
593
|
+
_emitObs("fileUpload.permission_denied", 1, { action: "admin" });
|
|
594
|
+
_emitAudit("fileUpload." + action, {
|
|
595
|
+
actor: requestHelpers.extractActorContext(actor),
|
|
596
|
+
resource: { kind: "fileUpload", id: meta.uploadId },
|
|
597
|
+
outcome: "denied",
|
|
598
|
+
reason: "cross-actor-admin-scope-required",
|
|
599
|
+
metadata: { uploadId: meta.uploadId, owner: meta.actorId,
|
|
600
|
+
caller: callerKey, action: action },
|
|
601
|
+
});
|
|
602
|
+
throw _err("PERMISSION_DENIED",
|
|
603
|
+
"fileUpload." + action + ": cross-actor access requires scope 'fileUpload.admin'");
|
|
604
|
+
}
|
|
605
|
+
_emitObs("fileUpload.ownership_violation", 1, { action: action });
|
|
606
|
+
_emitAudit("fileUpload." + action, {
|
|
607
|
+
actor: requestHelpers.extractActorContext(actor),
|
|
608
|
+
resource: { kind: "fileUpload", id: meta.uploadId },
|
|
609
|
+
outcome: "denied",
|
|
610
|
+
reason: "ownership-violation",
|
|
611
|
+
metadata: { uploadId: meta.uploadId, owner: meta.actorId,
|
|
612
|
+
caller: callerKey, action: action },
|
|
613
|
+
});
|
|
614
|
+
throw _err("OWNERSHIP_VIOLATION",
|
|
615
|
+
"fileUpload." + action + ": actor does not own upload '" + meta.uploadId +
|
|
616
|
+
"' (ownership enforced; set allowCrossActor + 'fileUpload.admin' scope for admin tooling)");
|
|
617
|
+
}
|
|
618
|
+
|
|
550
619
|
function _readReceivedIndices(uploadId) {
|
|
551
620
|
var p = _receivedPath(uploadId);
|
|
552
621
|
if (!nodeFs.existsSync(p)) return [];
|
|
@@ -699,6 +768,7 @@ function create(opts) {
|
|
|
699
768
|
throw _err("UNKNOWN_UPLOAD",
|
|
700
769
|
"fileUpload.acceptChunk: no init() seen for '" + uploadId + "'; call init() first");
|
|
701
770
|
}
|
|
771
|
+
_checkOwnership("accept", actor, meta);
|
|
702
772
|
if (clock() - meta.lastChunkAt > maxIdleMs) {
|
|
703
773
|
// Idle-timed-out — too much time since init or last chunk.
|
|
704
774
|
throw _err("UPLOAD_IDLE_EXPIRED",
|
|
@@ -991,6 +1061,7 @@ function create(opts) {
|
|
|
991
1061
|
throw _err("UNKNOWN_UPLOAD",
|
|
992
1062
|
"fileUpload.finalize: no init() seen for '" + uploadId + "'");
|
|
993
1063
|
}
|
|
1064
|
+
_checkOwnership("finalize", actor, meta);
|
|
994
1065
|
|
|
995
1066
|
_validateManifest(manifest);
|
|
996
1067
|
|
|
@@ -1216,6 +1287,7 @@ function create(opts) {
|
|
|
1216
1287
|
_checkPermission("status", callerOpts.actor);
|
|
1217
1288
|
var meta = _readMeta(uploadId);
|
|
1218
1289
|
if (!meta) return null;
|
|
1290
|
+
_checkOwnership("status", callerOpts.actor, meta);
|
|
1219
1291
|
var indices = _readReceivedIndices(uploadId).slice().sort(function (a, b) { return a - b; });
|
|
1220
1292
|
return {
|
|
1221
1293
|
uploadId: uploadId,
|
|
@@ -1258,6 +1330,7 @@ function create(opts) {
|
|
|
1258
1330
|
_checkPermission("cancel", callerOpts.actor);
|
|
1259
1331
|
var meta = _readMeta(uploadId);
|
|
1260
1332
|
if (!meta) return { ok: false, uploadId: uploadId, reason: "not-found" };
|
|
1333
|
+
_checkOwnership("cancel", callerOpts.actor, meta);
|
|
1261
1334
|
try { nodeFs.rmSync(_uploadDir(uploadId), { recursive: true, force: true }); }
|
|
1262
1335
|
catch (_e) { /* best-effort */ }
|
|
1263
1336
|
_emitObs("fileUpload.cancelled", 1);
|
package/lib/mail-server-jmap.js
CHANGED
|
@@ -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):
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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:
|
|
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
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:
|
|
5
|
+
"serialNumber": "urn:uuid:e0d3a558-1b7f-49b9-834a-79543f884cc8",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-06-
|
|
8
|
+
"timestamp": "2026-06-29T09:06:18.938Z",
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.15.56",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.15.
|
|
25
|
+
"version": "0.15.56",
|
|
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.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.15.56",
|
|
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.
|
|
57
|
+
"ref": "@blamejs/core@0.15.56",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|