@blamejs/core 0.16.18 → 0.16.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.16.x
10
10
 
11
+ - v0.16.20 (2026-07-12) — **Make the ClamAV scan-verdict classifier fail closed on a coalesced infected+clean reply, refuse a session under a strict device-binding threshold when the anomaly score can't be computed, guard the MessageFormat select renderer against prototype-chain keys, and reject a non-canonical webhook timestamp.** Covering untested adversarial branches across the mail scanner, the session device-binding verifier, the i18n message renderer, and the webhook verifier surfaced a set of fail-open and correctness defects, fixed at the root. The mail scanner's ClamAV INSTREAM reply classifier tested the benign OK token before the malign FOUND token, so a reply carrying both (a coalesced or stale-then-fresh reply on a reused connection) resolved to clean and delivered an infected message; the classifier now tests FOUND, then ERROR, then OK, so a malign signal always dominates and only an OK with no FOUND/ERROR is clean. Session verification under a strict maxAnomalyScore device-binding policy admitted a relocated device whenever no decisive anomaly score could be produced (no scorer supplied, or a scorer that threw or returned a non-finite value) -- it now fails closed, matching the unreadable-binding discipline. The ICU-style MessageFormat select renderer looked up an end-user-supplied select value with a bare object index, so a value naming an Object.prototype member (__proto__ / constructor / toString) returned an inherited member and either rendered garbage or threw a render-time DoS; every case lookup is now own-property only. And the webhook verifier accepted any string that Number() maps to the signed timestamp (scientific, trailing .0, hex, leading zero/plus), making the authenticated t= field malleable; it now requires the canonical decimal. **Fixed:** *b.webhook verifier rejects a non-canonical authenticated timestamp* — The verifier parsed the authenticated t= field with Number() and validated only the post-coercion integer, then re-composed the signed string from that integer -- so any string Number() maps to the same value (scientific 1.7e9, a trailing .0, hex 0x..., a leading zero/plus/whitespace) re-canonicalized into the identical signed string and verified, making the authenticated timestamp field malleable. The verifier now also requires the raw bytes to equal the canonical decimal the signer emits, the same strict digits-only parse the Stripe-compatible verifier already applied; the only legitimate producer (b.webhook.signer) emits the canonical form, so there is no interop cost. · *b.i18n.dir honors a custom RTL language configured in any case* — dir() folds the requested locale's primary subtag to lower case before the RTL-membership test, but a custom rtlLanguages list was stored verbatim, so an operator-supplied entry like "AR" or "CKB" never matched the lower-cased lookup and the language rendered left-to-right. The configured list is now folded to lower case the same way the lookup is, so a custom RTL entry matches regardless of case. · *b.i18n.t resolves a leaf shadowed by a namespace in a more-specific locale* — The fallback-chain lookup returned the first locale where the dotted key resolved to any non-undefined value, including a nested namespace object -- so a namespace at that path in the requested locale halted the chain and shadowed a real translation leaf defined in a fallback locale, leaking the raw key into the UI (and has() reported false). The lookup now treats only a renderable leaf (a string or a plural-shaped object) as a hit and keeps walking the chain past a namespace object, so a leaf in a fallback locale resolves. **Security:** *b.mail.scan fails closed on a coalesced infected+clean ClamAV reply* — The ClamAV INSTREAM reply classifier tested the benign `stream: OK` token before the malign `... FOUND` token, so a reply that carried both -- a coalesced or stale-then-fresh reply on a reused connection, or an intermediary that concatenates two responses -- resolved to a clean verdict and delivered an infected message (a scan bypass). The classifier is a security verdict, so it now tests FOUND (infected) first, then ERROR (do-not-deliver), then OK (clean); an unrecognized shape is still an error. A reply containing a FOUND signal can no longer be downgraded to clean by an adjacent OK token. · *b.session.verify fails closed under a strict anomaly threshold when the score can't be computed* — Under the strict maxAnomalyScore device-binding policy, verify() only assigned a fingerprint anomaly score when the supplied scorer returned a finite number. With no scorer (the option is documented as pairing with one but is omittable), or a scorer that threw or returned a non-finite value, the score stayed null and the refusal guard (score present AND above threshold) was false, so verify RETURNED a session bound to one device when presented from another. An uncomputable anomaly score on genuine fingerprint drift now fails closed (verify returns null), matching the fail-closed discipline the unreadable-binding branch already applied; the legitimate path (a finite score at or below the threshold admits, with the score surfaced) is unchanged. · *b.i18n MessageFormat select renderer guards against prototype-chain keys* — The ICU-style MessageFormat select renderer resolved a case with a bare object index on an end-user-supplied select value, so a value naming an Object.prototype member -- __proto__ / constructor / toString / hasOwnProperty / valueOf -- returned a truthy INHERITED member, bypassing the `other` fallback: an inherited value with no length rendered as empty (silent output corruption), and an inherited function threw when rendered as a non-array (a request-level DoS). Every select/plural case lookup is now own-property only, so an attacker-supplied select value that names a prototype member falls through to the `other` case. **Detectors:** *MessageFormat case lookups must be own-property* — A structural check asserts that the MessageFormat renderer resolves a select/plural case through the own-property helper rather than a bare object index, so an end-user-supplied select value can never reach the prototype chain and re-open the corruption/DoS class.
12
+
13
+ - v0.16.19 (2026-07-12) — **Close a family of entity- and whitespace-hidden dangerous-URL-scheme and CSS-injection bypasses across b.guardHtml / b.guardSvg / b.guardMarkdown behind one shared normalizer, bound a DNS decompression-pointer name bomb, refuse an ambiguous audit-anchor canonicalization, and stop an empty-string sealed field from crashing an AAD-table write.** Covering untested adversarial branches across the content guards, a DNS wire parser, the audit-log anchor, and the sealed-field codec surfaced a family of fail-open defects, fixed at the root. The most serious span the content-guard URL-scheme and CSS-injection denylists: a browser removes tab/lf/cr from anywhere in a URL and trims a leading/trailing control-or-space run before resolving the scheme, and character-reference-decodes a style attribute before the CSS parser sees it, so an entity-encoded tab, an entity-encoded leading space, an HTML named entity, or an entity-encoded CSS token could all navigate/execute while reading past a denylist that matched the raw bytes. b.guardHtml missed the whitespace normalization entirely and matched CSS against raw bytes; b.guardMarkdown decoded only numeric entities; b.guardSvg had a partial fix. All three now route their scheme and CSS-token checks through one pair of shared codepoint-class normalizers, so no guard can strip a different set of encodings than another. A DNS compression-pointer chain could decompress a single name past the RFC 1035 255-octet / label caps (a decompression-amplification DoS); the audit-log anchor's newline-delimited signed bytes let one signature validate for several different tip/previous-tip splits (defeating the linkage tamper-evidence); an SVG animation element permitted under a permissive profile lost its open tag; and an empty-string sealed column crashed an AAD-table insert. **Fixed:** *b.guardSvg preserves a profile-permitted safe animation element* — Under a permissive profile that allows animation, a safe animation element (for example an <animate> whose attributeName targets a visual property) had its open tag dropped while its end tag was still emitted, leaving an orphan close tag and silently stripping an element the profile permits. The sanitize path now affirmatively re-permits the safe-target case; an unsafe-target animation (attributeName=href and similar) is still dropped and neutralized. · *b.cryptoField.sealRow seals an empty-string field as a tamper-evident envelope* — sealRow dispatched every non-null value -- including an empty string -- to one of three envelope-seal branches that disagreed on empty plaintext: the plain and per-row-key branches handled it, but the AAD branch is fail-closed and threw, so a write to an AAD-sealed table whose sealed column held an empty string crashed the insert. An empty string now encodes to a non-empty typed marker before sealing, so it becomes a real authenticated envelope (never a bare plaintext empty string) that round-trips to an empty string on read. On the read side, a bare empty string found in an AAD-bound or per-row-key sealed column -- which after this change can only be an envelope-downgrade by a DB-write attacker -- fails closed (the cell is nulled and audited) instead of being accepted as a valid empty value, so the sealed-column tamper-evidence guarantee holds for empty values too. **Security:** *b.guardHtml / b.guardSvg / b.guardMarkdown refuse entity- and whitespace-hidden dangerous URL schemes* — The URL-scheme denylist on every URL-bearing attribute (href / xlink:href / markdown link, image, autolink, reference-definition) is resolved after normalizing the value the way a browser does: character-reference decoding (numeric AND the HTML5 named-entity ASCII subset browsers honor, e.g. &Tab; / &colon;), removing tab/lf/cr from anywhere, and trimming a leading/trailing C0-control-or-space run. Previously b.guardHtml stripped neither tab/lf/cr nor an entity-encoded leading space, and b.guardMarkdown decoded only numeric entities, so payloads such as java&#9;script:, java&Tab;script:, and &#32;javascript: resolved to an empty scheme and were served/rendered as safe while a browser executed them as javascript:. The normalization now lives in two shared codepoint-class primitives (b.codepointClass.decodeMarkupEntities and b.codepointClass.stripUrlSchemeWhitespace) that all three guards compose, so no guard can fold away a different set of encodings than another. · *b.guardHtml / b.guardSvg detect entity-encoded and whitespace-hidden CSS injection in style attributes* — A style attribute is HTML/XML character-reference-decoded before the CSS parser sees it, so width:ex&#x70;ression(, background:url(&#x6A;avascript:...), and behavior&colon;url(...) reach CSS as expression( / url(javascript:...) / behavior: with no literal danger token in the raw bytes. The CSS-danger check matched the raw attribute value, so these bypassed the denylist and were served verbatim (a stored CSS-injection XSS). Both guards now match the entity-decoded value AND fold the URL-scheme whitespace a browser strips inside url(...) (tab / lf / cr), so an entity-hidden tab in a CSS URL scheme (url(java&Tab;script:) reaching CSS as url(java<TAB>script:), which navigates as javascript:) can no longer defeat the contiguous javascript: pattern either. A plain (unencoded) dangerous style is still caught and a benign style is untouched. · *b.safeDns bounds a decompressed name across the whole compression-pointer chain* — readName tracked its per-name octet and label budgets per stack frame, so each compression-pointer jump restarted a fresh RFC 1035 255-octet / label budget and a pointer was charged as only its 2 on-wire bytes. A crafted pointer chain therefore decompressed a single name well past the advertised 255-octet / label caps (a decompression-amplification DoS on untrusted DNS responses). The octet and label accountants now thread through the whole pointer chain, so the composite decompressed name is bounded by the same caps that bound a single in-line name; independent names are still measured independently and no name that decompresses within the caps is newly rejected. · *b.auditSign refuses an anchor whose fields carry the record delimiter* — The chain-anchor signed bytes are a newline-delimited layout of format / counter / tipHash / prevTipHash / createdAt. A literal newline inside any operator-influenced string field (format / tipHash / prevTipHash) let content migrate across a field boundary without changing the signed bytes, so one signature was valid for several different { tipHash, prevTipHash } splits -- an attacker could rewrite a stored anchor's apparent tip/linkage while keeping verify() happy, defeating the prevTipHash linkage tamper-evidence. anchor() now refuses a delimiter-bearing field at sign time and verifyAnchor refuses one at verify time, so an ambiguous anchor can neither be minted nor accepted; every legitimate (delimiter-free) anchor is byte-identical and unaffected. **Detectors:** *Guard scheme extractors and CSS-danger checks must compose the shared normalizers* — Two structural checks assert that a content guard which resolves a URL scheme for a denylist routes the decoded value through b.codepointClass.stripUrlSchemeWhitespace, and that a guard's CSS-danger check matches the entity-decoded style value via b.codepointClass.decodeMarkupEntities -- so a future guard cannot silently fold away a narrower set of encodings than the browser and re-open the bypass class.
14
+
11
15
  - v0.16.18 (2026-07-12) — **Refuse a stale FIDO metadata BLOB on the operator-fetch path, harden the GraphQL DoS-shape guard against escaped strings and malformed queries, parse POSIX leading-space octal in tar headers, and fix the FIDO certified-level, self-update 304, and swap-maxBytes paths — surfaced by covering previously-untested error and adversarial branches.** Covering untested error and adversarial branches across four security-relevant primitives surfaced seven defects, fixed at the root. The most serious: b.auth.fidoMds3.fetch() reimplemented the metadata-BLOB validation inline and omitted the stale-BLOB refusal the internal path enforced — so a signed-but-expired BLOB (nextUpdate in the past) was accepted and cached, letting an attacker who serves an ancient correctly-signed BLOB freeze an operator's revoked/compromised-authenticator list at a time of their choosing. Both paths now route through one _verifyBlobWithRoots source of truth, so no check can be present on one and missing on the other. The GraphQL query-shape guard (the pre-schema depth/alias DoS walker) miscounted on a string literal containing an escaped quote and on a brace-unbalanced malformed query, weakening the depth/alias limits it exists to enforce. The FIDO certified-level resolver lexically compared status reports and let an undated later report (a decertification or downgrade) lose to an earlier dated one, so a now-decertified authenticator could still read as certified. A tar reader misparsed POSIX leading-space-padded octal header fields to 0 (a size misparse desynced the block walker and rejected archives other tars extract). self-update's documented 304 If-None-Match fast-path was dead (it threw on every unchanged conditional poll), and selfUpdate.swap read an opts.maxBytes it never declared, so it could refuse a large binary that selfUpdate.verify accepted. **Fixed:** *b.auth.fidoMds3 certified-level honors an undated later decertification or downgrade* — _certifiedLevel compared status reports lexically and coerced a missing effectiveDate to an empty string, which sorts before any real date — so a later report with no effectiveDate (a NOT_FIDO_CERTIFIED decertification, or a downgrade/upgrade) always lost to an earlier dated grant, and certifiedLevel froze at the stale, higher historical value. A step-up policy reading certifiedLevel >= N could therefore accept a now-decertified authenticator. Reports without a comparable date now fall back to array order (append order = chronological), so a later decertification/downgrade wins. · *b.archive.read.tar parses POSIX leading-space-padded octal header fields* — A tar numeric header field (mode / uid / gid / size / mtime / checksum) that is left-padded with ASCII spaces — POSIX-legal, and emitted by star / BSD / Java / Perl tars — was misparsed to 0 because the octal reader treated a leading space as a field terminator. A misparsed size desynced the 512-byte block walker (it read the file body as the next header) and rejected an archive other tars extract cleanly. The reader now skips leading-space padding before reading octal digits. · *b.selfUpdate.poll returns a no-update result on a 304 Not Modified* — The documented If-None-Match / ETag fast-path was unreachable: poll delegated HTTP status handling to the HTTP client, which rejects every non-2xx (304 included) as an error before poll could inspect the status. A conditional poll that correctly received a 304 threw selfupdate/poll-failed instead of returning { available: false, statusCode: 304 }. The 304 (and the non-2xx) branch is now reached, so ETag-conditional polling works as documented. · *b.selfUpdate.swap accepts the maxBytes it re-reads under* — swap re-reads the newly-installed bytes to re-hash them (closing the verify→swap window) under an opts.maxBytes cap, but its option schema never declared maxBytes — so a caller who passed it was refused with selfupdate/bad-opts and swap always used the fixed 1 GiB default, which could refuse a large binary that selfUpdate.verify (which does declare maxBytes) accepted. swap now declares maxBytes (validated like verify's), so the two caps match; rollback, which re-reads nothing, still does not accept it. **Security:** *b.auth.fidoMds3.fetch refuses a stale (expired) metadata BLOB* — The operator-facing fetch path (which trusts caCertificate roots) reimplemented the metadata-BLOB parse and verify inline and omitted the stale-BLOB refusal that the default-roots path enforced, so a BLOB whose nextUpdate is already in the past was accepted and cached even though FIDO MDS3 §3.1.7 says it must not be trusted. An attacker serving an ancient, correctly-signed BLOB could pin an operator to a revoked/compromised-authenticator list frozen at that time. The JWS + x5c-chain verify, payload-shape checks, and stale-BLOB refusal now live in one _verifyBlobWithRoots helper that both the operator-fetch and default-roots paths route through, so the checks can never drift apart again. · *b.guardGraphql hardens the pre-schema DoS-shape walker against escaped strings and malformed queries* — The query-shape walker that enforces depth and alias limits before the query reaches a schema miscounted in two ways: it treated an escaped quote inside a valid GraphQL string literal as the string's terminator (desyncing its in-string state), and it popped its depth-indexed alias counter on every closing brace even for a brace-unbalanced malformed query (underflowing the counter). Both weakened the depth/alias caps the guard exists to enforce against a DoS-shaped query. The walker now tracks string escapes correctly and guards the alias-counter stack against imbalance.
12
16
 
13
17
  - v0.16.17 (2026-07-12) — **Reject INI float literals that overflow to Infinity, return a verdict (not an exception) when a reverse-DNS lookup faults, wrap an async redirect-hook rejection like a sync throw, and refuse a fractional --max-rows — four defects surfaced by covering previously-untested error branches.** Covering previously-untested error and adversarial branches across four primitives surfaced four genuine defects, each fixed at the root. b.parsers.ini.parse coerced a value like `x = 1e999` straight to ±Infinity — its integer and hex branches already reject out-of-range numbers, but the float branch had no finiteness guard, so an overflowing float slipped through and could poison a downstream size cap or timeout; it is now rejected with ini/value-out-of-range. b.mail.iprev.verify threw an unhandled exception when the forward-confirm DNS lookup returned an error code outside the handful it enumerated (EREFUSED / ENOTIMP / …), even though its reverse-lookup path and every sibling (SPF/DKIM/DMARC/ARC) return a verdict for such faults; it now returns a temperror verdict, and — like those siblings — accepts an operator opts.dnsLookup resolver so the confirm path is resolvable offline. b.httpClient wrapped a synchronous onRedirect hook throw into a REDIRECT_ABORTED error but let an async hook rejection escape unwrapped; both now abort the redirect identically. And the blamejs audit verify-chain --max-rows flag accepted a fractional value (2.5), which truncated the chain walk mid-row and reported a nonsensical fractional count; it now requires a whole positive integer, matching its own error message. **Fixed:** *b.parsers.ini.parse rejects an overflowing float instead of coercing to Infinity* — A float literal that exceeds the representable range (e.g. `x = 1e999`) coerced to ±Infinity. The integer and hex coercion branches already reject out-of-range numbers via Number.isSafeInteger, but the float branch returned Number(raw) with no finiteness check — so an Infinity could flow into a downstream size cap or timeout, a denial-of-service vector. The float branch now rejects a non-finite result with ini/value-out-of-range; a large-but-finite float (1e308) and underflow (1e-999 → 0) still parse. · *b.mail.iprev.verify returns a temperror verdict on an un-enumerated reverse/forward DNS fault* — The forward-confirm DNS lookup's error handler enumerated a few transient codes and threw for anything else, so a resolver returning EREFUSED / ENOTIMP / EBADRESP produced an unhandled exception from the public API rather than a verdict. The reverse-lookup path and every sibling result type (SPF / DKIM / DMARC / ARC) return a verdict for a DNS-derived fault; the forward path now does too (temperror). iprev.verify also gains an operator opts.dnsLookup resolver, matching the dnsLookup contract the other types already honor, so the forward-confirm path is resolvable offline. · *b.httpClient aborts a redirect on an async onRedirect hook rejection* — A synchronous throw from the onRedirect hook was wrapped into a REDIRECT_ABORTED error, but an async hook that rejected let the rejection escape unwrapped — inconsistent handling for the same operator control point. An async onRedirect rejection now aborts the redirect with REDIRECT_ABORTED, identical to the synchronous throw. · *blamejs audit verify-chain --max-rows requires a whole positive integer* — The --max-rows flag validated only that the value was finite and >= 1, so a fractional value (2.5) was accepted and passed to the chain walk, where it truncated the verification mid-row and reported a fractional rowsVerified count — despite the flag's own error message promising a positive integer. It now rejects a non-integer value, matching the sibling --steps flag.
package/lib/audit-sign.js CHANGED
@@ -204,6 +204,24 @@ function _computeFingerprint(publicKeyPem) {
204
204
  // untouched). Changing this invalidates every prior anchor.
205
205
  var ANCHOR_FORMAT = "blamejs-chain-anchor-v1";
206
206
 
207
+ // The anchor signed-bytes layout below is newline-delimited, so a literal
208
+ // newline inside any operator-influenced string field (format / tipHash /
209
+ // prevTipHash) would let content migrate across a field boundary WITHOUT
210
+ // changing the signed bytes: two different { tipHash, prevTipHash } splits
211
+ // collide on one signature, so a signature minted for one split verifies for a
212
+ // different split. That defeats the "prevTipHash is bound into the signature"
213
+ // linkage guarantee — an attacker could rewrite the apparent tip / linkage of a
214
+ // stored anchor while keeping verify() happy. counter + createdAt are numbers
215
+ // (String() of a finite number can never contain the delimiter), so only the
216
+ // three string fields need guarding. Reject the delimiter at BOTH sign time
217
+ // (so no ambiguous signature is ever minted) and verify time (so a pre-existing
218
+ // or hand-crafted ambiguous anchor is refused, not accepted). The wire format
219
+ // stays byte-identical for every legitimate (delimiter-free) anchor.
220
+ var ANCHOR_FIELD_DELIMITER = "\n";
221
+ function _containsAnchorDelimiter(s) {
222
+ return typeof s === "string" && s.indexOf(ANCHOR_FIELD_DELIMITER) !== -1;
223
+ }
224
+
207
225
  // Build the canonical signed bytes for one anchor. Fixed multi-line layout (no
208
226
  // JSON serializer quirks). prevTipHash IS part of the signed payload, so the
209
227
  // link between consecutive anchors is tamper-evident — an attacker cannot
@@ -878,10 +896,20 @@ function _normalizeTip(tip, fnLabel) {
878
896
  throw _err("ANCHOR_BAD_TIPHASH",
879
897
  "auditSign." + fnLabel + ": tip.tipHash must be a non-empty string");
880
898
  }
899
+ if (_containsAnchorDelimiter(tip.tipHash)) {
900
+ throw _err("ANCHOR_BAD_TIPHASH",
901
+ "auditSign." + fnLabel + ": tip.tipHash must not contain a newline (the anchor " +
902
+ "record delimiter — it would make the signed bytes ambiguous)");
903
+ }
881
904
  if (tip.prevTipHash != null && typeof tip.prevTipHash !== "string") {
882
905
  throw _err("ANCHOR_BAD_PREV",
883
906
  "auditSign." + fnLabel + ": tip.prevTipHash, when present, must be a string");
884
907
  }
908
+ if (_containsAnchorDelimiter(tip.prevTipHash)) {
909
+ throw _err("ANCHOR_BAD_PREV",
910
+ "auditSign." + fnLabel + ": tip.prevTipHash must not contain a newline (the anchor " +
911
+ "record delimiter — it would make the signed bytes ambiguous)");
912
+ }
885
913
  return {
886
914
  counter: counter,
887
915
  tipHash: tip.tipHash,
@@ -916,7 +944,9 @@ function _normalizeTip(tip, fnLabel) {
916
944
  * that key store (not fully store-free) — keep the history with the anchors.
917
945
  *
918
946
  * Throws `AuditSignError` (`ANCHOR_BAD_TIP` / `ANCHOR_BAD_COUNTER` /
919
- * `ANCHOR_BAD_TIPHASH` / `ANCHOR_BAD_PREV`) on a malformed tip;
947
+ * `ANCHOR_BAD_TIPHASH` / `ANCHOR_BAD_PREV` / `ANCHOR_BAD_FORMAT`) on a malformed
948
+ * tip — including any `format` / `tipHash` / `prevTipHash` that carries a
949
+ * newline, which would make the signed bytes ambiguous;
920
950
  * `audit-sign/not-initialized` when `init()` has not been awaited.
921
951
  *
922
952
  * @opts
@@ -934,6 +964,11 @@ function anchor(tip, opts) {
934
964
  _requireInit();
935
965
  opts = opts || {};
936
966
  var t = _normalizeTip(tip, "anchor");
967
+ if (_containsAnchorDelimiter(opts.format)) {
968
+ throw _err("ANCHOR_BAD_FORMAT",
969
+ "auditSign.anchor: opts.format must not contain a newline (the anchor " +
970
+ "record delimiter — it would make the signed bytes ambiguous)");
971
+ }
937
972
  var format = (typeof opts.format === "string" && opts.format.length > 0) ? opts.format : ANCHOR_FORMAT;
938
973
  var createdAt = (typeof opts.createdAt === "number" && isFinite(opts.createdAt)) ? opts.createdAt : Date.now();
939
974
  var payload = anchorPayload(t.counter, t.tipHash, t.prevTipHash, createdAt, format);
@@ -962,6 +997,17 @@ function _verifyOneAnchor(a) {
962
997
  if (typeof a.counter !== "number" || !isFinite(a.counter)) {
963
998
  return { ok: false, reason: "anchor counter is not a finite number" };
964
999
  }
1000
+ // Fail closed on a delimiter-bearing field: the newline-delimited signed
1001
+ // bytes are ambiguous when format / tipHash / prevTipHash carries the
1002
+ // delimiter, so ONE signature can be valid for several different logical
1003
+ // { tipHash, prevTipHash } splits. Refuse rather than accept the ambiguity —
1004
+ // a legitimate anchor is delimiter-free (anchor() rejects a newline field at
1005
+ // sign time), so this only ever rejects a hand-crafted / boundary-shifted one.
1006
+ if (_containsAnchorDelimiter(a.tipHash) ||
1007
+ _containsAnchorDelimiter(a.format) ||
1008
+ _containsAnchorDelimiter(a.prevTipHash)) {
1009
+ return { ok: false, reason: "anchor field contains the record delimiter (ambiguous canonicalization)" };
1010
+ }
965
1011
  var pub = getPublicKeyByFingerprint(a.publicKeyFingerprint);
966
1012
  if (!pub) {
967
1013
  return { ok: false, reason: "no audit-signing key on record for this anchor's fingerprint" };
@@ -581,10 +581,95 @@ function decodeNumericEntities(s) {
581
581
  });
582
582
  }
583
583
 
584
+ // The HTML5 named-entity ASCII subset a browser resolves inside URL and CSS
585
+ // attribute contexts — the scheme/whitespace-significant characters an attacker
586
+ // hides a payload behind (`java&Tab;script:` / `behavior&colon;`). One table so
587
+ // guard-html / guard-svg / guard-markdown decode the SAME set and cannot drift
588
+ // (guard-markdown shipped without named-entity decoding at all, so `&Tab;` /
589
+ // `&NewLine;` slipped past its scheme denylist).
590
+ var NAMED_ENTITY_ASCII = {
591
+ // Whitespace + control chars browsers strip inside URL schemes
592
+ Tab: "\t", NewLine: "\n",
593
+ // Scheme-significant punctuation
594
+ colon: ":", semi: ";", period: ".", sol: "/", bsol: "\\",
595
+ num: "#", excl: "!", quest: "?", lpar: "(", rpar: ")",
596
+ lsqb: "[", rsqb: "]", lcub: "{", rcub: "}",
597
+ // Quotes / brackets
598
+ quot: "\"", apos: "'", lt: "<", gt: ">",
599
+ // Misc ASCII
600
+ amp: "&", commat: "@", dollar: "$", percnt: "%",
601
+ ast: "*", plus: "+", lowbar: "_", hyphen: "-",
602
+ // Latin-1 space browsers treat as URL-strippable
603
+ nbsp: " ",
604
+ };
605
+ var NAMED_ENTITY_RE_G = /&([A-Za-z][A-Za-z0-9]+);/g;
606
+
607
+ /**
608
+ * @primitive b.codepointClass.decodeMarkupEntities
609
+ * @signature b.codepointClass.decodeMarkupEntities(value)
610
+ * @since 0.16.19
611
+ * @status stable
612
+ * @related b.codepointClass.decodeNumericEntities, b.codepointClass.stripUrlSchemeWhitespace
613
+ *
614
+ * Decode the character references a browser resolves inside an attribute value
615
+ * -- numeric (hex/decimal, semicolon OPTIONAL) then the named-entity ASCII
616
+ * subset browsers honor in URL/CSS contexts -- and drop the C0 controls and
617
+ * zero-widths a payload hides behind. The single decoder every content guard
618
+ * routes a scheme / CSS-token danger check through, so a threat cannot slip
619
+ * past the guard that forgot to decode an encoding a sibling strips. Pair with
620
+ * `stripUrlSchemeWhitespace` for a URL-scheme check.
621
+ *
622
+ * @example
623
+ * b.codepointClass.decodeMarkupEntities("ex&#x70;ression("); // "expression("
624
+ * b.codepointClass.decodeMarkupEntities("behavior&colon;"); // "behavior:"
625
+ */
626
+ function decodeMarkupEntities(value) {
627
+ var s = decodeNumericEntities(String(value == null ? "" : value));
628
+ s = s.replace(NAMED_ENTITY_RE_G, function (m, name) {
629
+ if (Object.prototype.hasOwnProperty.call(NAMED_ENTITY_ASCII, name)) {
630
+ return NAMED_ENTITY_ASCII[name];
631
+ }
632
+ return m;
633
+ });
634
+ return s.replace(C0_CTRL_RE_G, "").replace(ZW_RE_G, "");
635
+ }
636
+
637
+ var URL_TAB_NEWLINE_RE_G = /[\t\n\r]/g; // URL tab/newline strip, not byte size
638
+ // allow:dynamic-regex -- codepoints from a literal [0x00, 0x20] range (C0 controls + space)
639
+ var URL_C0_SPACE_CC = charClass([[0x0000, 0x0020]]);
640
+ var URL_C0_SPACE_TRIM_RE = new RegExp("^(?:[" + URL_C0_SPACE_CC + "])+|(?:[" + URL_C0_SPACE_CC + "])+$", "g");
641
+ /**
642
+ * @primitive b.codepointClass.stripUrlSchemeWhitespace
643
+ * @signature b.codepointClass.stripUrlSchemeWhitespace(s)
644
+ * @since 0.16.19
645
+ * @status stable
646
+ * @related b.codepointClass.decodeMarkupEntities, b.codepointClass.decodeNumericEntities
647
+ *
648
+ * Fold away exactly the whitespace the WHATWG URL parser removes before it
649
+ * resolves a scheme: ASCII tab / LF / CR from ANYWHERE, plus a leading/trailing
650
+ * C0-control-or-space run. tab/lf/cr are excluded from the C0-control catalog
651
+ * and space is not a control, so a danger check that strips only C0/zero-width
652
+ * still lets `java<TAB>script:` or an entity-encoded leading space
653
+ * (`&#32;javascript:`) read as scheme-less. Run AFTER entity decoding; every
654
+ * guard that extracts a URL scheme for a denylist routes the decoded value
655
+ * through this.
656
+ *
657
+ * @example
658
+ * b.codepointClass.stripUrlSchemeWhitespace(" javascript:x"); // "javascript:x"
659
+ */
660
+ function stripUrlSchemeWhitespace(s) {
661
+ return String(s == null ? "" : s)
662
+ .replace(URL_TAB_NEWLINE_RE_G, "")
663
+ .replace(URL_C0_SPACE_TRIM_RE, "");
664
+ }
665
+
584
666
  module.exports = {
585
667
  isForbiddenControlChar: isForbiddenControlChar,
586
668
  firstControlCharOffset: firstControlCharOffset,
587
669
  decodeNumericEntities: decodeNumericEntities,
670
+ decodeMarkupEntities: decodeMarkupEntities,
671
+ NAMED_ENTITY_ASCII: NAMED_ENTITY_ASCII,
672
+ stripUrlSchemeWhitespace: stripUrlSchemeWhitespace,
588
673
  isAsciiAlnum: isAsciiAlnum,
589
674
  isUnreserved: isUnreserved,
590
675
  hex4: hex4,
@@ -89,6 +89,11 @@ var TYPED_SENTINEL = String.fromCharCode(0) + "bjsv1:";
89
89
 
90
90
  function _encodeTyped(value) {
91
91
  if (typeof value === "string") {
92
+ // "" encodes to a NON-empty typed marker so a sealed empty string is a real
93
+ // authenticated envelope, never a bare plaintext "": vault.aad.seal refuses
94
+ // empty plaintext, and a bare "" in a sealed cell is indistinguishable from a
95
+ // ciphertext a DB-write attacker downgraded to "".
96
+ if (value === "") return TYPED_SENTINEL + "E:";
92
97
  return value.indexOf(TYPED_SENTINEL) === 0 ? TYPED_SENTINEL + "S:" + value : value;
93
98
  }
94
99
  if (Buffer.isBuffer(value)) return TYPED_SENTINEL + "B:" + value.toString("base64");
@@ -102,6 +107,7 @@ function _decodeTyped(str) {
102
107
  var body = str.slice(TYPED_SENTINEL.length);
103
108
  var tag = body.slice(0, 2);
104
109
  var payload = body.slice(2);
110
+ if (tag === "E:") return "";
105
111
  if (tag === "B:") return Buffer.from(payload, "base64");
106
112
  if (tag === "J:") return safeJson.parse(payload); // plaintext is AEAD-verified; safeJson blocks proto-pollution defensively
107
113
  if (tag === "S:") return payload;
@@ -1071,6 +1077,11 @@ function sealRow(table, row, opts) {
1071
1077
  // - plain mode: vault.seal (idempotent — already-sealed pass through).
1072
1078
  for (var i = 0; i < s.sealedFields.length; i++) {
1073
1079
  var field = s.sealedFields[i];
1080
+ // Skip only null / undefined (nothing to seal). An empty string IS sealed:
1081
+ // _encodeTyped("") yields a non-empty typed marker (E:), so every branch
1082
+ // produces a real authenticated envelope -- never a bare plaintext "" that a
1083
+ // DB-write attacker could forge, or that a ciphertext could be silently
1084
+ // downgraded to (unsealRow fails closed on a bare "" in an AAD/keyed cell).
1074
1085
  if (out[field] === undefined || out[field] === null) continue;
1075
1086
  if (kRow && field === residencyCol) continue; // residency tag stays plaintext
1076
1087
  if (kRow) {
@@ -1216,6 +1227,27 @@ function unsealRow(table, row, actor, dbHandle) {
1216
1227
 
1217
1228
  for (var i = 0; i < s.sealedFields.length; i++) {
1218
1229
  var field = s.sealedFields[i];
1230
+ // Post-seal, a legitimately-empty sealed value is a NON-empty authenticated
1231
+ // envelope (see _encodeTyped "" -> E:), so a bare "" present in an AAD-bound
1232
+ // or per-row-key sealed column is an envelope-downgrade tamper -- a DB-write
1233
+ // attacker replacing a ciphertext with "". Fail closed (null the cell + audit),
1234
+ // mirroring the plain-vault aad-downgrade refusal below; never surface it as a
1235
+ // valid empty value. Plain-mode tables keep the lenient pass-through.
1236
+ if (out[field] === "" && (s.aad || keyedTable)) {
1237
+ out[field] = null;
1238
+ try {
1239
+ audit().safeEmit({
1240
+ action: "system.crypto.unseal_failed",
1241
+ outcome: "failure",
1242
+ metadata: {
1243
+ table: table, field: field, rowId: out[s.rowIdField] || out._id || null,
1244
+ shape: (s.aad ? "aad" : "row"),
1245
+ reason: "empty-string cell in a sealed AAD/keyed column (envelope downgrade)",
1246
+ },
1247
+ });
1248
+ } catch (_e) { /* drop-silent */ }
1249
+ continue;
1250
+ }
1219
1251
  if (out[field]) {
1220
1252
  // Per-cell envelope shape for audit metadata (operators write alert
1221
1253
  // rules off it): "row" = K_row cell, "aad" = vault.aad: cell on an
package/lib/guard-html.js CHANGED
@@ -110,11 +110,6 @@ void observability;
110
110
 
111
111
  var _err = GuardHtmlError.factory;
112
112
 
113
- // ---- Codepoint catalog (shared via lib/codepoint-class) ----
114
-
115
- var C0_CTRL_RE_G = codepointClass.C0_CTRL_RE_G;
116
- var ZW_RE_G = codepointClass.ZW_RE_G;
117
-
118
113
  // ---- Tag denylists / allowlists ----
119
114
 
120
115
  // Always-dangerous tags. Active scripts, plugin embeds, form elements,
@@ -362,54 +357,21 @@ function escapeAttr(value) {
362
357
  .replace(/=/g, "&#61;");
363
358
  }
364
359
 
365
- // HTML5 named entities that decode to ASCII codepoints focused on
366
- // the entries browsers honor inside URL contexts (whitespace, control
367
- // chars, scheme-significant punctuation). The full WHATWG named-
368
- // character-reference table is ~2,231 entries; this is the
369
- // security-load-bearing subset documented in scheme-bypass writeups
370
- // (CVE-2026-30838 class). High-codepoint named entities (e.g. mathematical
371
- // symbols) don't affect URL scheme parsing, so they're omitted.
372
- var NAMED_ENTITY_ASCII = {
373
- // Whitespace + control chars browsers strip inside URL schemes
374
- Tab: "\t", NewLine: "\n",
375
- // Scheme-significant punctuation
376
- colon: ":", semi: ";", period: ".", sol: "/", bsol: "\\",
377
- num: "#", excl: "!", quest: "?", lpar: "(", rpar: ")",
378
- lsqb: "[", rsqb: "]", lcub: "{", rcub: "}",
379
- // Quotes / brackets
380
- quot: "\"", apos: "'", lt: "<", gt: ">",
381
- // Misc ASCII
382
- amp: "&", commat: "@", dollar: "$", percnt: "%",
383
- ast: "*", plus: "+", lowbar: "_", hyphen: "-",
384
- // Whitespace markers (codepoints in the ASCII / Latin-1 range that
385
- // browsers treat as URL-strippable)
386
- nbsp: " ",
387
- };
388
-
389
- // _normalizeUrl — peel off entity-encoded leading whitespace and
390
- // HTML/URL-encoded scheme prefix tricks, then return the lowercased
391
- // scheme. Returns "" if no scheme.
360
+ // The named-entity ASCII table + entity decoder now live in codepoint-class
361
+ // (NAMED_ENTITY_ASCII / decodeMarkupEntities), shared with guard-svg /
362
+ // guard-markdown so all three decode the SAME browser-honored set and cannot
363
+ // drift on which encoding a scheme / CSS-token danger check must fold away.
364
+ // _extractScheme decode entity-hidden scheme-prefix tricks, fold away the
365
+ // whitespace the WHATWG URL parser would (tab/lf/cr anywhere + a leading/
366
+ // trailing C0-control-or-space run none of which the C0/zero-width strip
367
+ // covers: tab/lf/cr are excluded from C0_CTRL_RANGES and space is not a
368
+ // control), then return the lowercased scheme ("" if none). The shared
369
+ // codepoint-class primitive keeps guard-html / guard-svg from drifting on which
370
+ // whitespace to strip, so neither `java<TAB>script:` nor `&#32;javascript:` can
371
+ // read as scheme-less.
392
372
  function _extractScheme(rawUrl) {
393
- var s = String(rawUrl || "").trim();
394
- // Decode HTML numeric entities (hex &#x..; and decimal &#..;, semicolon
395
- // OPTIONAL) just enough to expose hidden schemes like &#x6A;avascript: or
396
- // the browser-decoded no-semicolon form &#106avascript:. Shared decoder so
397
- // guard-html / guard-svg / guard-markdown can't drift (see codepoint-class).
398
- s = codepointClass.decodeNumericEntities(s);
399
- // Decode HTML5 named entities that browsers honor inside URL
400
- // contexts. Without this, payloads like `java&Tab;script:alert(1)`
401
- // bypass the scheme allowlist (the literal `&Tab;` between `java`
402
- // and `script:` doesn't match any denied scheme; the browser then
403
- // decodes the entity, strips the tab, and executes javascript:).
404
- s = s.replace(/&([A-Za-z][A-Za-z0-9]+);/g, function (m, name) {
405
- if (Object.prototype.hasOwnProperty.call(NAMED_ENTITY_ASCII, name)) {
406
- return NAMED_ENTITY_ASCII[name];
407
- }
408
- return m;
409
- });
410
- // Strip embedded whitespace + control chars + zero-widths the
411
- // URL parser would tolerate.
412
- s = s.replace(C0_CTRL_RE_G, "").replace(ZW_RE_G, "");
373
+ var s = codepointClass.stripUrlSchemeWhitespace(
374
+ codepointClass.decodeMarkupEntities(String(rawUrl || "").trim()));
413
375
  var m = s.match(/^([A-Za-z][A-Za-z0-9+.-]*):/);
414
376
  return m ? m[1].toLowerCase() : "";
415
377
  }
@@ -443,8 +405,19 @@ function _isClobberGlobal(name) {
443
405
  }
444
406
 
445
407
  function _isCssDangerous(value) {
408
+ // A style attribute is HTML/XML character-reference-decoded before the CSS
409
+ // parser sees it, so `ex&#x70;ression(` reaches CSS as `expression(` and
410
+ // `behavior&colon;` as `behavior:`. Match against the decoded value — the same
411
+ // normalization the URL-scheme check performs — so an entity-encoded CSS
412
+ // payload can't be served verbatim past the danger patterns.
413
+ // Also fold the URL-scheme whitespace a browser strips inside url(...) -- tab /
414
+ // lf / cr -- so an entity-hidden tab in a CSS URL scheme (url(java&Tab;script:))
415
+ // cannot defeat the contiguous `javascript:` danger pattern, the same bypass the
416
+ // URL-scheme check folds away for href.
417
+ var decoded = codepointClass.stripUrlSchemeWhitespace(
418
+ codepointClass.decodeMarkupEntities(value));
446
419
  for (var i = 0; i < CSS_DANGEROUS_PATTERNS.length; i += 1) {
447
- if (CSS_DANGEROUS_PATTERNS[i].test(value)) return true;
420
+ if (CSS_DANGEROUS_PATTERNS[i].test(decoded)) return true;
448
421
  }
449
422
  return false;
450
423
  }
@@ -87,8 +87,6 @@ var INLINE_LINK_RE = /(!?)\[([^\]\n]*)\]\(\s*([^)\s]+)\s*(?:"[^"]*")?\s*\)/g;
87
87
  var AUTOLINK_RE = /<((?:[a-zA-Z][a-zA-Z0-9+.-]{0,32}):[^\s>]+)>/g;
88
88
  // Reference-link definition `[label]: url "title"`.
89
89
  var REF_DEF_RE = /^\s{0,3}\[([^\]\n]+)\]:\s*([^\s]+)/gm;
90
- // HTML entity hex / decimal scheme bypass — decode and re-test.
91
- var HTML_ENTITY_NUM_RE = /&#(?:x([0-9a-f]+)|(\d+));?/gi;
92
90
 
93
91
  // Front-matter block (YAML triple-dash or TOML triple-plus).
94
92
  var FRONT_MATTER_YAML_RE = /^---\s*\n[\s\S]+?\n---\s*\n?/;
@@ -99,31 +97,20 @@ var DOCTYPE_INLINE_RE = /<!DOCTYPE\b/i;
99
97
  var CODE_FENCE_LANG_RE = /^(?:```|~~~)([^\n]*)\n/gm;
100
98
  var EMPH_RUN_RE = /[*_]{20,}/; // allow:regex-no-length-cap — character-class repeat is linear in input length
101
99
 
102
- function _decodeHtmlEntities(s) {
103
- return s.replace(HTML_ENTITY_NUM_RE, function (match, hex, dec) {
104
- var code = hex !== undefined ? parseInt(hex, 16) : parseInt(dec, 10); // parseInt radix args (16 hex / 10 decimal)
105
- if (!isFinite(code) || code < 0 || code > 0x10ffff) return match; // Unicode codepoint range
106
- try { return String.fromCodePoint(code); } catch (_e) { return match; }
107
- });
108
- }
109
100
 
110
101
  function _isDangerousUrl(url, opts) {
111
102
  if (typeof url !== "string") return null;
112
- var s = url.trim();
113
- s = _decodeHtmlEntities(s);
114
- // Strip null + ASCII control chars from the URL ` javascript:` works
115
- // in some browsers because the leading control bytes are tolerated.
116
- // Char-by-char filter avoids the no-control-regex lint surface; the
117
- // codepoint catalog (< 0x20 or 0x7F) is the same shape as the
118
- // codepointClass tables.
119
- var stripped = "";
120
- for (var ci = 0; ci < s.length; ci += 1) {
121
- var cc = s.charCodeAt(ci);
122
- if (cc > 0x1f && cc !== 0x7f) stripped += s.charAt(ci); // ASCII control range thresholds
123
- }
124
- s = stripped;
125
- if (DANGEROUS_SCHEME_RE.test(s)) return s.match(/^[a-z]+/i)[0].toLowerCase(); // allow:regex-no-length-cap — `s` is a markdown URL token already bounded by the inline-link / autolink / ref-def matchers (which themselves run on input bounded by maxBytes)
126
- if (FILE_SCHEME_RE.test(s) && opts.filePolicy !== "allow") return "file"; // allow:regex-no-length-cap — same bounded-URL-token reasoning
103
+ // Decode the entity-hidden scheme tricks a browser resolves -- numeric AND the
104
+ // named-entity ASCII subset (guard-markdown previously decoded numeric only, so
105
+ // `java&Tab;script:` / a `&colon;`-hidden scheme slipped past) -- then fold away
106
+ // the whitespace the WHATWG URL parser strips (tab/lf/cr anywhere + a leading/
107
+ // trailing C0-control-or-space run, so `&#32;javascript:` -> " javascript:" can't
108
+ // defeat the `^scheme:` anchor). Shared codepoint-class primitives keep
109
+ // guard-markdown / guard-html / guard-svg from drifting on which encodings to fold.
110
+ var s = codepointClass.stripUrlSchemeWhitespace(
111
+ codepointClass.decodeMarkupEntities(url.trim()));
112
+ if (DANGEROUS_SCHEME_RE.test(s)) return s.match(/^[a-z]+/i)[0].toLowerCase(); // allow:regex-no-length-cap -- `s` is a markdown URL token already bounded by the inline-link / autolink / ref-def matchers (which themselves run on input bounded by maxBytes)
113
+ if (FILE_SCHEME_RE.test(s) && opts.filePolicy !== "allow") return "file"; // allow:regex-no-length-cap -- same bounded-URL-token reasoning
127
114
  return null;
128
115
  }
129
116
 
package/lib/guard-svg.js CHANGED
@@ -122,11 +122,6 @@ void observability;
122
122
 
123
123
  var _err = GuardSvgError.factory;
124
124
 
125
- // ---- Codepoint catalog (shared via lib/codepoint-class) ----
126
-
127
- var C0_CTRL_RE_G = codepointClass.C0_CTRL_RE_G;
128
- var ZW_RE_G = codepointClass.ZW_RE_G;
129
-
130
125
  // ---- Tag classification ----
131
126
 
132
127
  // Always-dangerous SVG tags. Active scripts, namespace-shift escape
@@ -314,32 +309,20 @@ var COMPLIANCE_POSTURES = gateContract.compliancePostures(PROFILES, { base: 256
314
309
  // ---- Internal helpers ----
315
310
 
316
311
 
317
- // HTML5 named-entity ASCII subset same shape as guard-html.
318
- // Browsers honor these inside URL contexts; without decoding them,
319
- // `java&Tab;script:` and friends bypass the scheme allowlist.
320
- var SVG_NAMED_ENTITY_ASCII = {
321
- Tab: "\t", NewLine: "\n",
322
- colon: ":", semi: ";", period: ".", sol: "/", bsol: "\\",
323
- num: "#", excl: "!", quest: "?", lpar: "(", rpar: ")",
324
- lsqb: "[", rsqb: "]", lcub: "{", rcub: "}",
325
- quot: "\"", apos: "'", lt: "<", gt: ">",
326
- amp: "&", commat: "@", dollar: "$", percnt: "%",
327
- ast: "*", plus: "+", lowbar: "_", hyphen: "-",
328
- nbsp: " ",
329
- };
312
+ // The named-entity ASCII table + entity decoder live in codepoint-class
313
+ // (NAMED_ENTITY_ASCII / decodeMarkupEntities), shared with guard-html /
314
+ // guard-markdown so all three decode the SAME browser-honored set and cannot
315
+ // drift on which encoding a scheme / CSS-token danger check must fold away.
330
316
 
331
317
  function _extractScheme(rawUrl) {
332
- var s = String(rawUrl || "").trim();
333
- // Numeric entities (hex/decimal, semicolon OPTIONAL) via the shared decoder
334
- // so guard-html / guard-svg / guard-markdown can't drift (see codepoint-class).
335
- s = codepointClass.decodeNumericEntities(s);
336
- s = s.replace(/&([A-Za-z][A-Za-z0-9]+);/g, function (m, name) {
337
- if (Object.prototype.hasOwnProperty.call(SVG_NAMED_ENTITY_ASCII, name)) {
338
- return SVG_NAMED_ENTITY_ASCII[name];
339
- }
340
- return m;
341
- });
342
- s = s.replace(C0_CTRL_RE_G, "").replace(ZW_RE_G, "");
318
+ // Decode entities, then fold away exactly the whitespace the WHATWG URL parser
319
+ // would (tab/lf/cr anywhere + a leading/trailing C0-control-or-space run) via
320
+ // the shared codepoint-class primitive, so an entity-hidden tab or leading
321
+ // space (`java&Tab;script:` / `&#32;javascript:`) can't push the scheme past
322
+ // the `^[A-Za-z]` anchor and read as scheme-less. Shared with guard-html so
323
+ // the two can't drift on which whitespace to strip.
324
+ var s = codepointClass.stripUrlSchemeWhitespace(
325
+ codepointClass.decodeMarkupEntities(String(rawUrl || "").trim()));
343
326
  var m = s.match(/^([A-Za-z][A-Za-z0-9+.-]*):/);
344
327
  return m ? m[1].toLowerCase() : "";
345
328
  }
@@ -355,8 +338,19 @@ function _isFragmentRef(rawUrl) {
355
338
  }
356
339
 
357
340
  function _isCssDangerous(value) {
341
+ // A style attribute is HTML/XML character-reference-decoded before the CSS
342
+ // parser sees it, so `ex&#x70;ression(` reaches CSS as `expression(` and
343
+ // `behavior&colon;` as `behavior:`. Match against the decoded value — the
344
+ // same normalization the URL-scheme check performs — so an entity-encoded
345
+ // CSS payload can't be served verbatim past the danger patterns.
346
+ // Also fold the URL-scheme whitespace a browser strips inside url(...) -- tab /
347
+ // lf / cr -- so an entity-hidden tab in a CSS URL scheme (url(java&Tab;script:))
348
+ // cannot defeat the contiguous `javascript:` danger pattern, the same bypass the
349
+ // URL-scheme check folds away for href.
350
+ var decoded = codepointClass.stripUrlSchemeWhitespace(
351
+ codepointClass.decodeMarkupEntities(value));
358
352
  for (var i = 0; i < CSS_DANGEROUS_PATTERNS.length; i += 1) {
359
- if (CSS_DANGEROUS_PATTERNS[i].test(value)) return true;
353
+ if (CSS_DANGEROUS_PATTERNS[i].test(decoded)) return true;
360
354
  }
361
355
  return false;
362
356
  }
@@ -782,7 +776,12 @@ function _sanitize(input, opts) {
782
776
  }
783
777
  var allowed = !dangerousTags[tok.name] && allowedTags[tok.name];
784
778
  if (animationTags[tok.name] && opts.allowAnimation && allowedTags[tok.name]) {
785
- // Animation element re-check attributeName.
779
+ // Animation element under a profile that permits animation: allowed iff
780
+ // its attributeName is in the safe-targets allowlist. Every animation tag
781
+ // is in DANGEROUS_TAGS, so `allowed` is already false above — this branch
782
+ // must AFFIRMATIVELY re-permit the safe case, else the open tag is dropped
783
+ // while its (still-allowlisted) end tag is emitted, leaving an orphan
784
+ // close and silently stripping a profile-permitted element.
786
785
  var safeAnimation = true;
787
786
  (tok.attrs || []).forEach(function (a) {
788
787
  if (a.name.toLowerCase() === "attributename" &&
@@ -790,7 +789,7 @@ function _sanitize(input, opts) {
790
789
  safeAnimation = false;
791
790
  }
792
791
  });
793
- if (!safeAnimation) allowed = false;
792
+ allowed = safeAnimation;
794
793
  }
795
794
  if (!allowed) {
796
795
  if (BODY_DROP[tok.name] && !tok.selfClosing) {
@@ -362,6 +362,17 @@ function _renderSequence(nodes, vars, locale, hashContext, depth) {
362
362
  return out;
363
363
  }
364
364
 
365
+ // Own-property case lookup. A select value is operator/end-user supplied, so
366
+ // String(sv) can be `__proto__` / `constructor` / `toString` / `hasOwnProperty`
367
+ // etc. A bare `node.cases[key]` would then return a truthy INHERITED
368
+ // Object.prototype member, bypassing the `other` fallback and either rendering
369
+ // garbage (a proto value with no `.length`) or throwing on `_renderSequence` of
370
+ // a non-array (a request DoS). Every case-map lookup goes through this so no key
371
+ // can reach the prototype chain.
372
+ function _ownCase(cases, key) {
373
+ return Object.prototype.hasOwnProperty.call(cases, key) ? cases[key] : undefined;
374
+ }
375
+
365
376
  function _renderNode(node, vars, locale, hashContext, depth) {
366
377
  if (node.type === "literal") return node.value;
367
378
  if (node.type === "hash") {
@@ -381,18 +392,18 @@ function _renderNode(node, vars, locale, hashContext, depth) {
381
392
  }
382
393
  var adjusted = n - (node.offset || 0);
383
394
  var exact = "=" + n;
384
- var caseBody = node.cases[exact];
395
+ var caseBody = _ownCase(node.cases, exact);
385
396
  if (!caseBody) {
386
397
  var pr = _pluralRules(locale, node.type === "ordinal" ? "ordinal" : "cardinal");
387
398
  var category = pr.select(adjusted);
388
- caseBody = node.cases[category] || node.cases.other;
399
+ caseBody = _ownCase(node.cases, category) || _ownCase(node.cases, "other");
389
400
  }
390
401
  return _renderSequence(caseBody, vars, locale, adjusted, depth + 1);
391
402
  }
392
403
  if (node.type === "select") {
393
404
  var sv = vars[node.name];
394
405
  var key = (sv === undefined || sv === null) ? "other" : String(sv);
395
- var body = node.cases[key] || node.cases.other;
406
+ var body = _ownCase(node.cases, key) || _ownCase(node.cases, "other");
396
407
  return _renderSequence(body, vars, locale, hashContext, depth + 1);
397
408
  }
398
409
  return "";
package/lib/i18n.js CHANGED
@@ -250,12 +250,19 @@ function _validateRtlList(value) {
250
250
  if (!Array.isArray(value)) {
251
251
  throw _err("BAD_OPT", "i18n.create: rtlLanguages must be an array of language subtags");
252
252
  }
253
+ // dir() folds the requested locale's primary subtag to lower case before
254
+ // the membership test, so fold the configured list the same way. Language
255
+ // subtags are conventionally lower case but BCP 47 is case-insensitive;
256
+ // normalizing only the lookup side would silently drop RTL for an
257
+ // operator-supplied entry like "AR" or "CKB".
258
+ var normalized = [];
253
259
  for (var i = 0; i < value.length; i++) {
254
260
  if (typeof value[i] !== "string" || value[i].length === 0) {
255
261
  throw _err("BAD_OPT", "i18n.create: rtlLanguages[" + i + "] must be a non-empty string");
256
262
  }
263
+ normalized.push(value[i].toLowerCase());
257
264
  }
258
- return new Set(value);
265
+ return new Set(normalized);
259
266
  }
260
267
 
261
268
  // ---- Translation tree validation ----
@@ -658,7 +665,14 @@ function create(opts) {
658
665
  _ensureLocaleLoaded(loc);
659
666
  if (!translations[loc]) continue;
660
667
  var v = _resolveKey(translations[loc], key);
661
- if (v !== undefined) {
668
+ if (v === undefined) continue;
669
+ // Only a renderable leaf is a hit: a string, or a plural-shaped
670
+ // object. A nested namespace object at this path is not a
671
+ // translation value — skip it and keep walking the fallback chain so
672
+ // a leaf defined in a less-specific parent or fallback locale still
673
+ // resolves, instead of the namespace shadowing it and the raw key
674
+ // leaking into the UI.
675
+ if (typeof v === "string" || _isPluralShape(v)) {
662
676
  return { value: v, foundIn: loc };
663
677
  }
664
678
  }
@@ -717,14 +731,12 @@ function create(opts) {
717
731
  var raw;
718
732
  if (typeof found.value === "string") {
719
733
  raw = found.value;
720
- } else if (_isPluralShape(found.value)) {
734
+ } else {
735
+ // Guaranteed plural-shaped: _lookupRaw only reports a hit for a
736
+ // renderable leaf (string or plural-shaped object), so a namespace
737
+ // object never reaches here — it is skipped during the chain walk.
721
738
  var count = (vars && typeof vars.count === "number") ? vars.count : 0;
722
739
  raw = _selectPlural(found.value, count, found.foundIn, callerOpts.ordinal === true);
723
- } else {
724
- // Operator stored a nested tree at this key but called t() against
725
- // the namespace. Return the key-path as a missing-key signal.
726
- _emitObs("i18n.missing", { locale: locale, key: key });
727
- return key;
728
740
  }
729
741
 
730
742
  // ICU MessageFormat path — when the operator opts in via
@@ -767,14 +779,12 @@ function create(opts) {
767
779
  callerOpts = callerOpts || {};
768
780
  if (typeof key !== "string" || key.length === 0) return false;
769
781
  var locale = _resolveLocale(callerOpts.locale);
770
- var found = _lookupRaw(key, locale);
771
- if (found === null) return false;
772
- // A nested namespace object (not plural-shaped) is NOT a resolvable
773
- // translation value has() should report false so callers can gate
782
+ // _lookupRaw only reports a hit for a renderable leaf (a string or a
783
+ // plural-shaped object); a nested namespace object is skipped during
784
+ // the chain walk. So has() correctly reports false for a namespace key
785
+ // yet true when a fallback locale supplies the leaf — callers can gate
774
786
  // "show this UI block only if translated" on leaf entries.
775
- if (typeof found.value === "string") return true;
776
- if (_isPluralShape(found.value)) return true;
777
- return false;
787
+ return _lookupRaw(key, locale) !== null;
778
788
  }
779
789
 
780
790
  function formatNumber(value, formatOpts, callerOpts) {
package/lib/mail-scan.js CHANGED
@@ -236,7 +236,7 @@ function create(opts) {
236
236
  return _failTo(auditImpl, e, ms);
237
237
  });
238
238
  }
239
- return _scanClamavInstream(messageBytes).then(function (rv) {
239
+ return _scanClamavInstream(messageBytes, scanOpts).then(function (rv) {
240
240
  rv.durationMs = Date.now() - t0;
241
241
  _emitScanResult(auditImpl, rv);
242
242
  return rv;
@@ -339,9 +339,11 @@ function create(opts) {
339
339
  });
340
340
  }
341
341
 
342
- function _scanClamavInstream(messageBytes) {
342
+ function _scanClamavInstream(messageBytes, scanOpts) {
343
343
  return new Promise(function (resolve, reject) {
344
- var sock = net.createConnection({ host: opts.host, port: opts.port });
344
+ var sock = (scanOpts && scanOpts._socket && typeof scanOpts._socket.write === "function")
345
+ ? scanOpts._socket
346
+ : net.createConnection({ host: opts.host, port: opts.port });
345
347
  var collector = safeBuffer.boundedChunkCollector({
346
348
  maxBytes: caps.maxResponseBytes,
347
349
  errorClass: MailScanError,
@@ -383,21 +385,26 @@ function create(opts) {
383
385
  var reply = collector.result().toString("utf8").replace(/[\r\n\0]+$/g, ""); // allow:regex-no-length-cap — trailing-trim anchored
384
386
  // ClamAV INSTREAM reply: "<id>: <verdict>" where verdict is
385
387
  // "stream: OK", "stream: <Sig.Name> FOUND", or "INSTREAM size
386
- // limit exceeded. ERROR".
387
- if (/stream:\s+OK\b/.test(reply)) { // allow:regex-no-length-cap anchored to fixed token
388
+ // limit exceeded. ERROR". This is a security verdict classifier,
389
+ // so it is fail-closed: the malign FOUND signal is tested BEFORE
390
+ // the benign OK signal. A reply that carries both a FOUND token
391
+ // and an OK token (a coalesced / stale-then-fresh reply on a
392
+ // reused connection, or an intermediary that concatenates two
393
+ // responses) must resolve to "infected", never downgrade to
394
+ // "clean". An unrecognized shape is "error" (do-not-deliver), so
395
+ // the only path to "clean" is an OK with no FOUND / no ERROR.
396
+ var m = reply.match(/stream:\s+(.+?)\s+FOUND\b/); // allow:regex-no-length-cap — anchored to fixed FOUND token
397
+ if (m) {
398
+ resolve({ verdict: "infected", threats: [m[1]] });
399
+ } else if (/ERROR/i.test(reply)) { // allow:regex-no-length-cap — anchored to fixed ERROR token
400
+ resolve({ verdict: "error", threats: [] });
401
+ } else if (/stream:\s+OK\b/.test(reply)) { // allow:regex-no-length-cap — anchored to fixed token
388
402
  resolve({ verdict: "clean", threats: [] });
389
403
  } else {
390
- var m = reply.match(/stream:\s+(.+?)\s+FOUND\b/); // allow:regex-no-length-capanchored to fixed FOUND token
391
- if (m) {
392
- resolve({ verdict: "infected", threats: [m[1]] });
393
- } else if (/ERROR/i.test(reply)) { // allow:regex-no-length-cap anchored to fixed ERROR token
394
- resolve({ verdict: "error", threats: [] });
395
- } else {
396
- // Unrecognized reply shape — treat as error so the caller
397
- // gets a definite "do not deliver" signal instead of a
398
- // silent clean verdict.
399
- resolve({ verdict: "error", threats: [] });
400
- }
404
+ // Unrecognized reply shapetreat as error so the caller
405
+ // gets a definite "do not deliver" signal instead of a
406
+ // silent clean verdict.
407
+ resolve({ verdict: "error", threats: [] });
401
408
  }
402
409
  });
403
410
 
package/lib/safe-dns.js CHANGED
@@ -357,14 +357,24 @@ function checkCnameChainDepth(depth, opts) {
357
357
  }
358
358
  }
359
359
 
360
- function _readName(state, pointerDepth) {
360
+ function _readName(state, pointerDepth, acc) {
361
361
  if (pointerDepth > state.caps.maxPointerDepth) {
362
362
  throw new SafeDnsError("safe-dns/oversize-pointer-depth",
363
363
  "safeDns.readName: compression-pointer chain depth=" + pointerDepth +
364
364
  " exceeds maxPointerDepth=" + state.caps.maxPointerDepth + " (RFC 1035 §4.1.4 loop defense)");
365
365
  }
366
+ // Both per-name accountants — the decompressed octet count and the label
367
+ // count — thread through the WHOLE compression-pointer chain via `acc`. A
368
+ // pointer jump continues the same budget instead of restarting it, so the
369
+ // composite name a chain expands to is bounded by the same RFC 1035 §3.1
370
+ // 255-octet / §2.3.4 maxLabels caps that bound a single in-line name. When
371
+ // the accountants were frame-local, each jump began a fresh 255-octet /
372
+ // maxLabels budget, so a chain of N pointers decompressed to ~N x 255 octets
373
+ // and ~N x maxLabels labels past the advertised per-name caps — a
374
+ // decompression-amplification seam. `acc` is undefined for a top-level name
375
+ // (each is measured independently) and shared with every sub-read.
376
+ if (!acc) acc = { bytes: 0, labels: 0 };
366
377
  var labels = [];
367
- var totalBytes = 0;
368
378
  var jumped = false;
369
379
  var afterPointerOff = -1;
370
380
  var off = state.off;
@@ -376,10 +386,10 @@ function _readName(state, pointerDepth) {
376
386
  var byte = state.buf[off];
377
387
  if (byte === 0) {
378
388
  off += 1;
379
- totalBytes += 1;
380
- if (totalBytes > DNS_MAX_NAME_BYTES) {
389
+ acc.bytes += 1;
390
+ if (acc.bytes > DNS_MAX_NAME_BYTES) {
381
391
  throw new SafeDnsError("safe-dns/oversize-name",
382
- "safeDns.readName: wire-name=" + totalBytes + " bytes exceeds RFC 1035 cap=" +
392
+ "safeDns.readName: wire-name=" + acc.bytes + " bytes exceeds RFC 1035 cap=" +
383
393
  DNS_MAX_NAME_BYTES);
384
394
  }
385
395
  break;
@@ -394,20 +404,14 @@ function _readName(state, pointerDepth) {
394
404
  throw new SafeDnsError("safe-dns/truncated-name",
395
405
  "safeDns.readName: compression pointer offset past message end");
396
406
  }
397
- // First compression pointer ends the in-line label walk
398
- // (line break below). `jumped` can never already be true here;
399
- // assign unconditionally per Codex code-quality review.
407
+ // First compression pointer ends the in-line label walk. The pointed-to
408
+ // tail continues accumulating into the SAME `acc`, so the composite
409
+ // decompressed name stays bounded by the per-name octet + label caps.
400
410
  afterPointerOff = off + 2; // RFC 1035 §4.1.4 2-byte pointer width
401
411
  jumped = true;
402
412
  var subState = { off: ptrOff, buf: state.buf, caps: state.caps };
403
- var tailName = _readName(subState, pointerDepth + 1);
413
+ var tailName = _readName(subState, pointerDepth + 1, acc);
404
414
  if (tailName.length) labels.push(tailName);
405
- totalBytes += 2; // RFC 1035 §4.1.4 2-byte pointer width
406
- if (totalBytes > DNS_MAX_NAME_BYTES) {
407
- throw new SafeDnsError("safe-dns/oversize-name",
408
- "safeDns.readName: composite name=" + totalBytes + " bytes exceeds RFC 1035 cap=" +
409
- DNS_MAX_NAME_BYTES);
410
- }
411
415
  break;
412
416
  }
413
417
  if (byte > DNS_MAX_LABEL_BYTES) {
@@ -419,15 +423,16 @@ function _readName(state, pointerDepth) {
419
423
  "safeDns.readName: label content past message end");
420
424
  }
421
425
  labels.push(state.buf.toString("ascii", off + 1, off + 1 + byte));
422
- if (labels.length > state.caps.maxLabels) {
426
+ acc.labels += 1;
427
+ if (acc.labels > state.caps.maxLabels) {
423
428
  throw new SafeDnsError("safe-dns/oversize-labels",
424
- "safeDns.readName: label count=" + labels.length + " exceeds maxLabels=" + state.caps.maxLabels);
429
+ "safeDns.readName: label count=" + acc.labels + " exceeds maxLabels=" + state.caps.maxLabels);
425
430
  }
426
431
  off += 1 + byte;
427
- totalBytes += 1 + byte;
428
- if (totalBytes > DNS_MAX_NAME_BYTES) {
432
+ acc.bytes += 1 + byte;
433
+ if (acc.bytes > DNS_MAX_NAME_BYTES) {
429
434
  throw new SafeDnsError("safe-dns/oversize-name",
430
- "safeDns.readName: wire-name=" + totalBytes + " bytes exceeds RFC 1035 cap=" +
435
+ "safeDns.readName: wire-name=" + acc.bytes + " bytes exceeds RFC 1035 cap=" +
431
436
  DNS_MAX_NAME_BYTES);
432
437
  }
433
438
  }
package/lib/session.js CHANGED
@@ -681,10 +681,18 @@ async function verify(token, verifyOpts) {
681
681
  if (verifyOpts.requireFingerprintMatch === true) {
682
682
  return null;
683
683
  }
684
- if (typeof verifyOpts.maxAnomalyScore === "number" &&
685
- fingerprintAnomalyScore !== null &&
686
- fingerprintAnomalyScore > verifyOpts.maxAnomalyScore) {
687
- return null;
684
+ if (typeof verifyOpts.maxAnomalyScore === "number") {
685
+ // Genuine drift under a declared strict anomaly threshold. Refuse when
686
+ // the computed score exceeds the threshold — AND when no decisive score
687
+ // could be produced (no scorer supplied, or the scorer threw / returned
688
+ // a non-finite value, leaving fingerprintAnomalyScore null). An
689
+ // uncomputable anomaly score on real drift must FAIL CLOSED, not be
690
+ // treated as "below threshold" and silently admit a relocated device.
691
+ // Same fail-closed discipline the unreadable-binding branch above applies.
692
+ if (fingerprintAnomalyScore === null ||
693
+ fingerprintAnomalyScore > verifyOpts.maxAnomalyScore) {
694
+ return null;
695
+ }
688
696
  }
689
697
  }
690
698
  }
package/lib/webhook.js CHANGED
@@ -669,8 +669,18 @@ function verifier(opts) {
669
669
  throw _failure("MISSING_TIMESTAMP", "webhook: t= field missing from signature header", "missing-timestamp", ctxReq);
670
670
  }
671
671
  var ts = Number(parsed.t);
672
- if (!numericBounds.isNonNegativeFiniteInt(ts)) {
673
- throw _failure("BAD_TIMESTAMP", "webhook: t= field is not a non-negative integer, got " + JSON.stringify(parsed.t), "bad-timestamp", ctxReq);
672
+ // The authenticated timestamp is re-composed into the signed string from
673
+ // `ts` (the Number) below, so it must be the exact canonical decimal the
674
+ // signer emits (`String(Math.floor(now / 1000))`). Validating only the
675
+ // post-coercion value would accept every string Number() maps to the same
676
+ // integer — scientific ("1.7e9"), a trailing ".0", hex ("0x..."), a
677
+ // leading "0"/"+"/whitespace — each of which re-canonicalizes into the
678
+ // signed string and re-verifies, making the authenticated t= field
679
+ // malleable. Require the raw bytes to equal String(ts) so the sole
680
+ // canonical form the signer produces is the sole form accepted (matching
681
+ // the strict digits-only parse the Stripe verifier already applies).
682
+ if (!numericBounds.isNonNegativeFiniteInt(ts) || String(ts) !== parsed.t) {
683
+ throw _failure("BAD_TIMESTAMP", "webhook: t= field is not a canonical non-negative integer, got " + JSON.stringify(parsed.t), "bad-timestamp", ctxReq);
674
684
  }
675
685
  if (parsed.id === null || parsed.id.length === 0) {
676
686
  throw _failure("MISSING_ID", "webhook: id= field missing from signature header", "missing-id", ctxReq);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.16.18",
3
+ "version": "0.16.20",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:1786ee2a-259a-4966-b764-11772b484854",
5
+ "serialNumber": "urn:uuid:4eff6a21-428a-4198-9028-b4137d2c90c5",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-12T08:00:39.980Z",
8
+ "timestamp": "2026-07-12T13:00:38.123Z",
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.18",
22
+ "bom-ref": "@blamejs/core@0.16.20",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.16.18",
25
+ "version": "0.16.20",
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.18",
29
+ "purl": "pkg:npm/%40blamejs/core@0.16.20",
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.18",
57
+ "ref": "@blamejs/core@0.16.20",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]