@blamejs/core 0.9.40 → 0.9.42
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 +2 -0
- package/lib/middleware/idempotency-key.js +85 -8
- package/lib/problem-details.js +43 -0
- package/lib/storage.js +12 -2
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.9.x
|
|
10
10
|
|
|
11
|
+
- v0.9.42 (2026-05-15) — **`b.middleware.idempotencyKey` `bodyFingerprint` hook + misordered-mount detector.** New `opts.bodyFingerprint: (req) => Buffer|string|object|null` lets operators supply a custom body extractor instead of relying on the default `req._rawBody || req.body` lookup; useful when the parsed-body shape needs canonicalization (sorted keys, stripped metadata) before the fingerprint hash so retry-with-equivalent-payload doesn't trip the §4.3 same-key-different-body refusal. Hook return is normalized to Buffer (Buffer passthrough; string → UTF-8 bytes; object/array → `JSON.stringify` → bytes; null/undefined → empty fingerprint). Throws inside the hook emit `idempotency.body_fingerprint_failed` audit (warning) and treat the body as empty. **Mount-order constraint:** idempotency must run AFTER body-parser; the hook reads request state at the moment idempotency executes, so a misordered mount silently degrades the fingerprint to method+path. `b.middleware.composePipeline` (v0.9.44) places bodyParser=20 / idempotency=30 by default. Body-bearing methods (POST/PUT/PATCH) that arrive without parsed-body OR raw-body data now emit `idempotency.empty_body_fingerprint` audit (warning) carrying `hasRawBody` / `hasParsedBody` / `hasFingerprintHook` so a misconfigured pipeline is detectable from audit logs.
|
|
12
|
+
- v0.9.41 (2026-05-15) — **Operator-friction ergonomic helpers surfaced from downstream-consumer gap audit.** Three small additive surfaces, no behavior change for existing callers. (1) **`b.storage.listBackends()`** now surfaces `rootDir` for local-protocol backends, sourced from the live backend (with config-reload propagation) so downstream path-traversal guards + scratch-dir derivation read the canonical path directly from the framework instead of re-deriving from operator-supplied opts. Remote protocols (sigv4 / gcs / azure-blob / http-put) don't carry a rootDir; the field stays absent for those. (2) **`b.problemDetails.send(res, fields)`** — bare wire-shape emit shortcut that lets routes migrate incrementally from inline `res.status(400).json({ error: ... })` to RFC 9457 problem-details without restructuring the handler around an error throw. Equivalent to `respond(res, create(fields))` in one call; same `application/problem+json` content type + `Cache-Control: no-store`. (3) **`b.mail.send` CR/LF/NUL refusal** confirmed already in place at `lib/mail.js:275` / `:309` / `:1808` per RFC 5321 §2.3.8 + RFC 5322 §3.2.5 header-injection defense — operators with inline `validateEmailAddr` wrappers can retire them. No new API, just confirmation that the existing primitive already covers the wire-protocol injection class (CVE-2026-32178 .NET System.Net.Mail header injection defended at the framework boundary).
|
|
11
13
|
- v0.9.40 (2026-05-15) — **`b.guardListId` — RFC 2919 List-Id header validator.** Companion to v0.9.39 `b.guardListUnsubscribe`; gates outbound mailing-list mail so the List-Id carries a well-formed identifier downstream filters + bulk-sender pipelines reliably route on. (1) **`b.guardListId.validate(headerValue, opts?)`** — parses bracketed (`<my-list.example.com>`), phrase-prefixed (`My Newsletter <my-list.example.com>`), and bare-identifier forms per RFC 2919 §2. Returns `{ action, listId, label, namespace, phrase, reason }`. Action one of `accept` / `refuse`. (2) **RFC 2919 §3 caps + ABNF** — list-id capped at 255 octets; header value capped at RFC 5322 §2.1.1 line cap (998 bytes); per-label shape per RFC 5322 §3.2.3 dot-atom-text. (3) **Phrase-smuggling defense** — phrase MUST NOT contain `<` / `>` (would smuggle a second bracketed identifier through the parser). Trailing content after `>` refused. Nested or unmatched brackets refused. (4) **CRLF / NUL / C0 / DEL refusal** — header-injection defense per RFC 5322 §3.2.5 + CVE-2026-32178 wire-protocol surface class. (5) **`localhost` namespace handling** (RFC 2919 §3) — strict requires the recommended 32-hex random component in the label (the SHOULD becomes operator-strict for HIPAA / PCI / GDPR / SOC2 postures); balanced / permissive accept without. (6) **FQDN namespace enforcement** under strict / balanced — list-id with single-label namespace (e.g. `mylist.test`) refused unless permissive. (7) Heuristic label / namespace split — last 2 dot-segments → namespace (matches typical DNS delegation); consumers needing PSL-accurate org-domain extraction compose `b.publicSuffix.organizationalDomain`. Three profiles + posture cascade (hipaa / pci-dss / gdpr / soc2 → strict). Fuzz harness ships in `fuzz/guard-list-id.fuzz.js`. Registered as standalone guard with `KIND="list-id"`. Threat-model: List-Id forging (RFC 2919 §8 explicitly notes the identifier is NOT an authentication signal; operators wanting authentication compose b.mail.auth.dmarc / arc.verify), bulk-sender bucket-drop (Gmail 2024 keys on List-Id presence for Precedence: list / 5000+ daily-send mail).
|
|
12
14
|
- v0.9.39 (2026-05-15) — **`b.guardListUnsubscribe` — RFC 2369 + RFC 8058 List-Unsubscribe / List-Unsubscribe-Post validator.** Gates the outbound submission path so messages carrying a List-Id (or any mailing-list shape) emit headers Gmail / Yahoo / Outlook one-click unsubscribe machinery actually accepts. (1) **`b.guardListUnsubscribe.validate({ listUnsubscribe, listUnsubscribePost }, opts?)`** — returns `{ action, reason, uris, hasHttpsUri, hasMailtoUri, postHeaderOk, oneClickReady }`. (2) **Gmail / Yahoo bulk-sender 2024 enforcement** — under strict requires at least one `https://` URI in the header (mailto: alone refused) + the paired `List-Unsubscribe-Post: List-Unsubscribe=One-Click` value EXACTLY (case-sensitive — Gmail silently fails one-click on mixed-case variants). (3) **Always-refused schemes** — `javascript:` / `data:` / `file:` / `vbscript:` / `blob:` refused regardless of profile (XSS / file-read class in mail-client rendering). (4) **`http://` refused under strict / balanced** — one-click endpoint MUST be TLS per RFC 8058 §2. Permissive accepts http for audit-only legacy use. (5) **Header-injection defense** — CRLF, NUL, C0 controls, DEL refused at validate time (RFC 5322 §3.2.5). (6) **Bounded surface** — per-URI byte cap (2 KiB strict / 4 KiB permissive), URI-count cap (4 / 8 / 16), header total byte cap (4 / 4 / 8 KiB). RFC 3986 §3.1 scheme shape; RFC 2369 §3.1 angle-bracket URI list. HTTPS URIs validated through `b.safeUrl.parse` with the framework's HTTPS allowlist. Three profiles + posture cascade (hipaa / pci-dss / gdpr / soc2 → strict). Fuzz harness ships in `fuzz/guard-list-unsubscribe.fuzz.js`. Registered as a standalone guard with KIND="list-unsubscribe". Threat-model coverage: unsubscribe-link injection via AI-generated newsletter templates, open-redirect via List-Unsubscribe (operator validates target host downstream via own safeRedirect allowlist), mail-client mishandling (Outlook's mailto: auto-fetch history).
|
|
13
15
|
- v0.9.38 (2026-05-15) — **Re-publish bundle: prefix npm tarball path with `./` so npm doesn't mis-classify it as a git spec.** v0.9.30 and v0.9.37 publish workflow runs both failed at exit 128 — npm 10+ interprets a relative tarball path containing `/` (`dist/blamejs-core-0.9.X.tgz`) as a git spec and attempts `git ls-remote ssh://git@github.com/dist/...tgz`, which the runner's SSH credentials can't auth against. v0.9.29-v0.9.37 never reached npm as a result; v0.9.28 remained the latest published version on the registry. v0.9.38 ships only the workflow path fix (no operator-facing primitive change vs v0.9.37) — operators upgrading from v0.9.28 see the full bundled surface delivered by v0.9.29-v0.9.37: agent.trace + agent.snapshot (v0.9.29 / v0.9.30), safeDns + network.dns.resolver (v0.9.31), guardSmtpCommand (v0.9.32), mail.rbl (v0.9.33), mail.greylist + lib/ip-utils (v0.9.34), mail.helo (v0.9.35), guardEnvelope (v0.9.36), guardDsn (v0.9.37).
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
var nodeCrypto = require("node:crypto");
|
|
44
44
|
var lazyRequire = require("../lazy-require");
|
|
45
45
|
var numericBounds = require("../numeric-bounds");
|
|
46
|
+
var validateOpts = require("../validate-opts");
|
|
46
47
|
var safeBuffer = require("../safe-buffer");
|
|
47
48
|
var safeJson = require("../safe-json");
|
|
48
49
|
var safeSql = require("../safe-sql");
|
|
@@ -458,6 +459,21 @@ function _emitAudit(action, metadata, outcome) {
|
|
|
458
459
|
* methods: string[], // default: ["POST","PUT","PATCH","DELETE"]
|
|
459
460
|
* headerName: string, // default: "idempotency-key"
|
|
460
461
|
* requireIdempotencyKey: boolean, // default: false — refuse missing-key
|
|
462
|
+
* bodyFingerprint: function, // (req) => Buffer|string|object|null — operator-supplied body extractor
|
|
463
|
+
* maxBodyBytes: number, // default: 1 MiB — replay-cache body cap
|
|
464
|
+
*
|
|
465
|
+
* **Mount order — idempotency MUST run AFTER body-parser.** The hook
|
|
466
|
+
* (and the default `req._rawBody||req.body` lookup) reads request
|
|
467
|
+
* state at the moment the idempotency middleware runs; if it runs
|
|
468
|
+
* before body-parser, `req.body` is still unset and the fingerprint
|
|
469
|
+
* silently degrades to method+path only — which fails the §4.3
|
|
470
|
+
* "same key, different body" guarantee. `b.middleware.composePipeline`
|
|
471
|
+
* places bodyParser=20 / idempotency=30 by default so the canonical
|
|
472
|
+
* order is correct; operators wiring middleware manually must mount
|
|
473
|
+
* idempotency AFTER bodyParser. The runtime emits
|
|
474
|
+
* `idempotency.empty_body_fingerprint` audit (warning) whenever a
|
|
475
|
+
* body-bearing request reaches the middleware with no body data,
|
|
476
|
+
* so the misordering is detectable from audit logs.
|
|
461
477
|
*
|
|
462
478
|
* @example
|
|
463
479
|
* var store = b.middleware.idempotencyKey.memoryStore({ maxEntries: 10000 });
|
|
@@ -465,6 +481,11 @@ function _emitAudit(action, metadata, outcome) {
|
|
|
465
481
|
* store: store,
|
|
466
482
|
* ttlMs: C.TIME.hours(24),
|
|
467
483
|
* methods: ["POST", "PUT", "PATCH"],
|
|
484
|
+
* // Optional: provide a body-fingerprint extractor that pulls
|
|
485
|
+
* // from the parsed body shape. The extractor only runs against
|
|
486
|
+
* // state populated by upstream middleware; mount idempotency
|
|
487
|
+
* // AFTER bodyParser (composePipeline does this by default).
|
|
488
|
+
* bodyFingerprint: function (req) { return req.body || null; },
|
|
468
489
|
* });
|
|
469
490
|
* app.use(mw);
|
|
470
491
|
*/
|
|
@@ -484,6 +505,20 @@ function create(opts) {
|
|
|
484
505
|
? opts.headerName.toLowerCase()
|
|
485
506
|
: "idempotency-key";
|
|
486
507
|
var requireKey = opts.requireIdempotencyKey === true;
|
|
508
|
+
// Operator-supplied body-fingerprint extractor. When provided,
|
|
509
|
+
// the middleware calls this instead of the inline
|
|
510
|
+
// `req._rawBody || req.body` lookup. Lets operators mount
|
|
511
|
+
// body-parser BEFORE the idempotency middleware and surface the
|
|
512
|
+
// parsed body shape (req.body is the typical post-parser
|
|
513
|
+
// attachment point); the inline lookup runs BEFORE body-parser
|
|
514
|
+
// by default, so the fingerprint silently degrades to
|
|
515
|
+
// method+path-only when body-parser mounts after. With this
|
|
516
|
+
// hook the middleware reads the body shape the operator
|
|
517
|
+
// canonically attached, regardless of mount order.
|
|
518
|
+
var bodyFingerprintFn = validateOpts.optionalFunction(
|
|
519
|
+
opts.bodyFingerprint, "idempotencyKey.bodyFingerprint",
|
|
520
|
+
IdempotencyError, "idempotency/bad-body-fingerprint"
|
|
521
|
+
) || null;
|
|
487
522
|
|
|
488
523
|
// Per-response collector cap. Idempotency replay only makes sense
|
|
489
524
|
// for response bodies that fit in memory; the cap is operator-
|
|
@@ -522,17 +557,59 @@ function create(opts) {
|
|
|
522
557
|
return problemDetails().respond(res, bad);
|
|
523
558
|
}
|
|
524
559
|
|
|
525
|
-
var bodyBytes
|
|
526
|
-
if (
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
// robust but the operator-attached body shape is whatever the
|
|
530
|
-
// upstream parser produced; canonicalization is operator-side.
|
|
560
|
+
var bodyBytes;
|
|
561
|
+
if (bodyFingerprintFn) {
|
|
562
|
+
// Operator-supplied hook — called after body-parser so req.body
|
|
563
|
+
// is populated. Hook returns Buffer / string / null.
|
|
531
564
|
try {
|
|
532
|
-
|
|
533
|
-
|
|
565
|
+
var fpVal = bodyFingerprintFn(req);
|
|
566
|
+
if (fpVal === null || fpVal === undefined) {
|
|
567
|
+
bodyBytes = null;
|
|
568
|
+
} else if (Buffer.isBuffer(fpVal)) {
|
|
569
|
+
bodyBytes = fpVal;
|
|
570
|
+
} else if (typeof fpVal === "string") {
|
|
571
|
+
bodyBytes = Buffer.from(fpVal, "utf8");
|
|
572
|
+
} else {
|
|
573
|
+
// Object / array — JSON-stringify so the hash is stable.
|
|
574
|
+
bodyBytes = Buffer.from(JSON.stringify(fpVal), "utf8");
|
|
575
|
+
}
|
|
576
|
+
} catch (e) {
|
|
577
|
+
_emitAudit("idempotency.body_fingerprint_failed",
|
|
578
|
+
{ error: String(e && e.message || e) }, "warning");
|
|
534
579
|
bodyBytes = null;
|
|
535
580
|
}
|
|
581
|
+
} else {
|
|
582
|
+
bodyBytes = req._rawBody || req.body || null;
|
|
583
|
+
if (bodyBytes && typeof bodyBytes === "object" && !Buffer.isBuffer(bodyBytes)) {
|
|
584
|
+
// Buffer-ize a non-buffer body (already-parsed JSON, etc.) so the
|
|
585
|
+
// hash is stable. JSON.stringify with sorted keys would be more
|
|
586
|
+
// robust but the operator-attached body shape is whatever the
|
|
587
|
+
// upstream parser produced; canonicalization is operator-side.
|
|
588
|
+
try {
|
|
589
|
+
bodyBytes = Buffer.from(JSON.stringify(bodyBytes), "utf8");
|
|
590
|
+
} catch (_e) {
|
|
591
|
+
bodyBytes = null;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Misordered-mount detector — body-bearing method reached us
|
|
597
|
+
// with neither a parsed body nor a raw-body buffer. Most likely
|
|
598
|
+
// body-parser hasn't run yet, which silently degrades the
|
|
599
|
+
// fingerprint to method+path. Emit a warning so the audit log
|
|
600
|
+
// surfaces the misconfiguration. (Genuinely empty POST bodies
|
|
601
|
+
// also trip this — acceptable cost; the audit field captures the
|
|
602
|
+
// distinction via `hasRawBody`/`hasParsedBody`.)
|
|
603
|
+
if (!bodyBytes && (method === "POST" || method === "PUT" || method === "PATCH")) {
|
|
604
|
+
_emitAudit("idempotency.empty_body_fingerprint",
|
|
605
|
+
{
|
|
606
|
+
method: method,
|
|
607
|
+
path: req.url,
|
|
608
|
+
hasRawBody: Boolean(req._rawBody),
|
|
609
|
+
hasParsedBody: req.body !== undefined && req.body !== null,
|
|
610
|
+
hasFingerprintHook: Boolean(bodyFingerprintFn),
|
|
611
|
+
},
|
|
612
|
+
"warning");
|
|
536
613
|
}
|
|
537
614
|
|
|
538
615
|
var fingerprint = _fingerprintRequest(req, bodyBytes);
|
package/lib/problem-details.js
CHANGED
|
@@ -358,6 +358,48 @@ function respond(res, problem) {
|
|
|
358
358
|
res.end(body);
|
|
359
359
|
}
|
|
360
360
|
|
|
361
|
+
/**
|
|
362
|
+
* @primitive b.problemDetails.send
|
|
363
|
+
* @signature b.problemDetails.send(res, fields)
|
|
364
|
+
* @since 0.9.41
|
|
365
|
+
* @status stable
|
|
366
|
+
* @related b.problemDetails.create, b.problemDetails.respond
|
|
367
|
+
*
|
|
368
|
+
* Build + emit a problem-details response in one call. Equivalent
|
|
369
|
+
* to `respond(res, create(fields))` but lets routes migrate
|
|
370
|
+
* incrementally from inline `res.status(400).json({ error: "..." })`
|
|
371
|
+
* shapes without restructuring the handler around an error throw.
|
|
372
|
+
*
|
|
373
|
+
* The same RFC 9457 §3 `application/problem+json` content type +
|
|
374
|
+
* `Cache-Control: no-store` are written; status code defaults to
|
|
375
|
+
* 500 when omitted.
|
|
376
|
+
*
|
|
377
|
+
* @opts
|
|
378
|
+
* status: number, // HTTP status code (100..599); default 500
|
|
379
|
+
* title: string, // operator-supplied short title
|
|
380
|
+
* detail: string, // operator-supplied human-readable explanation
|
|
381
|
+
* type: string, // problem-type URI (defaults to "about:blank")
|
|
382
|
+
* instance: string, // optional per-occurrence URI
|
|
383
|
+
* extensions: object, // operator-specific extension fields
|
|
384
|
+
*
|
|
385
|
+
* @example
|
|
386
|
+
* // Migrating from inline JSON-error shape:
|
|
387
|
+
* // res.status(400).json({ error: "Missing 'name' field" });
|
|
388
|
+
* // to RFC 9457 problem-details:
|
|
389
|
+
* b.problemDetails.send(res, {
|
|
390
|
+
* status: 400,
|
|
391
|
+
* title: "Missing required field",
|
|
392
|
+
* detail: "Body field 'name' is required",
|
|
393
|
+
* });
|
|
394
|
+
*/
|
|
395
|
+
function send(res, fields) {
|
|
396
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields)) {
|
|
397
|
+
throw new ProblemDetailsError("problem-details/bad-fields",
|
|
398
|
+
"send: fields must be a non-null object", true);
|
|
399
|
+
}
|
|
400
|
+
return respond(res, create(fields));
|
|
401
|
+
}
|
|
402
|
+
|
|
361
403
|
/**
|
|
362
404
|
* @primitive b.problemDetails.validate
|
|
363
405
|
* @signature b.problemDetails.validate(doc)
|
|
@@ -431,6 +473,7 @@ module.exports = {
|
|
|
431
473
|
create: create,
|
|
432
474
|
fromError: fromError,
|
|
433
475
|
respond: respond,
|
|
476
|
+
send: send,
|
|
434
477
|
validate: validate,
|
|
435
478
|
RESERVED_FIELDS: RESERVED_FIELDS,
|
|
436
479
|
ProblemDetailsError: ProblemDetailsError,
|
package/lib/storage.js
CHANGED
|
@@ -577,13 +577,23 @@ function listBackends() {
|
|
|
577
577
|
_requireInit();
|
|
578
578
|
var out = [];
|
|
579
579
|
for (var name in backends) {
|
|
580
|
-
|
|
580
|
+
var entry = {
|
|
581
581
|
name: name,
|
|
582
582
|
protocol: backends[name].protocol,
|
|
583
583
|
classifications: backends[name].classifications.slice(),
|
|
584
584
|
residencyTag: backends[name].residencyTag,
|
|
585
585
|
breakerState: backends[name].breaker.getState(),
|
|
586
|
-
}
|
|
586
|
+
};
|
|
587
|
+
// Surface the resolved local rootDir so downstream operators
|
|
588
|
+
// building path-traversal guards or scratch-dir layouts read the
|
|
589
|
+
// live path (with config-reload propagation) directly from the
|
|
590
|
+
// backend rather than re-deriving from operator-supplied opts.
|
|
591
|
+
// Remote protocols (sigv4 / gcs / azure-blob / http-put) don't
|
|
592
|
+
// have a rootDir; the field stays absent for those.
|
|
593
|
+
if (backends[name].protocol === "local" && backends[name].raw && typeof backends[name].raw.rootDir === "string") {
|
|
594
|
+
entry.rootDir = backends[name].raw.rootDir;
|
|
595
|
+
}
|
|
596
|
+
out.push(entry);
|
|
587
597
|
}
|
|
588
598
|
return out;
|
|
589
599
|
}
|
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.6",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:73f521a3-612c-45e1-8d73-dfeb13b9231f",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-15T15:59:31.588Z",
|
|
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.9.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.9.42",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.9.
|
|
25
|
+
"version": "0.9.42",
|
|
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.9.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.9.42",
|
|
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.9.
|
|
57
|
+
"ref": "@blamejs/core@0.9.42",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|