@blamejs/core 0.15.56 → 0.15.58
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/file-upload.js +54 -18
- package/lib/http-message-signature.js +47 -1
- 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.58 (2026-06-29) — **File-upload content-safety scanning now also runs the gate for a file's magic-byte-detected type, so a mislabeled file can't dodge the scanner for its real type by choosing the extension.** b.fileUpload selected the per-extension content-safety gate purely from the upload's filename extension, which the uploader controls. A file whose magic bytes identify one type but whose name carries another extension (e.g. a PDF named photo.png) was therefore scanned by the gate for the named extension — or, when no gate was registered for that extension, not scanned at all — so an uploader could dodge the scanner configured for the file's real type by renaming it. When a fileType detector is wired, finalize now also runs the content-safety gate for the type the magic bytes identify, in addition to the filename-extension gate, so the scanner for the real type runs even under a mismatched name. Magic-byte-less text formats (HTML, SVG, CSV) cannot be classified this way; that residual is covered by serving uploads with an explicit Content-Type plus X-Content-Type-Options: nosniff and by registering a content-safety gate for every accepted extension. **Security:** *Content-safety gate selection follows the sniffed type, not just the filename extension* — finalize chose the content-safety gate from nodePath.extname(filename), so a file's real type could be hidden behind a chosen extension: a PDF named photo.png ran the .png gate (or, with no .png gate, skipped scanning) and never reached the .pdf scanner. When opts.fileType is wired, finalize now detects the magic-byte type and, if it differs from the filename extension and a gate is registered for it, runs that gate too (alongside the filename-extension gate), refusing or sanitizing per the gate's decision. Existing behavior is unchanged when no fileType detector is wired or the detected type matches the extension. Formats without magic bytes (HTML/SVG/CSV/plain text) remain undetectable by content sniffing — defend those by serving stored files with a fixed Content-Type and X-Content-Type-Options: nosniff, and by registering a content-safety gate for each accepted extension.
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
11
15
|
- 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
16
|
|
|
13
17
|
- 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.
|
package/lib/file-upload.js
CHANGED
|
@@ -1164,11 +1164,36 @@ function create(opts) {
|
|
|
1164
1164
|
}
|
|
1165
1165
|
if (contentSafety) {
|
|
1166
1166
|
var safetyExt = nodePath.extname(filename).toLowerCase();
|
|
1167
|
-
|
|
1168
|
-
|
|
1167
|
+
// Gates to run: the filename-extension gate, plus — when the magic bytes
|
|
1168
|
+
// confidently identify a DIFFERENT type — that type's gate too, so a
|
|
1169
|
+
// mislabeled magic-byte file (e.g. a PDF named .png) cannot dodge the
|
|
1170
|
+
// scanner for its real type by choosing the extension. Text formats with
|
|
1171
|
+
// no magic bytes (HTML/SVG/CSV) are not detectable here; that residual is
|
|
1172
|
+
// covered by the serving layer's Content-Type + X-Content-Type-Options:
|
|
1173
|
+
// nosniff and by registering a content gate for every accepted extension.
|
|
1174
|
+
var gateExts = [safetyExt];
|
|
1175
|
+
// Sniff from the reassembled body, or — for a streamed upload past
|
|
1176
|
+
// maxStreamReassemblyBytes (bodyBuffer null) — the first chunk already
|
|
1177
|
+
// read for the MIME-sniff gate. So a streamed mislabel still routes to its
|
|
1178
|
+
// real type's gate; and when that gate cannot scan the un-reassembled
|
|
1179
|
+
// body, the loop below surfaces it as a streamed-over-cap skip (not a
|
|
1180
|
+
// no-gate skip), so operators can alert rather than miss the bypass.
|
|
1181
|
+
var sniffBytes = bodyBuffer || firstChunk;
|
|
1182
|
+
if (fileType && sniffBytes) {
|
|
1183
|
+
var sniffed = fileType.detect(sniffBytes);
|
|
1184
|
+
if (sniffed && sniffed.extension) {
|
|
1185
|
+
var sniffedExt = "." + String(sniffed.extension).toLowerCase();
|
|
1186
|
+
if (sniffedExt !== safetyExt && gateExts.indexOf(sniffedExt) === -1) {
|
|
1187
|
+
gateExts.push(sniffedExt);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
// Run one gate; throws on refuse / gate-error, mutates bodyBuffer on
|
|
1192
|
+
// sanitize so a later gate sees the cleaned bytes.
|
|
1193
|
+
var _runContentSafetyGate = async function (gate, gateExt) {
|
|
1169
1194
|
var safetyDecision;
|
|
1170
1195
|
try {
|
|
1171
|
-
safetyDecision = await
|
|
1196
|
+
safetyDecision = await gate.check({
|
|
1172
1197
|
bytes: bodyBuffer,
|
|
1173
1198
|
filename: filename,
|
|
1174
1199
|
actor: actor,
|
|
@@ -1186,12 +1211,12 @@ function create(opts) {
|
|
|
1186
1211
|
"fileUpload.finalize: contentSafety gate threw: " + (gateErr && gateErr.message));
|
|
1187
1212
|
}
|
|
1188
1213
|
if (!safetyDecision.ok || safetyDecision.action === "refuse") {
|
|
1189
|
-
_emitObs("fileUpload.content_safety_refused", 1, { ext:
|
|
1214
|
+
_emitObs("fileUpload.content_safety_refused", 1, { ext: gateExt });
|
|
1190
1215
|
_emitAudit("fileUpload.finalize_failure", {
|
|
1191
1216
|
actor: requestHelpers.extractActorContext(actor),
|
|
1192
1217
|
outcome: "failure", reason: "content-safety-refused",
|
|
1193
1218
|
metadata: {
|
|
1194
|
-
uploadId: uploadId, ext:
|
|
1219
|
+
uploadId: uploadId, ext: gateExt,
|
|
1195
1220
|
issues: gateContract.summarizeIssues(safetyDecision.issues),
|
|
1196
1221
|
},
|
|
1197
1222
|
});
|
|
@@ -1200,26 +1225,37 @@ function create(opts) {
|
|
|
1200
1225
|
(safetyDecision.issues || []).map(function (i) { return i.kind; }).join(", ") + ")");
|
|
1201
1226
|
}
|
|
1202
1227
|
if (safetyDecision.action === "sanitize" && safetyDecision.sanitized) {
|
|
1203
|
-
// Replace the body buffer with the sanitized variant.
|
|
1204
1228
|
bodyBuffer = safetyDecision.sanitized;
|
|
1205
|
-
// Clear the streaming alias if present — sanitized fits in memory.
|
|
1206
1229
|
bodyStream = null;
|
|
1207
1230
|
}
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1231
|
+
};
|
|
1232
|
+
var ranAnyGate = false;
|
|
1233
|
+
var hasGateButNoBody = false;
|
|
1234
|
+
for (var ge = 0; ge < gateExts.length; ge += 1) {
|
|
1235
|
+
var gateForExt = contentSafety[gateExts[ge]];
|
|
1236
|
+
if (gateForExt && typeof gateForExt.check === "function") {
|
|
1237
|
+
if (bodyBuffer) {
|
|
1238
|
+
await _runContentSafetyGate(gateForExt, gateExts[ge]);
|
|
1239
|
+
ranAnyGate = true;
|
|
1240
|
+
} else {
|
|
1241
|
+
hasGateButNoBody = true;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
if (!ranAnyGate && hasGateButNoBody) {
|
|
1246
|
+
// A content-safety gate is configured, but the upload streamed past
|
|
1247
|
+
// maxStreamReassemblyBytes and was never reassembled into a buffer the
|
|
1248
|
+
// byte-level gate can inspect. The MIME-sniff and filename gates still
|
|
1249
|
+
// ran; the per-extension content gate did NOT. Audit the skip so
|
|
1250
|
+
// operators can alert, lower maxStreamReassemblyBytes, or cap
|
|
1215
1251
|
// maxFileBytes to force content-gating of this type.
|
|
1216
1252
|
_emitContentSafetySkipped(uploadId, actor, "streamed-over-reassembly-cap",
|
|
1217
1253
|
safetyExt, verified.totalBytes);
|
|
1218
|
-
} else {
|
|
1254
|
+
} else if (!ranAnyGate) {
|
|
1219
1255
|
// contentSafety is wired but no gate is registered for this file's
|
|
1220
|
-
// extension — the byte-level scan does not run.
|
|
1221
|
-
// a review can tell the upload bypassed content
|
|
1222
|
-
// register a gate for the extension if it should be
|
|
1256
|
+
// extension or its sniffed type — the byte-level scan does not run.
|
|
1257
|
+
// Audit the skip so a review can tell the upload bypassed content
|
|
1258
|
+
// scanning (and register a gate for the extension if it should be).
|
|
1223
1259
|
_emitContentSafetySkipped(uploadId, actor, "no-gate-for-extension",
|
|
1224
1260
|
safetyExt, verified.totalBytes);
|
|
1225
1261
|
}
|
|
@@ -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" };
|
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:35ef4bd7-07c5-42a5-b080-7b6950504f68",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-06-
|
|
8
|
+
"timestamp": "2026-06-29T12:31:38.470Z",
|
|
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.58",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.15.
|
|
25
|
+
"version": "0.15.58",
|
|
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.58",
|
|
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.58",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|