@blamejs/core 0.16.17 → 0.16.19
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/archive-tar.js +12 -2
- package/lib/audit-sign.js +47 -1
- package/lib/auth/fido-mds3.js +58 -47
- package/lib/codepoint-class.js +85 -0
- package/lib/crypto-field.js +32 -0
- package/lib/guard-graphql.js +30 -11
- package/lib/guard-html.js +26 -53
- package/lib/guard-markdown.js +11 -24
- package/lib/guard-svg.js +31 -32
- package/lib/safe-dns.js +25 -20
- package/lib/self-update.js +20 -0
- 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.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. 	 / :), 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	script:, java	script:, and  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:expression(, background:url(javascript:...), and behavior: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	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.
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
11
15
|
- 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.
|
|
12
16
|
|
|
13
17
|
- v0.16.16 (2026-07-12) — **Build the data-subject-request ticket store's SQL through the shared b.sql query builder instead of hand-assembled statements, and add a static check that keeps db-handle primitives composing b.sql.** A maintainability change with no behavior difference for operators. The b.dsr ticket store built its reads and writes by concatenating table and column names into SQL strings passed to db.prepare, re-implementing the identifier quoting and sealed-field handling that b.sql — the same builder b.db.from() uses — already provides. That hand-rolled shape is how b.tenant.quota's storage query drifted from the query builder and accrued a run of parity defects fixed in 0.16.15 (reserved-word names, schema-qualified names, sealed-column filtering). The store's DML now composes b.sql (its schema DDL, which is not a b.sql concern, stays as direct statements), so its SQL cannot diverge from the builder. A new codebase-patterns check flags any db-handle primitive that passes an inline SELECT / INSERT / UPDATE / DELETE string literal to db.prepare / runSql, directing it to compose b.sql instead, so this class of drift cannot recur. **Changed:** *b.dsr ticket store composes b.sql for its reads and writes* — The data-subject-request ticket store (insert / get / list / update / delete / purge and the legacy re-seal backfill) now builds its DML with the b.sql query builder — sql.select / insert / update / delete(table, { dialect, quoteName }).…toSql() — and prepares the resulting statement, rather than concatenating identifiers into SQL strings by hand. This removes a hand-rolled identifier-quoting surface that could drift from what b.db.from() accepts. Schema provisioning (CREATE TABLE / INDEX, ALTER, PRAGMA) is not a b.sql concern and remains as direct statements. One behavior change: on a store backed by a vault, a ticket payload is AEAD-sealed and base64-encoded (~4/3 expansion) before it is bound, and the bound cell must fit the query builder's 64 MiB per-value ceiling — so the payload is now capped at an expansion-safe plaintext size (~48 MiB) and a larger ticket is refused with dsr/ticket-too-large (route large access/portability exports through chunked storage rather than one giant sealed cell). Plaintext stores keep the full 64 MiB limit. When a vault is first enabled on a table that already holds an over-cap legacy plaintext row, the one-time re-seal backfill still migrates that row's subject columns and derived hashes — so it stays findable by subject lookup and erasable by the data-subject erasure purge — and leaves only the over-cap payload plaintext (still under the read ceiling, DB-encrypted at rest, and removed when the row is erased), rather than failing provisioning with a query-builder error. **Detectors:** *Static check: db-handle primitives must compose b.sql for DML* — A new codebase-patterns check flags any primitive holding a db handle that runs DML by passing an inline SELECT / INSERT / UPDATE / DELETE string literal to db.prepare / runSql — the shape that lets a query drift from b.sql's identifier quoting and sealed-field rewrite (the b.tenant.quota storage defect class). It directs authors to build the query with b.sql and prepare the resulting string. DDL and PRAGMA (not b.sql verbs) and queries already built through a b.sql variable are out of scope.
|
package/lib/archive-tar.js
CHANGED
|
@@ -175,10 +175,20 @@ function _writeString(buf, value, offset, width) {
|
|
|
175
175
|
|
|
176
176
|
function _readOctal(buf, offset, width) {
|
|
177
177
|
// Read an octal-encoded field. Terminator may be space or NUL.
|
|
178
|
+
//
|
|
179
|
+
// POSIX permits numeric fields to be LEFT-padded with spaces (as well as
|
|
180
|
+
// zeros) — star, BSD tar, and several Java/Perl tar libraries emit them,
|
|
181
|
+
// and GNU tar / libarchive skip them. Treating a leading space as a
|
|
182
|
+
// terminator silently misreads such a field as 0 (a parser-differential:
|
|
183
|
+
// the reader would disagree with every other tar on the same bytes, and a
|
|
184
|
+
// misread size desyncs the block walker). Skip leading-space padding first;
|
|
185
|
+
// a space or NUL AFTER the digits still terminates the field.
|
|
186
|
+
var i = 0;
|
|
187
|
+
while (i < width && buf[offset + i] === 0x20) i += 1; // ASCII space (0x20) leading padding per POSIX numeric field
|
|
178
188
|
var s = "";
|
|
179
|
-
for (
|
|
189
|
+
for (; i < width; i += 1) {
|
|
180
190
|
var c = buf[offset + i];
|
|
181
|
-
if (c === 0x20 || c === 0) break; // ASCII space (0x20) + NUL (0x00) field terminators
|
|
191
|
+
if (c === 0x20 || c === 0) break; // ASCII space (0x20) + NUL (0x00) field terminators after digits
|
|
182
192
|
if (c < 0x30 || c > 0x37) { // ASCII '0' (0x30) .. '7' (0x37) octal digits
|
|
183
193
|
throw new TarError("archive-tar/bad-octal",
|
|
184
194
|
"non-octal byte 0x" + c.toString(16) + " at offset " + (offset + i)); // radix=16 for diagnostic hex format
|
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
|
|
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" };
|
package/lib/auth/fido-mds3.js
CHANGED
|
@@ -344,12 +344,16 @@ function _parseNextUpdate(s) {
|
|
|
344
344
|
return d;
|
|
345
345
|
}
|
|
346
346
|
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
|
|
347
|
+
// Single source of truth for the BLOB-trust decision: JWS + x5c-chain verify
|
|
348
|
+
// against the supplied trust roots, payload-shape validation, AND the
|
|
349
|
+
// stale-BLOB refusal. fetch() (operator-supplied roots via caCertificate) and
|
|
350
|
+
// _verifyAndParseBlob (default vendored roots) BOTH route here so no check can
|
|
351
|
+
// ever be present on one path but missing on the other. The stale-refusal used
|
|
352
|
+
// to live only in this helper's caller-inlined twin, so the operator-facing
|
|
353
|
+
// fetch path silently accepted (and cached) a signed-but-expired BLOB; keeping
|
|
354
|
+
// one body closes that class.
|
|
355
|
+
function _verifyBlobWithRoots(token, rootPems) {
|
|
351
356
|
var jws = _parseJws(token);
|
|
352
|
-
var rootPems = _resolveRoots(undefined);
|
|
353
357
|
var chain = _validateChain(jws.header.x5c, rootPems);
|
|
354
358
|
_verifyJws(jws, chain[0]);
|
|
355
359
|
var payload = jws.payload;
|
|
@@ -366,19 +370,19 @@ function _verifyAndParseBlob(token) {
|
|
|
366
370
|
throw new FidoMds3Error("fido-mds3/bad-payload",
|
|
367
371
|
"BLOB payload 'nextUpdate' missing or not YYYY-MM-DD: " + payload.nextUpdate);
|
|
368
372
|
}
|
|
369
|
-
// Stale-BLOB refusal
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
373
|
+
// Stale-BLOB refusal (FIDO MDS3 section 3.1.7): clients SHOULD refresh by
|
|
374
|
+
// nextUpdate; a BLOB whose nextUpdate is already in the past is not safe to
|
|
375
|
+
// trust even though its cert chain still validates. Flooring the staleness to
|
|
376
|
+
// MIN_CACHE_TTL_MS in _ttlFromNextUpdate is not enough on its own -- the BLOB
|
|
377
|
+
// itself must be refused, or an attacker serving an ancient signed-but-
|
|
378
|
+
// expired BLOB keeps operators pinned to a revoked-authenticator list frozen
|
|
379
|
+
// at that time. Refuse at parse time so neither fetch nor a cache lookup ever
|
|
380
|
+
// honors it.
|
|
377
381
|
if (nextUpdate.getTime() < Date.now()) {
|
|
378
382
|
throw new FidoMds3Error("fido-mds3/blob-stale",
|
|
379
383
|
"BLOB payload nextUpdate \"" + payload.nextUpdate +
|
|
380
|
-
"\" is in the past
|
|
381
|
-
"(FIDO MDS3
|
|
384
|
+
"\" is in the past -- refusing to trust a stale metadata BLOB " +
|
|
385
|
+
"(FIDO MDS3 section 3.1.7)");
|
|
382
386
|
}
|
|
383
387
|
return {
|
|
384
388
|
entries: payload.entries,
|
|
@@ -388,6 +392,13 @@ function _verifyAndParseBlob(token) {
|
|
|
388
392
|
};
|
|
389
393
|
}
|
|
390
394
|
|
|
395
|
+
// Internal verify-blob helper used by tests to exercise the verifier against
|
|
396
|
+
// the DEFAULT vendored MDS3 trust roots without standing up a real HTTPS
|
|
397
|
+
// endpoint. Operator-facing surface goes through fetch().
|
|
398
|
+
function _verifyAndParseBlob(token) {
|
|
399
|
+
return _verifyBlobWithRoots(token, _resolveRoots(undefined));
|
|
400
|
+
}
|
|
401
|
+
|
|
391
402
|
// ---- public surface ----
|
|
392
403
|
|
|
393
404
|
/**
|
|
@@ -481,39 +492,22 @@ async function fetch(opts) { // allow:raw-outbound-http-framework-internal —
|
|
|
481
492
|
}
|
|
482
493
|
var token = rsp.body.toString("ascii").trim();
|
|
483
494
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
}
|
|
492
|
-
if (typeof payload.no !== "number" || !isFinite(payload.no)) {
|
|
493
|
-
throw new FidoMds3Error("fido-mds3/bad-payload",
|
|
494
|
-
"BLOB payload missing or non-numeric 'no'");
|
|
495
|
-
}
|
|
496
|
-
var nextUpdate = _parseNextUpdate(payload.nextUpdate);
|
|
497
|
-
if (!nextUpdate) {
|
|
498
|
-
throw new FidoMds3Error("fido-mds3/bad-payload",
|
|
499
|
-
"BLOB payload 'nextUpdate' missing or not YYYY-MM-DD: " + payload.nextUpdate);
|
|
500
|
-
}
|
|
501
|
-
var record = {
|
|
502
|
-
entries: payload.entries,
|
|
503
|
-
no: payload.no,
|
|
504
|
-
nextUpdate: nextUpdate,
|
|
505
|
-
url: url,
|
|
506
|
-
legalHeader: payload.legalHeader,
|
|
507
|
-
};
|
|
495
|
+
// Route the trust decision through the shared verifier so this operator-
|
|
496
|
+
// facing path enforces the identical checks (chain verify, payload shape,
|
|
497
|
+
// AND the stale-BLOB refusal) as the internal helper -- fetch previously
|
|
498
|
+
// reimplemented the parse inline and omitted the stale check, silently
|
|
499
|
+
// accepting and caching an ancient signed-but-expired BLOB.
|
|
500
|
+
var record = _verifyBlobWithRoots(token, rootPems);
|
|
501
|
+
record.url = url;
|
|
508
502
|
// Re-assert TTL based on the BLOB's nextUpdate (overrides the
|
|
509
503
|
// wrap-call's safe-minimum seed).
|
|
510
|
-
try { await c.set(cacheKey, record, _ttlFromNextUpdate(nextUpdate)); }
|
|
504
|
+
try { await c.set(cacheKey, record, _ttlFromNextUpdate(record.nextUpdate)); }
|
|
511
505
|
catch (_e) { /* cache.set best-effort */ }
|
|
512
506
|
try { audit().safeEmit({
|
|
513
507
|
action: "auth.fido_mds3.fetch",
|
|
514
508
|
outcome: "success",
|
|
515
|
-
metadata: { url: url, no:
|
|
516
|
-
nextUpdate:
|
|
509
|
+
metadata: { url: url, no: record.no, entries: record.entries.length,
|
|
510
|
+
nextUpdate: record.nextUpdate.toISOString().slice(0, 10) },
|
|
517
511
|
}); } catch (_e) { /* audit best-effort */ }
|
|
518
512
|
return record;
|
|
519
513
|
}, MIN_CACHE_TTL_MS);
|
|
@@ -577,18 +571,35 @@ function lookupAaguid(blob, aaguid) {
|
|
|
577
571
|
function _certifiedLevel(statusReports) {
|
|
578
572
|
if (!Array.isArray(statusReports)) return { level: 0, plus: false };
|
|
579
573
|
var latest = null;
|
|
580
|
-
var latestDate = null;
|
|
574
|
+
var latestDate = null; // null == no comparable date (missing / malformed)
|
|
581
575
|
for (var i = 0; i < statusReports.length; i++) {
|
|
582
576
|
var sr = statusReports[i];
|
|
583
577
|
// Only certification-status reports move the level: a level grant, or its
|
|
584
578
|
// explicit revocation (NOT_FIDO_CERTIFIED). Other statuses (UPDATE_AVAILABLE,
|
|
585
|
-
// REVOKED,
|
|
586
|
-
// status length is bounded before the regex test below
|
|
579
|
+
// REVOKED, etc.) are handled elsewhere and must not be read as a level. The
|
|
580
|
+
// status length is bounded before the regex test below -- FIDO status tokens
|
|
587
581
|
// are short enums; bounding input before any .test() is the convention.
|
|
588
582
|
if (!sr || typeof sr.status !== "string" || sr.status.length > 64) continue;
|
|
589
583
|
if (!CERT_LEVEL_RE.test(sr.status) && sr.status !== "NOT_FIDO_CERTIFIED") continue;
|
|
590
|
-
|
|
591
|
-
|
|
584
|
+
// Only a well-formed calendar date is comparable. Reuse _parseNextUpdate
|
|
585
|
+
// (which already validates YYYY-MM-DD AND rejects impossible dates such as
|
|
586
|
+
// 2026-02-31) and compare epoch ms. A missing / malformed effectiveDate is
|
|
587
|
+
// "no comparable date" (null) and falls back to array order (append order,
|
|
588
|
+
// which the spec defines as chronological): the report later in the array
|
|
589
|
+
// is the more recent one. Coercing a missing date to "" and running it
|
|
590
|
+
// through a lexical compare made an undated report lose to EVERY earlier
|
|
591
|
+
// dated report, so a later decertification / downgrade / upgrade whose
|
|
592
|
+
// effectiveDate was absent never superseded the earlier grant -- freezing
|
|
593
|
+
// certifiedLevel at a stale value (an authenticator-assurance bypass: a
|
|
594
|
+
// decertified or downgraded authenticator kept reporting its prior level to
|
|
595
|
+
// step-up / risk policy).
|
|
596
|
+
var parsed = typeof sr.effectiveDate === "string" ? _parseNextUpdate(sr.effectiveDate) : null;
|
|
597
|
+
var d = parsed ? parsed.getTime() : null;
|
|
598
|
+
if (latest === null) { latest = sr; latestDate = d; continue; }
|
|
599
|
+
// sr is later in array order than the current latest. A missing/malformed
|
|
600
|
+
// date on EITHER side means array order decides -> sr (later) wins. Both
|
|
601
|
+
// dated -> the newer-or-equal date wins (sr, being later, takes ties).
|
|
602
|
+
if (d === null || latestDate === null || d >= latestDate) {
|
|
592
603
|
latest = sr; latestDate = d;
|
|
593
604
|
}
|
|
594
605
|
}
|
package/lib/codepoint-class.js
CHANGED
|
@@ -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	script:` / `behavior:`). 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 `	` /
|
|
589
|
+
// `
` 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("expression("); // "expression("
|
|
624
|
+
* b.codepointClass.decodeMarkupEntities("behavior:"); // "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
|
+
* (` 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,
|
package/lib/crypto-field.js
CHANGED
|
@@ -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-graphql.js
CHANGED
|
@@ -165,7 +165,8 @@ function _measureQueryShape(query) {
|
|
|
165
165
|
var depth = 0;
|
|
166
166
|
var inString = false;
|
|
167
167
|
var inComment = false;
|
|
168
|
-
var
|
|
168
|
+
var escapeNext = false; // inside a string, the previous char was an unescaped backslash
|
|
169
|
+
var aliasStack = [0]; // per-selection-set alias counter (top of stack = current set)
|
|
169
170
|
for (var i = 0; i < query.length; i += 1) {
|
|
170
171
|
var c = query.charAt(i);
|
|
171
172
|
if (inComment) {
|
|
@@ -173,7 +174,18 @@ function _measureQueryShape(query) {
|
|
|
173
174
|
continue;
|
|
174
175
|
}
|
|
175
176
|
if (inString) {
|
|
176
|
-
|
|
177
|
+
// Escaped-quote handling must track the backslash RUN, not just the
|
|
178
|
+
// single preceding char. `"\\"` is a complete string (its backslash is
|
|
179
|
+
// itself escaped), so a naive `charAt(i - 1) !== "\\"` test reads the
|
|
180
|
+
// closing quote as escaped, leaves the walker stuck in-string, and
|
|
181
|
+
// blinds it to every following brace / colon — silently under-counting
|
|
182
|
+
// depth and alias breadth so a depth-bomb or alias-bomb rides through as
|
|
183
|
+
// shape-clean (a fail-open DoS-measurement bypass on valid GraphQL).
|
|
184
|
+
// Toggle on each backslash: a closing quote ends the string only after
|
|
185
|
+
// an EVEN run (escapeNext false).
|
|
186
|
+
if (escapeNext) { escapeNext = false; continue; }
|
|
187
|
+
if (c === "\\") { escapeNext = true; continue; }
|
|
188
|
+
if (c === '"') inString = false;
|
|
177
189
|
continue;
|
|
178
190
|
}
|
|
179
191
|
if (c === '"') { inString = true; continue; }
|
|
@@ -181,28 +193,35 @@ function _measureQueryShape(query) {
|
|
|
181
193
|
if (c === "{") {
|
|
182
194
|
depth += 1;
|
|
183
195
|
if (depth > maxDepth) maxDepth = depth;
|
|
184
|
-
|
|
196
|
+
aliasStack.push(0);
|
|
185
197
|
} else if (c === "}") {
|
|
186
198
|
// Capture the current selection-set's alias count before popping
|
|
187
199
|
// — otherwise we lose the per-block max when the block closes.
|
|
188
|
-
var current =
|
|
200
|
+
var current = aliasStack[aliasStack.length - 1] || 0;
|
|
189
201
|
if (current > maxAliases) maxAliases = current;
|
|
190
202
|
depth -= 1;
|
|
191
|
-
|
|
203
|
+
// Never pop the base counter: an unbalanced leading `}` would otherwise
|
|
204
|
+
// desync the stack from `depth`, and a subsequent `depth`-indexed
|
|
205
|
+
// increment lands on an absent slot (`undefined + 1 === NaN`), poisoning
|
|
206
|
+
// every later comparison so an alias-bomb reads as clean.
|
|
207
|
+
if (aliasStack.length > 1) aliasStack.pop();
|
|
192
208
|
if (depth < 0) depth = 0;
|
|
193
209
|
} else if (c === ":") {
|
|
194
|
-
// Alias indicator — `alias: field
|
|
195
|
-
//
|
|
210
|
+
// Alias / argument indicator — `alias: field` / `field(arg: value)`.
|
|
211
|
+
// Increment the CURRENT selection-set's counter (top of stack) when the
|
|
212
|
+
// char before `:` looks like an identifier. Using the stack top rather
|
|
213
|
+
// than a `depth` index keeps counting correct even when a malformed
|
|
214
|
+
// brace run has decoupled `depth` from the stack length.
|
|
196
215
|
var prev = i > 0 ? query.charAt(i - 1) : "";
|
|
197
|
-
if (/[A-Za-z0-9_]/.test(prev) &&
|
|
198
|
-
|
|
216
|
+
if (/[A-Za-z0-9_]/.test(prev) && aliasStack.length > 1) {
|
|
217
|
+
aliasStack[aliasStack.length - 1] += 1;
|
|
199
218
|
}
|
|
200
219
|
}
|
|
201
220
|
}
|
|
202
221
|
// Final sweep covers any unclosed selection-sets (operator-supplied
|
|
203
222
|
// syntactically-invalid queries).
|
|
204
|
-
for (var ai = 0; ai <
|
|
205
|
-
if (
|
|
223
|
+
for (var ai = 0; ai < aliasStack.length; ai += 1) {
|
|
224
|
+
if (aliasStack[ai] > maxAliases) maxAliases = aliasStack[ai];
|
|
206
225
|
}
|
|
207
226
|
return { maxDepth: maxDepth, maxAliases: maxAliases };
|
|
208
227
|
}
|
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, "=");
|
|
363
358
|
}
|
|
364
359
|
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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 ` javascript:` can
|
|
371
|
+
// read as scheme-less.
|
|
392
372
|
function _extractScheme(rawUrl) {
|
|
393
|
-
var s =
|
|
394
|
-
|
|
395
|
-
// OPTIONAL) just enough to expose hidden schemes like javascript: or
|
|
396
|
-
// the browser-decoded no-semicolon form javascript:. 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	script:alert(1)`
|
|
401
|
-
// bypass the scheme allowlist (the literal `	` 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 `expression(` reaches CSS as `expression(` and
|
|
410
|
+
// `behavior:` 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	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(
|
|
420
|
+
if (CSS_DANGEROUS_PATTERNS[i].test(decoded)) return true;
|
|
448
421
|
}
|
|
449
422
|
return false;
|
|
450
423
|
}
|
package/lib/guard-markdown.js
CHANGED
|
@@ -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
|
-
|
|
113
|
-
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
var
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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	script:` / a `:`-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 ` 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
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
|
|
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
|
-
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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	script:` / ` 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 `expression(` reaches CSS as `expression(` and
|
|
343
|
+
// `behavior:` 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	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(
|
|
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
|
|
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
|
-
|
|
792
|
+
allowed = safeAnimation;
|
|
794
793
|
}
|
|
795
794
|
if (!allowed) {
|
|
796
795
|
if (BODY_DROP[tok.name] && !tok.selfClosing) {
|
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
|
-
|
|
380
|
-
if (
|
|
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=" +
|
|
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
|
-
//
|
|
399
|
-
//
|
|
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
|
-
|
|
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
|
|
429
|
+
"safeDns.readName: label count=" + acc.labels + " exceeds maxLabels=" + state.caps.maxLabels);
|
|
425
430
|
}
|
|
426
431
|
off += 1 + byte;
|
|
427
|
-
|
|
428
|
-
if (
|
|
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=" +
|
|
435
|
+
"safeDns.readName: wire-name=" + acc.bytes + " bytes exceeds RFC 1035 cap=" +
|
|
431
436
|
DNS_MAX_NAME_BYTES);
|
|
432
437
|
}
|
|
433
438
|
}
|
package/lib/self-update.js
CHANGED
|
@@ -378,6 +378,15 @@ async function poll(opts) {
|
|
|
378
378
|
allowedHosts: opts.allowedHosts,
|
|
379
379
|
allowedProtocols: opts.allowedProtocols,
|
|
380
380
|
allowInternal: opts.allowInternal,
|
|
381
|
+
// poll() owns status handling — the branches below distinguish a 304
|
|
382
|
+
// If-None-Match "fast no-update" hit from a real non-2xx refusal and a
|
|
383
|
+
// 2xx feed to parse. Without always-resolve, httpClient.request rejects
|
|
384
|
+
// EVERY non-2xx (304 included) as HTTP_ERROR before poll can inspect
|
|
385
|
+
// res.statusCode, which made the documented conditional-poll fast-path
|
|
386
|
+
// and the selfupdate/poll-non-2xx branch dead code — a conditional poll
|
|
387
|
+
// that correctly received a 304 threw selfupdate/poll-failed instead of
|
|
388
|
+
// reporting "no update".
|
|
389
|
+
responseMode: "always-resolve",
|
|
381
390
|
errorClass: SelfUpdateError,
|
|
382
391
|
});
|
|
383
392
|
} catch (e) {
|
|
@@ -630,6 +639,15 @@ function _validateSwapOpts(opts, label) {
|
|
|
630
639
|
"selfUpdate.swap: opts.hashAlgo must be one of " + ALLOWED_HASH_ALGS.join(", "));
|
|
631
640
|
}
|
|
632
641
|
};
|
|
642
|
+
// swap re-reads the from-bytes to re-hash them (closing the verify->swap
|
|
643
|
+
// window); its cap must be declarable so it matches the maxBytes an
|
|
644
|
+
// operator passed to selfUpdate.verify for the same asset — otherwise swap
|
|
645
|
+
// would refuse a large binary that verify accepted. Optional; defaults to
|
|
646
|
+
// the same C.BYTES.gib(1) cap the body applies.
|
|
647
|
+
schema.maxBytes = function (value) {
|
|
648
|
+
numericBounds.requirePositiveFiniteIntIfPresent(value,
|
|
649
|
+
"selfUpdate.swap: opts.maxBytes", SelfUpdateError, "selfupdate/bad-max-bytes");
|
|
650
|
+
};
|
|
633
651
|
}
|
|
634
652
|
schema.to = { rule: "required-string", code: "selfupdate/bad-to",
|
|
635
653
|
label: "selfUpdate." + label + ": opts.to" };
|
|
@@ -696,6 +714,8 @@ async function _safeRollback(backupTo, to, hadOriginal) {
|
|
|
696
714
|
* backupTo: string, // required — backup path for the existing `to`
|
|
697
715
|
* expectedHash: string, // required — the hash selfUpdate.verify returned
|
|
698
716
|
* hashAlgo: string, // sha3-512 (default) | sha-256 | sha-512 | shake256
|
|
717
|
+
* maxBytes: number, // from-bytes re-hash cap (default 1 GiB) — set to
|
|
718
|
+
* // the same value passed to selfUpdate.verify
|
|
699
719
|
*
|
|
700
720
|
* @example
|
|
701
721
|
* var v = await b.selfUpdate.verify({ assetPath, signaturePath, pubkeyPem });
|
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:eb1100d2-6fe5-42ed-8c45-8ecc67a610fa",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-12T11:39:25.071Z",
|
|
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.19",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.16.
|
|
25
|
+
"version": "0.16.19",
|
|
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.19",
|
|
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.19",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|