@blamejs/core 0.16.32 → 0.16.34
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/agent-snapshot.js +63 -6
- package/lib/crypto.js +51 -7
- package/lib/guard-image.js +24 -5
- package/lib/guard-pdf.js +9 -1
- package/lib/mail-auth.js +32 -0
- package/lib/mail-dkim.js +11 -0
- package/lib/middleware/dpop.js +10 -5
- 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.16.x
|
|
10
10
|
|
|
11
|
+
- v0.16.34 (2026-07-16) — **The DPoP middleware returns the correct multiple-proof rejection when a request carries a repeated DPoP header, instead of mislabeling it as a missing proof.** RFC 9449 §4.1 permits only one DPoP header value per request. b.middleware.dpop rejected a request that carried the header as an array -- repeated DPoP: lines a custom server or proxy did not collapse -- but its array-shape check sat after the missing-header guard, and an array is not a string, so the missing-header guard always ran first. A duplicated DPoP proof was therefore rejected as a missing proof (and, when a DPoP nonce was required, answered with use_dpop_nonce, prompting the client into a pointless nonce-retry loop) rather than with the invalid_dpop_proof / multiple-DPoP-headers rejection the specification calls for. Both paths already refused the request, so this was never a fail-open -- only an incorrect diagnostic and a wasted round trip. The array-shape check now runs before the missing-header guard, so a repeated DPoP header is rejected with the correct error. **Fixed:** *DPoP middleware rejects a repeated DPoP header with the correct multiple-proof error* — b.middleware.dpop enforces the RFC 9449 §4.1 single-value rule, but its Array.isArray check for a repeated header (when a server or proxy delivered the DPoP header as an array rather than a comma-joined string) sat after the non-string / empty guard. Because an array fails the non-string check first, the dedicated multiple-DPoP-headers branch never ran: a duplicated proof was reported as a missing DPoP header, and under a required-nonce policy it returned use_dpop_nonce, driving the client into a fruitless nonce-retry loop. The array-shape check now runs first, so a repeated DPoP header is rejected with invalid_dpop_proof and a multiple-DPoP-headers message. This changes only the error code and message for that malformed-request case; both orderings already refused the request, so no valid request is affected.
|
|
12
|
+
|
|
13
|
+
- v0.16.33 (2026-07-16) — **Agent-snapshot restore now authenticates a sealed snapshot's tenant and capture-time against its signature, closing a cross-tenant restore path, alongside fail-closed and never-throw hardening across the DKIM/ARC, crypto-envelope, and image/PDF verifiers.** A sealed agent snapshot's authenticated envelope binds its table, snapshot id, and schema version, but the decorative wrapper fields a hostile or compromised storage backend can rewrite -- the tenant id that loadLatest filters on and the capture time it sorts on -- were trusted without being checked against the signed body. b.agent.snapshot restore now cross-checks both wrapper fields against the signature-covered values and refuses a mismatch, so a relabelled row can no longer surface one tenant's authentic snapshot to another (cross-tenant restore) or misrepresent when the restored state was captured. The release also hardens several verifiers that a valid-but-unusual or hostile input could push off their documented contract: the DKIM, inbound-authentication, and ARC verifiers now accept a bare-LF (Unix line-ending) message and return an authentication verdict instead of throwing; the crypto envelope and packed-secret decoders reject a truncated ciphertext with their typed error instead of leaking a raw cipher exception; the image guard routes a byte-order-mark-prefixed SVG to refusal at every profile instead of serving it as unknown content; and the image and PDF guards no longer throw on a hostile metadata bag whose bytes field is an array-like object, honoring their never-throw inspection contract. **Fixed:** *DKIM, inbound-authentication, and ARC verifiers accept bare-LF messages instead of throwing* — b.mail.dkim.verify, b.mail.inbound.verify, and b.mail.arc.verify canonicalize over CRLF and split the header block on a CRLF-CRLF separator. A message read from a Unix file or mbox, or passed through operator tooling that stripped carriage returns, arrives with bare-LF line endings and previously raised an uncaught error out of the verifier rather than returning an authentication verdict. The header/body split now normalizes bare-LF to canonical CRLF before locating the separator (a no-op on a proper CRLF message), so a bare-LF message produces a verdict -- and a message signed on the CRLF wire but transported bare-LF now verifies correctly rather than failing. inbound.verify and arc.verify additionally treat a message with no separator at all as headers-only, returning a verdict in keeping with their always-return-a-verdict contract. · *Crypto envelope and packed-secret decoders reject truncated ciphertext with a typed error* — b.crypto.decryptEnvelope and b.crypto.decryptPacked verify that each declared component of an untrusted ciphertext -- the length-prefixed KEM ciphertext and hybrid ephemeral public key, and the trailing nonce and authentication tag -- fits within the envelope before handing it to the cipher or the key-agreement step. A ciphertext truncated inside any of those components previously reached Node crypto as an under-length value and surfaced as a raw exception (a cipher RangeError on the nonce, or a Failed to perform decapsulation / key-parse error on the KEM ciphertext or ephemeral key), escaping the documented Invalid envelope error contract and leaking implementation detail. Both decoders now reject a truncated input with their typed Invalid envelope / Invalid packed format error, while a truncation inside the ciphertext body still surfaces as the genuine authentication-tag failure; a well-formed input is never affected. · *Image guard routes a BOM-prefixed SVG to refusal at every profile* — b.guardImage detects SVG by its leading markup so it can route it to the SVG guard or refuse it. A UTF-8 byte-order-mark before the markup previously defeated the offset-anchored signature scan, so a BOM-prefixed SVG fell through as unknown content -- served rather than refused under the balanced and permissive profiles. The magic-byte scanner now skips a leading BOM when matching the SVG and XML signatures, so a BOM-prefixed SVG is detected and refused at every profile. The BOM skip applies only to those text-family signatures: a binary raster's magic must sit at its real offset, so a BOM-prefixed PNG or JPEG is still refused as unknown content rather than accepted as a valid raster. · *Image and PDF guards no longer throw on a hostile array-like metadata bag* — b.guardImage.validate and b.guardPdf.validate document pure inspection that never throws on hostile metadata. Their byte-size cap measured any value carrying a numeric length, but the measurement primitive accepts only strings, Buffers, and Uint8Arrays and threw on a plain Array or array-like object -- crashing a direct validate or sanitize caller (the gate path already fails closed). The cap now measures only those byte-carrying types and passes an unmeasurable array-like through to magic detection, which reads only the leading bytes and refuses unrecognized content, so validate returns a refusal instead of throwing. **Security:** *Agent-snapshot restore binds the requested tenant and capture-time to the sealed snapshot's signature* — b.agent.snapshot seals each snapshot under an authenticated envelope whose AAD binds the table, snapshot id, and schema version. The metadata a backend stores alongside the sealed blob -- the tenant id that loadLatest({ tenantId }) filters on and the takenAt it sorts on to pick the latest -- is not covered by that AAD, and a hostile or compromised backend can return independently tampered list() and get() results. A backend could therefore relabel tenant A's list() entry as tenant B (leaving A's get() row honest) so that loadLatest({ tenantId: 'tenant-b' }) selected and returned tenant A's authentic snapshot -- a cross-tenant restore of in-flight sagas, streams, and idempotency state -- or inflate a row's list() age to serve an older snapshot as the latest. loadLatest now binds the requested selection criteria to the loaded snapshot's signature-covered values: the authenticated tenant id must equal the requested tenant id, and the list() sort key that selected a row must equal that row's authenticated capture time; a divergence is refused (agent-snapshot/tenant-id-mismatch, agent-snapshot/taken-at-mismatch), and the load path additionally cross-checks the get() wrapper against the signed body. The fix is at load time and does not change the seal format, so previously persisted snapshots remain restorable. A hostile backend can still withhold snapshots it never reveals, but every snapshot returned is authentic and bound to the requested tenant. **Detectors:** *A byte-size cap must vet its input type before measuring it* — A codebase-patterns gate refuses a guard that measures a metadata bag's byte length gated only on a numeric length property -- the shape that let an array-like bytes field crash the image and PDF guards. A byte-size cap over untrusted metadata must confirm the value is a string, Buffer, or Uint8Array before measuring it, so a future guard cannot reintroduce the never-throw-contract violation.
|
|
14
|
+
|
|
11
15
|
- v0.16.32 (2026-07-15) — **The vendored Public Suffix List is refreshed to the current upstream snapshot, and vendor-update.sh gains the --refresh-data mode its file headers and verifier messages have always pointed at.** The vendored Mozilla Public Suffix List is updated to the latest upstream build, so organizational-domain derivation for DMARC alignment, BIMI issuer scoping, cookie-scope confinement, and same-site policy reflects the current registry delegations; the data module is regenerated and re-signed (SHA-256 + SHA3-512 + SLH-DSA), the manifest hashes refreshed, and the NOTICE attribution date updated. The refresh itself now runs through scripts/vendor-update.sh --refresh-data — the maintenance command that every vendored data file's header and lib/vendor-data.js's tamper-error messages reference — which fetches the upstream where one exists, sanity-checks the body before anything reaches the signer, re-appends the in-payload integrity canary, regenerates and re-signs the .data.js carrier, updates the manifest and NOTICE dates, and verifies all four integrity layers. A codebase-patterns gate now refuses any script flag referenced from error messages, file headers, or operator docs that the target script does not implement. **Added:** *scripts/vendor-update.sh --refresh-data — fetch, canary, re-sign, and verify the vendored data files in one command* — The mode that vendored data-file headers and lib/vendor-data.js tamper-error messages direct operators to now exists. `vendor-update.sh --refresh-data [entry]` refreshes the Public Suffix List and the SecLists common-password corpus from their upstreams (refusing a truncated or error body before it can reach the signer), re-appends each file's in-payload integrity canary, regenerates and re-signs the .data.js carrier with the operator-local SLH-DSA key whenever the raw file changed OR the carrier fails four-layer verification (so a corrupted signature block or a stripped provenance header is repairable through the documented path), updates the MANIFEST bundledAt and NOTICE attribution dates, refreshes the manifest hashes, and finishes by running the four-layer verifier (SHA-256 + SHA3-512 + SLH-DSA signature + canary) over every vendored data file. The operator-managed BIMI trust-anchor bundle is never fetched — it is re-signed only when the local .pem was edited per its file-header procedure. A Public Suffix List fetch whose VERSION timestamp is older than the vendored one is refused rather than signed (the list is CDN-served, and a lagging edge can return an older snapshot than the one already vendored). Entries whose upstream is unchanged are left byte-identical, so a no-op refresh produces a clean working tree; in-flight downloads live in gitignored *.refresh-tmp files that are removed on exit. **Changed:** *Vendored Public Suffix List refreshed to the current upstream snapshot* — The vendored Mozilla Public Suffix List is updated to the latest upstream build (2026-07-15), so organizational-domain derivation for DMARC alignment, BIMI issuer scoping, cookie-scope confinement, and same-site policy reflects the current registry delegations. The data module is regenerated and re-signed (SHA-256 + SHA3-512 + SLH-DSA), the manifest hashes refreshed, and the NOTICE attribution date updated. **Detectors:** *Script flags referenced from error messages, file headers, and operator docs must exist in the target script* — A codebase-patterns gate resolves every `<script>.sh --flag` / `<script>.js --flag` reference in lib/, scripts/, and the operator docs against the scripts/ directory and refuses any flag with no whole-token occurrence outside comment lines in the target script — a usage-header comment alone does not count as an implementation. A maintenance command recommended by a verification-failure message or a stale-data gate must work when the operator reaches for it.
|
|
12
16
|
|
|
13
17
|
- v0.16.31 (2026-07-13) — **Vendored library bundles ship as unminified, reviewable source at the same pinned versions, removing the last dynamic-code-execution shims from the package.** The five vendored library bundles -- @noble/ciphers 2.2.0, @noble/curves 2.2.0, @noble/post-quantum 0.6.1, @simplewebauthn/server 13.3.2, and the @peculiar/x509 2.0.0 + pkijs 3.4.0 PKI meta-bundle -- now ship as unminified esbuild output of the same pinned upstream versions, so an operator can open any lib/vendor/*.cjs and read it, or diff it against upstream at the MANIFEST-pinned version, instead of auditing multi-hundred-KB minified lines. Their reflect-metadata dependency now resolves to upstream's lite build, which drops the legacy global-object probes (Function("return this") and indirect eval) that could never execute on supported Node versions -- the published package now contains no eval or Function-constructor construct outside the documented worker-thread sandbox compiler. The vendor pipeline records the exact build invocation in MANIFEST.json, can rebuild the PKI meta-bundle at pinned component versions, and a codebase-patterns gate refuses any future refresh that reintroduces minified or dynamic-execution output under lib/vendor/. The repository supply-chain policy adds audited dispositions for shell, filesystem, and debug access, re-enables the eval alert class now that the package contains none, and narrows the minified-code disposition to the signed data payload carriers -- the code bundles themselves are enforced unminified by an in-repo gate. **Changed:** *Vendored bundles are unminified, reviewable esbuild output at the same pinned upstream versions* — lib/vendor/noble-ciphers.cjs, noble-curves.cjs, noble-post-quantum.cjs, simplewebauthn-server.cjs, and pki.cjs are rebuilt without minification from the same pinned upstream versions recorded in lib/vendor/MANIFEST.json (@noble/ciphers 2.2.0, @noble/curves 2.2.0, @noble/post-quantum 0.6.1, @simplewebauthn/server 13.3.2, @peculiar/x509 2.0.0 + pkijs 3.4.0). Exports, versions, and runtime behavior are unchanged; upstream license headers and esbuild's bundled-license footers are preserved. The five bundles grow from roughly 1.2 MB to 2.7 MB on disk (the npm tarball transfers compressed), in exchange for source an operator or scanner can actually review and diff against upstream. · *reflect-metadata inside the WebAuthn and PKI bundles resolves to upstream's lite build, removing Function("return this") and indirect-eval global probes* — The @simplewebauthn/server and PKI meta-bundles pull in reflect-metadata (decorator metadata for their ASN.1 schema serializers). The bundles now resolve it to the package's own ./lite entry at the same pinned version: an identical metadata API with the same cross-copy registry protocol, built for runtimes with native globalThis -- it contains none of the legacy global-object probes (Function("return this") / indirect eval) that were unreachable dead code on the framework's Node floor anyway. With this change the published package contains no eval, indirect eval, or Function-constructor construct outside b.sandbox's worker-thread compiler, which is the documented isolation boundary for operator-submitted code. · *Vendor pipeline: pinned PKI meta-bundle rebuilds, and the manifest bundler field derives from the actual build invocation* — scripts/vendor-update.sh now accepts the PKI meta-bundle's manifest version form directly (for example: vendor-update.sh peculiar-pki "2.0.0+pkijs-3.4.0") and installs exactly those @peculiar/x509 and pkijs component versions, where previously it could only bundle latest. The bundler field in lib/vendor/MANIFEST.json is now written by the script from the esbuild invocation that actually produced the artifact, so the recorded build command can no longer drift from the real one. · *Package-scanner policy adds shell, filesystem, and debug access dispositions and re-enables the eval alert class* — socket.yml documents why shell access (all process execution goes through b.processSpawn -- fixed binary, argument array, never shell:true), filesystem access, and debug access (no debugger statements or node:inspector in first-party code; Reflect metadata in the vendored ASN.1 serializers is reflection, not code execution) are inherent to a server framework. The eval alert class is deliberately no longer suppressed: the package contains no eval or Function-constructor construct outside the documented sandbox worker, so a future occurrence is a genuine regression signal rather than recurring noise. The minified-code class stays dispositioned, narrowed to the signed data payload carriers (lib/vendor/*.data.js): minified-code detectors also fire on high-density embedded-asset files, which those carriers are by design -- a 76-char-wrapped base64 payload plus a multi-KB signature line, verified at every load. The unminified property of the code bundles is enforced by the repository's own gate, not by the scanner class. **Detectors:** *Vendored bundles must stay unminified and free of dynamic-code-execution constructs* — A codebase-patterns gate walks every JS artifact under lib/vendor/ and refuses minified bundle output (whole-file average line length, with wide margins around the measured unminified and minified populations) and any eval, indirect-eval, Function-constructor, createRequire, or process.binding token. A vendor refresh that reintroduces --minify or an upstream global-object eval probe fails the gate before it can ship. · *Scanner policy keeps the minified-code class dispositioned while signed data carriers ship* — A companion gate refuses removing socket.yml's minifiedFile disposition while lib/vendor/*.data.js payload carriers ship: minified-code detectors match the carriers' high-density base64-plus-signature shape, and an omitted issue rule falls back to dashboard defaults, so dropping the entry would re-report the known-benign artifacts on every routine scan.
|
package/lib/agent-snapshot.js
CHANGED
|
@@ -457,9 +457,12 @@ async function _persist(ctx, snap) {
|
|
|
457
457
|
snap.sigPubKey = (typeof signer.getPublicKey === "function" && signer.getPublicKey()) || null;
|
|
458
458
|
|
|
459
459
|
// Seal the entire envelope under AAD that pins
|
|
460
|
-
//
|
|
460
|
+
// table + snapshotId + schemaVersion. AAD mismatch on unseal (a
|
|
461
461
|
// copy-paste attack from one snapshotId's row into another) fails
|
|
462
|
-
// the Poly1305 tag check; tampered bytes also fail. The
|
|
462
|
+
// the Poly1305 tag check; tampered bytes also fail. The tenantId is
|
|
463
|
+
// authenticated by the signature (it is a signed field) and the
|
|
464
|
+
// wrapper's decorative tenantId is cross-checked against the signed
|
|
465
|
+
// body at load, so it is not additionally bound in the AAD. The sealed
|
|
463
466
|
// string is what reaches durable storage.
|
|
464
467
|
var sealer = _resolveSealer(ctx);
|
|
465
468
|
var serialized = safeJson.stringify(snap);
|
|
@@ -475,7 +478,9 @@ async function _persist(ctx, snap) {
|
|
|
475
478
|
// implementation needs to filter by tenantId / takenAt without
|
|
476
479
|
// having to unseal every row. Sealed-blob carries the full
|
|
477
480
|
// envelope; the metadata is decorative + may be tamper-fuzzed by
|
|
478
|
-
// a hostile backend
|
|
481
|
+
// a hostile backend. snapshotId is bound by the AAD (unseal fails on
|
|
482
|
+
// a swap) and tenantId is cross-checked against the signed body at
|
|
483
|
+
// load, so a relabelled wrapper is refused rather than trusted.
|
|
479
484
|
stored = {
|
|
480
485
|
snapshotId: snap.snapshotId,
|
|
481
486
|
takenAt: snap.takenAt,
|
|
@@ -547,6 +552,31 @@ async function _unwrapAndVerify(ctx, raw, expectedId) {
|
|
|
547
552
|
throw new AgentSnapshotError("agent-snapshot/snapshot-id-mismatch",
|
|
548
553
|
"load: wrapper snapshotId='" + expectedId + "' does not match envelope='" + snap.snapshotId + "'");
|
|
549
554
|
}
|
|
555
|
+
// The wrapper's decorative tenantId is what the backend's list() /
|
|
556
|
+
// loadLatest tenant filter selects on, but it is NOT bound by the seal's
|
|
557
|
+
// AAD (which pins table + snapshotId + schemaVersion). A hostile backend
|
|
558
|
+
// can therefore relabel a sealed row's wrapper tenantId while the
|
|
559
|
+
// Poly1305 tag still verifies — so loadLatest({ tenantId }) would return
|
|
560
|
+
// a DIFFERENT tenant's authentic snapshot (cross-tenant restore).
|
|
561
|
+
// Cross-check the untrusted wrapper tenantId against the signed body's
|
|
562
|
+
// tenantId and refuse the mismatch, mirroring the snapshotId gate above;
|
|
563
|
+
// the inner value is the authentic one (covered by the signature).
|
|
564
|
+
if ((raw.tenantId || null) !== (snap.tenantId || null)) {
|
|
565
|
+
throw new AgentSnapshotError("agent-snapshot/tenant-id-mismatch",
|
|
566
|
+
"load: wrapper tenantId='" + (raw.tenantId || null) +
|
|
567
|
+
"' does not match envelope tenantId='" + (snap.tenantId || null) + "'");
|
|
568
|
+
}
|
|
569
|
+
// Same class as the tenantId/snapshotId gates: the wrapper takenAt is the
|
|
570
|
+
// field loadLatest sorts on to pick "latest", but it is not AAD-bound, so a
|
|
571
|
+
// hostile backend could relabel a returned row's age (misrepresenting when
|
|
572
|
+
// the restored state was captured — an audit-integrity / rollback signal).
|
|
573
|
+
// takenAt is a signed body field, so cross-check the untrusted wrapper value
|
|
574
|
+
// against it and refuse a divergence; the inner value is the authentic one.
|
|
575
|
+
if ((raw.takenAt || null) !== (snap.takenAt || null)) {
|
|
576
|
+
throw new AgentSnapshotError("agent-snapshot/taken-at-mismatch",
|
|
577
|
+
"load: wrapper takenAt='" + (raw.takenAt || null) +
|
|
578
|
+
"' does not match envelope takenAt='" + (snap.takenAt || null) + "'");
|
|
579
|
+
}
|
|
550
580
|
// Verify the signature before returning the envelope
|
|
551
581
|
// to the caller. Restore-side trust derives from this gate. The
|
|
552
582
|
// allowPlaintext escape hatch (operator-acknowledged dev mode)
|
|
@@ -595,9 +625,36 @@ async function _loadLatest(ctx, loadOpts) {
|
|
|
595
625
|
});
|
|
596
626
|
if (filtered.length === 0) return null;
|
|
597
627
|
filtered.sort(function (a, b) { return (b.takenAt || 0) - (a.takenAt || 0); });
|
|
598
|
-
var
|
|
599
|
-
var raw = await ctx.backend.get(
|
|
600
|
-
|
|
628
|
+
var selected = filtered[0];
|
|
629
|
+
var raw = await ctx.backend.get(selected.snapshotId);
|
|
630
|
+
var snap = await _unwrapAndVerify(ctx, raw, selected.snapshotId);
|
|
631
|
+
// list() and get() are independently-tamperable untrusted backend surfaces.
|
|
632
|
+
// _unwrapAndVerify only proves the get() wrapper agrees with the signed body;
|
|
633
|
+
// it does NOT prove the list() metadata used to FILTER and SELECT this row is
|
|
634
|
+
// honest. A backend that relabels tenant A's list() entry as tenant B while
|
|
635
|
+
// leaving A's get() row untouched would pass that wrapper/body check yet
|
|
636
|
+
// still surface A's authentic snapshot for a tenant-B query. So bind the
|
|
637
|
+
// REQUESTED selection criteria to the authenticated body: the loaded signed
|
|
638
|
+
// tenantId must equal the requested tenantId.
|
|
639
|
+
if (loadOpts.tenantId && (snap.tenantId || null) !== loadOpts.tenantId) {
|
|
640
|
+
throw new AgentSnapshotError("agent-snapshot/tenant-id-mismatch",
|
|
641
|
+
"loadLatest: requested tenantId='" + loadOpts.tenantId +
|
|
642
|
+
"' does not match the loaded snapshot's authenticated tenantId='" +
|
|
643
|
+
(snap.tenantId || null) + "'");
|
|
644
|
+
}
|
|
645
|
+
// The list() sort key that selected this row must match its authenticated
|
|
646
|
+
// takenAt, so a backend cannot inflate a row's list() age to win the "latest"
|
|
647
|
+
// sort while get() returns the honest (older) body. A backend can still
|
|
648
|
+
// withhold or reorder rows it never reveals -- an inherent freshness limit of
|
|
649
|
+
// an untrusted store -- but every RETURNED snapshot is authentic, tenant-
|
|
650
|
+
// bound, and carries its own authenticated capture time.
|
|
651
|
+
if ((selected.takenAt || null) !== (snap.takenAt || null)) {
|
|
652
|
+
throw new AgentSnapshotError("agent-snapshot/taken-at-mismatch",
|
|
653
|
+
"loadLatest: list() takenAt='" + (selected.takenAt || null) +
|
|
654
|
+
"' for the selected snapshot does not match its authenticated takenAt='" +
|
|
655
|
+
(snap.takenAt || null) + "'");
|
|
656
|
+
}
|
|
657
|
+
return snap;
|
|
601
658
|
}
|
|
602
659
|
|
|
603
660
|
async function _loadById(ctx, snapshotId) {
|
package/lib/crypto.js
CHANGED
|
@@ -1369,6 +1369,24 @@ function _envU16(buf, at) {
|
|
|
1369
1369
|
return buf.readUInt16BE(at);
|
|
1370
1370
|
}
|
|
1371
1371
|
|
|
1372
|
+
// Read a 2-byte-length-prefixed component and bounds-check that the declared
|
|
1373
|
+
// body actually fits in the envelope BEFORE slicing it. Without this a
|
|
1374
|
+
// truncated envelope hands an under-length component (a short KEM ciphertext /
|
|
1375
|
+
// ephemeral public key) to Node crypto, which throws a raw
|
|
1376
|
+
// "Failed to perform decapsulation" / key-parse error that escapes the
|
|
1377
|
+
// documented "Invalid envelope: ..." contract. Returns { bytes, pos } where
|
|
1378
|
+
// pos is the offset past the component. A well-formed envelope always carries
|
|
1379
|
+
// each declared component in full, so this never rejects a valid input.
|
|
1380
|
+
function _envSlice(buf, at, label) {
|
|
1381
|
+
var len = _envU16(buf, at);
|
|
1382
|
+
at += 2;
|
|
1383
|
+
if (at + len > buf.length) {
|
|
1384
|
+
throw new Error("Invalid envelope: truncated (declared " + len + "-byte " +
|
|
1385
|
+
label + " at offset " + at + " exceeds the " + buf.length + "-byte envelope)");
|
|
1386
|
+
}
|
|
1387
|
+
return { bytes: buf.subarray(at, at + len), pos: at + len };
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1372
1390
|
function decryptEnvelope(packed, privateKeys, internalOpts) {
|
|
1373
1391
|
if (!Buffer.isBuffer(packed) || packed.length < 4) {
|
|
1374
1392
|
throw new Error("Invalid envelope: too short (need at least the 4-byte suite header)");
|
|
@@ -1388,8 +1406,8 @@ function decryptEnvelope(packed, privateKeys, internalOpts) {
|
|
|
1388
1406
|
throw new Error("Invalid envelope: unsupported KDF (only SHAKE256 supported)");
|
|
1389
1407
|
}
|
|
1390
1408
|
|
|
1391
|
-
var
|
|
1392
|
-
var kemCt =
|
|
1409
|
+
var kemSlice = _envSlice(packed, pos, "KEM ciphertext");
|
|
1410
|
+
var kemCt = kemSlice.bytes; pos = kemSlice.pos;
|
|
1393
1411
|
|
|
1394
1412
|
var mlkemPriv = nodeCrypto.createPrivateKey(
|
|
1395
1413
|
typeof privateKeys === "string" ? privateKeys : privateKeys.privateKey
|
|
@@ -1399,8 +1417,8 @@ function decryptEnvelope(packed, privateKeys, internalOpts) {
|
|
|
1399
1417
|
var fixedInfo = omitFixedInfo ? Buffer.alloc(0) : _suiteFixedInfo(kemId, cipherId, kdfId);
|
|
1400
1418
|
|
|
1401
1419
|
if (kemId === C.KEM_IDS.ML_KEM_1024_P384) {
|
|
1402
|
-
var
|
|
1403
|
-
var ecEphDer =
|
|
1420
|
+
var ecEphSlice = _envSlice(packed, pos, "EC ephemeral public key");
|
|
1421
|
+
var ecEphDer = ecEphSlice.bytes; pos = ecEphSlice.pos;
|
|
1404
1422
|
var ecPrivPem = typeof privateKeys === "string" ? null : privateKeys.ecPrivateKey;
|
|
1405
1423
|
if (!ecPrivPem) throw new Error("Hybrid KEM requires EC private key");
|
|
1406
1424
|
var ecSs = nodeCrypto.diffieHellman({
|
|
@@ -1416,8 +1434,8 @@ function decryptEnvelope(packed, privateKeys, internalOpts) {
|
|
|
1416
1434
|
// the correct keypair via privateKeys when the envelope was sealed
|
|
1417
1435
|
// with this algorithm. Same length-prefixed shape as the P-384
|
|
1418
1436
|
// hybrid: 2-byte ec-eph-len + DER X25519 pubkey + nonce + ct.
|
|
1419
|
-
var
|
|
1420
|
-
var x25519EphDer =
|
|
1437
|
+
var x25519EphSlice = _envSlice(packed, pos, "X25519 ephemeral public key");
|
|
1438
|
+
var x25519EphDer = x25519EphSlice.bytes; pos = x25519EphSlice.pos;
|
|
1421
1439
|
var x25519PrivPem = typeof privateKeys === "string" ? null : privateKeys.x25519PrivateKey;
|
|
1422
1440
|
if (!x25519PrivPem) throw new Error("ML-KEM-768 + X25519 hybrid envelope requires x25519PrivateKey");
|
|
1423
1441
|
var x25519Ss = nodeCrypto.diffieHellman({
|
|
@@ -1429,7 +1447,22 @@ function decryptEnvelope(packed, privateKeys, internalOpts) {
|
|
|
1429
1447
|
throw new Error("Invalid envelope: unsupported KEM ID " + kemId);
|
|
1430
1448
|
}
|
|
1431
1449
|
|
|
1432
|
-
|
|
1450
|
+
// Bounds-check the trailing nonce + AEAD tag before slicing them out. A
|
|
1451
|
+
// truncated envelope (untrusted ciphertext) that ends inside the 24-byte
|
|
1452
|
+
// nonce or before the 16-byte Poly1305 tag otherwise reaches the cipher as
|
|
1453
|
+
// an under-length nonce / ciphertext and surfaces as a raw noble
|
|
1454
|
+
// RangeError ("nonce"/"ciphertext" length) — escaping the documented
|
|
1455
|
+
// "Invalid envelope: ..." error contract exactly the way an unchecked
|
|
1456
|
+
// _envU16 read would (see _envU16 above). Fail with the typed envelope
|
|
1457
|
+
// error instead. A valid envelope always carries a full nonce and at
|
|
1458
|
+
// least the tag, so this never rejects a well-formed input.
|
|
1459
|
+
var nonceLen = C.BYTES.bytes(24);
|
|
1460
|
+
var tagLen = C.BYTES.bytes(16);
|
|
1461
|
+
if (pos + nonceLen + tagLen > packed.length) {
|
|
1462
|
+
throw new Error("Invalid envelope: truncated (expected a " + nonceLen +
|
|
1463
|
+
"-byte nonce and a " + tagLen + "-byte authentication tag at offset " + pos + ")");
|
|
1464
|
+
}
|
|
1465
|
+
var nonce = packed.subarray(pos, pos + nonceLen); pos += nonceLen;
|
|
1433
1466
|
// Re-derive the 4-byte envelope-header AAD from the bytes we just
|
|
1434
1467
|
// dispatched on. A tampered header (algorithm-substitution attack)
|
|
1435
1468
|
// surfaces here as a Poly1305 tag verification failure.
|
|
@@ -1509,6 +1542,17 @@ function decryptPacked(packed, key, aad) {
|
|
|
1509
1542
|
if (packed[0] !== C.FORMAT.XCHACHA20_POLY1305) {
|
|
1510
1543
|
throw new Error("Invalid packed format: unsupported version");
|
|
1511
1544
|
}
|
|
1545
|
+
// A packed blob shorter than the 1-byte format id + 24-byte nonce +
|
|
1546
|
+
// 16-byte Poly1305 tag is truncated / corrupt (untrusted or damaged
|
|
1547
|
+
// storage cell); slicing the nonce / ciphertext out of it otherwise
|
|
1548
|
+
// reaches the cipher as an under-length nonce and throws a raw noble
|
|
1549
|
+
// RangeError that escapes this function's typed error contract (mirrors
|
|
1550
|
+
// the envelope decrypt bounds check). A valid packet is always at least
|
|
1551
|
+
// this long, so this never rejects a well-formed input.
|
|
1552
|
+
if (packed.length < 1 + C.BYTES.bytes(24) + C.BYTES.bytes(16)) {
|
|
1553
|
+
throw new Error("Invalid packed format: truncated (need at least a 1-byte " +
|
|
1554
|
+
"version, a 24-byte nonce, and a 16-byte authentication tag)");
|
|
1555
|
+
}
|
|
1512
1556
|
return Buffer.from(
|
|
1513
1557
|
xchacha20poly1305(key, packed.subarray(1, 25), aad ? Buffer.from(aad) : undefined)
|
|
1514
1558
|
.decrypt(packed.subarray(25))
|
package/lib/guard-image.js
CHANGED
|
@@ -101,8 +101,11 @@ var MAGIC_BYTES = Object.freeze([
|
|
|
101
101
|
{ mime: "image/heic", bytes: [0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x78], offset: 4 },
|
|
102
102
|
{ mime: "image/avif", bytes: [0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66], offset: 4 },
|
|
103
103
|
// SVG (XML) — `<?xml` or `<svg` starting bytes (after any UTF-8 BOM).
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
// textFamily: a leading UTF-8 BOM is legal before XML/SVG text and is skipped
|
|
105
|
+
// when matching these; binary rasters (above) are matched only at their real
|
|
106
|
+
// offset, so a BOM-prefixed raster is NOT accepted.
|
|
107
|
+
{ mime: "image/svg+xml", bytes: [0x3C, 0x3F, 0x78, 0x6D, 0x6C], textFamily: true }, // `<?xml`
|
|
108
|
+
{ mime: "image/svg+xml", bytes: [0x3C, 0x73, 0x76, 0x67], textFamily: true }, // `<svg`
|
|
106
109
|
]);
|
|
107
110
|
|
|
108
111
|
// ---- Profile presets ----
|
|
@@ -163,12 +166,21 @@ function _bytesAt(buf, offset, sig) {
|
|
|
163
166
|
|
|
164
167
|
function _detectMagicMimes(buf) {
|
|
165
168
|
if (!buf || typeof buf.length !== "number") return [];
|
|
169
|
+
// A leading UTF-8 BOM (EF BB BF) is legal only before the text-family images
|
|
170
|
+
// (SVG / XML); skip it ONLY for those entries so a BOM-prefixed SVG still
|
|
171
|
+
// matches its `<?xml` / `<svg` signature and routes to guardSvg at every
|
|
172
|
+
// profile. Binary rasters (PNG/JPEG/GIF/...) must carry their signature at
|
|
173
|
+
// its real offset — applying the BOM shift to them would let a BOM-prefixed
|
|
174
|
+
// raster masquerade as valid, bypassing strict magic validation. So the
|
|
175
|
+
// shift is gated on entry.textFamily.
|
|
176
|
+
var bom = (buf.length >= 3 && buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) ? 3 : 0;
|
|
166
177
|
var hits = [];
|
|
167
178
|
for (var i = 0; i < MAGIC_BYTES.length; i += 1) {
|
|
168
179
|
var entry = MAGIC_BYTES[i];
|
|
169
|
-
var
|
|
180
|
+
var shift = entry.textFamily ? bom : 0;
|
|
181
|
+
var offset = (entry.offset || 0) + shift;
|
|
170
182
|
if (!_bytesAt(buf, offset, entry.bytes)) continue;
|
|
171
|
-
if (entry.tail && !_bytesAt(buf, entry.tailOffset, entry.tail)) continue;
|
|
183
|
+
if (entry.tail && !_bytesAt(buf, entry.tailOffset + shift, entry.tail)) continue;
|
|
172
184
|
hits.push(entry.mime);
|
|
173
185
|
}
|
|
174
186
|
return hits;
|
|
@@ -182,8 +194,15 @@ function _detectIssues(metadata, opts) {
|
|
|
182
194
|
snippet: "image metadata is not an object" }];
|
|
183
195
|
}
|
|
184
196
|
|
|
197
|
+
// Measure the byte cap only for the types safeBuffer.byteLengthOf accepts
|
|
198
|
+
// (string / Buffer / Uint8Array). A hostile bag whose `bytes` is a plain
|
|
199
|
+
// Array or an array-like object still carries a numeric `.length` but is NOT
|
|
200
|
+
// measurable — measuring it threw TypeError, breaking validate's documented
|
|
201
|
+
// never-throw contract. Skip the cap for those (magic detection below is
|
|
202
|
+
// O(1)-bounded regardless of size) instead of crashing the caller.
|
|
185
203
|
var bytes = metadata.bytes;
|
|
186
|
-
if (bytes && typeof bytes
|
|
204
|
+
if (bytes && (Buffer.isBuffer(bytes) || typeof bytes === "string" || bytes instanceof Uint8Array) &&
|
|
205
|
+
safeBuffer.byteLengthOf(bytes) > opts.maxBytes) {
|
|
187
206
|
return [{ kind: "image-cap", severity: "high",
|
|
188
207
|
ruleId: "image.image-cap",
|
|
189
208
|
snippet: "image bytes exceed maxBytes " + opts.maxBytes }];
|
package/lib/guard-pdf.js
CHANGED
|
@@ -156,8 +156,16 @@ function _detectIssues(metadata, opts) {
|
|
|
156
156
|
snippet: "pdf metadata is not an object" }];
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
// Measure the byte cap only for the types safeBuffer.byteLengthOf accepts
|
|
160
|
+
// (string / Buffer / Uint8Array). A hostile bag whose `bytes` is a plain
|
|
161
|
+
// Array or an array-like object still carries a numeric `.length` but is NOT
|
|
162
|
+
// measurable — measuring it threw TypeError, breaking validate's documented
|
|
163
|
+
// never-throw-on-hostile-metadata contract. Skip the cap for those (magic
|
|
164
|
+
// detection reads only the leading bytes, O(1)-bounded regardless of the
|
|
165
|
+
// reported size) instead of crashing the caller.
|
|
159
166
|
var bytes = metadata.bytes;
|
|
160
|
-
if (bytes && typeof bytes
|
|
167
|
+
if (bytes && (Buffer.isBuffer(bytes) || typeof bytes === "string" || bytes instanceof Uint8Array) &&
|
|
168
|
+
safeBuffer.byteLengthOf(bytes) > opts.maxBytes) {
|
|
161
169
|
return [{ kind: "pdf-cap", severity: "high",
|
|
162
170
|
ruleId: "pdf.pdf-cap",
|
|
163
171
|
snippet: "pdf bytes exceed maxBytes " + opts.maxBytes }];
|
package/lib/mail-auth.js
CHANGED
|
@@ -1507,6 +1507,17 @@ async function arcVerify(rfc822, opts) {
|
|
|
1507
1507
|
"arc.verify: rfc822 must be a non-empty string");
|
|
1508
1508
|
}
|
|
1509
1509
|
opts = opts || {};
|
|
1510
|
+
// RFC 8617 §5.1.1 / RFC 6376 §3.4 — ARC-Message-Signature verification
|
|
1511
|
+
// reuses the DKIM verifier, whose header/body split REQUIRES CRLF CRLF and
|
|
1512
|
+
// whose canonicalization is CRLF-based. _splitHeaders / _parseHeaderLines
|
|
1513
|
+
// here accept bare-LF, so a bare-LF ARC message (proper for its own header
|
|
1514
|
+
// scan) reached the DKIM step and threw an uncaught DkimError out of
|
|
1515
|
+
// arc.verify — a foreign error type breaking the "returns a chain result or
|
|
1516
|
+
// throws only a typed MailAuthError" contract. Normalize bare LF the
|
|
1517
|
+
// operator's tooling introduced to canonical CRLF (a no-op on a proper CRLF
|
|
1518
|
+
// message; identical to the LF→CRLF fold the DKIM body canon already does)
|
|
1519
|
+
// so the AMS DKIM verify sees the same canonical bytes the sealer signed.
|
|
1520
|
+
rfc822 = rfc822.replace(/\r?\n/g, "\r\n");
|
|
1510
1521
|
var headers = _parseHeaderLines(_splitHeaders(rfc822));
|
|
1511
1522
|
var hops = [];
|
|
1512
1523
|
var seenSlot = {}; // {`<instance>:<name>`: true} — duplicate detection
|
|
@@ -2389,6 +2400,27 @@ async function inboundVerify(opts) {
|
|
|
2389
2400
|
throw new MailAuthError("mail-auth/inbound-bad-message",
|
|
2390
2401
|
"inbound.verify: message must be a non-empty string or Buffer (the full RFC 5322 message)");
|
|
2391
2402
|
}
|
|
2403
|
+
// RFC 5322 §2.1 — the SMTP wire format is CRLF, but bare-LF input is
|
|
2404
|
+
// accepted defensively (see the pipeline note above; _splitHeaderBlock
|
|
2405
|
+
// already honors it for the From extraction). The DKIM verifier
|
|
2406
|
+
// (RFC 6376 §3.4) canonicalizes over CRLF and its header/body split
|
|
2407
|
+
// REQUIRES CRLF CRLF — a bare-LF message otherwise threw an uncaught
|
|
2408
|
+
// DkimError out of the pipeline, breaking the documented "bare-LF
|
|
2409
|
+
// accepted" contract (and the module-wide invariant that message-derived
|
|
2410
|
+
// faults surface as a verdict, never a throw). Normalize any bare LF the
|
|
2411
|
+
// operator's tooling introduced to canonical CRLF — a no-op on a proper
|
|
2412
|
+
// CRLF message, and identical to the LF→CRLF fold the DKIM body
|
|
2413
|
+
// canonicalizer already performs — so every sub-verifier receives the
|
|
2414
|
+
// same canonical bytes and produces a verdict.
|
|
2415
|
+
message = message.replace(/\r?\n/g, "\r\n");
|
|
2416
|
+
// RFC 5322 §2.1 — a well-formed message terminates its header block with
|
|
2417
|
+
// an empty line. _splitHeaderBlock already treats a message with no such
|
|
2418
|
+
// separator as headers-only (empty body); mirror that here so the DKIM
|
|
2419
|
+
// verifier — whose split REQUIRES CRLF CRLF — sees a separator and
|
|
2420
|
+
// returns a "none" verdict on the same headers-only input instead of
|
|
2421
|
+
// throwing. Same root as the bare-LF case: every message _splitHeaderBlock
|
|
2422
|
+
// accepts must reach a verdict, never an uncaught throw.
|
|
2423
|
+
if (message.indexOf("\r\n\r\n") === -1) message += "\r\n\r\n";
|
|
2392
2424
|
var mailFrom = (typeof opts.mailFrom === "string" && opts.mailFrom.length > 0) ? opts.mailFrom : null;
|
|
2393
2425
|
var helo = (typeof opts.helo === "string" && opts.helo.length > 0) ? opts.helo : null;
|
|
2394
2426
|
|
package/lib/mail-dkim.js
CHANGED
|
@@ -134,6 +134,17 @@ function _splitHeadersBody(rfc822) {
|
|
|
134
134
|
// Headers terminated by the first empty line. Headers may use folded
|
|
135
135
|
// continuation lines (CRLF + WSP); we keep them folded and let the
|
|
136
136
|
// canonicalizer unfold relaxed-mode.
|
|
137
|
+
//
|
|
138
|
+
// RFC 5322 §2.1 — the wire format is CRLF, but a message read from a
|
|
139
|
+
// Unix file/mbox or passed through operator tooling that stripped CRs
|
|
140
|
+
// arrives bare-LF. Normalize to canonical CRLF before locating the
|
|
141
|
+
// separator (a no-op on a proper CRLF message, and identical to the
|
|
142
|
+
// LF->CRLF fold _canonBodySimple already performs) so a valid bare-LF
|
|
143
|
+
// message verifies instead of failing the separator search. A message
|
|
144
|
+
// with no empty line at all (no \n either) is genuinely separator-less
|
|
145
|
+
// and still throws below — normalization converts existing line endings,
|
|
146
|
+
// it never invents a separator.
|
|
147
|
+
rfc822 = rfc822.replace(/\r?\n/g, "\r\n");
|
|
137
148
|
var sep = rfc822.indexOf("\r\n\r\n");
|
|
138
149
|
if (sep === -1) {
|
|
139
150
|
throw new DkimError("dkim/missing-body-separator",
|
package/lib/middleware/dpop.js
CHANGED
|
@@ -311,16 +311,21 @@ function create(opts) {
|
|
|
311
311
|
|
|
312
312
|
var middleware = async function dpopMiddleware(req, res, next) {
|
|
313
313
|
var proofHeader = req.headers && req.headers.dpop;
|
|
314
|
+
// RFC 9449 §4.1 — only ONE DPoP header value per request. Check the
|
|
315
|
+
// array shape (repeated `DPoP:` header lines a custom server/proxy did
|
|
316
|
+
// not collapse) BEFORE the non-string guard: an array is not a string,
|
|
317
|
+
// so the guard below would otherwise shadow this branch and mislabel a
|
|
318
|
+
// duplicated proof as a missing one, letting the multiple-proof
|
|
319
|
+
// rejection never run.
|
|
320
|
+
if (Array.isArray(proofHeader)) {
|
|
321
|
+
return _writeUnauthorized(req, res, "invalid_dpop_proof",
|
|
322
|
+
"multiple DPoP headers are not allowed", null, onDeny, problemMode);
|
|
323
|
+
}
|
|
314
324
|
if (typeof proofHeader !== "string" || proofHeader.length === 0) {
|
|
315
325
|
return _writeUnauthorized(req, res,
|
|
316
326
|
nonceMgr ? "use_dpop_nonce" : "invalid_dpop_proof",
|
|
317
327
|
"DPoP header required", _freshNonce(), onDeny, problemMode);
|
|
318
328
|
}
|
|
319
|
-
// RFC 9449 §4.1 — only ONE DPoP header value per request.
|
|
320
|
-
if (Array.isArray(proofHeader)) {
|
|
321
|
-
return _writeUnauthorized(req, res, "invalid_dpop_proof",
|
|
322
|
-
"multiple DPoP headers are not allowed", null, onDeny, problemMode);
|
|
323
|
-
}
|
|
324
329
|
// RFC 9449 §4.1 single-value invariant. node:http
|
|
325
330
|
// collapses repeated headers into a comma-joined string when the
|
|
326
331
|
// client ships `DPoP: proof1, DPoP: proof2`; the Array.isArray
|
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:cb89f635-0d20-4d7a-b200-e38dc8bed885",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-16T12:36:01.015Z",
|
|
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.16.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.16.34",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.16.
|
|
25
|
+
"version": "0.16.34",
|
|
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.16.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.16.34",
|
|
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.16.
|
|
57
|
+
"ref": "@blamejs/core@0.16.34",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|