@blamejs/core 0.17.3 → 0.17.5
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/backup/manifest.js +8 -0
- package/lib/db-query.js +34 -7
- package/lib/guard-agent-registry.js +1 -1
- package/lib/guard-list-unsubscribe.js +2 -2
- package/lib/html-balance.js +2 -2
- package/lib/mail-bimi.js +1 -1
- package/lib/mail-helo.js +6 -1
- package/lib/mail-server-registry.js +8 -2
- package/lib/network-dns-resolver.js +11 -1
- package/lib/nonce-store.js +9 -1
- package/lib/restore.js +19 -0
- package/lib/safe-decompress.js +8 -2
- package/lib/safe-sql.js +88 -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.17.x
|
|
10
10
|
|
|
11
|
+
- v0.17.5 (2026-07-17) — **A tampered or over-filtered backup can no longer silently wipe the live data directory on restore, DNSSEC-strict resolution stays enforced when a stale answer is served, and a family of guards that matched a name against a lookup table now reject a prototype-member name instead of misclassifying it.** Restoring a backup whose manifest lists zero files -- because a tampered unsigned bundle stripped every entry, or an opts.filter matched nothing -- extracted an empty staging directory and swapped it over the live data directory: a silent, full data-directory wipe reported to the operator as a successful restore. The manifest validator now refuses an empty file list, and the restore path refuses to swap a zero-file extract over a non-empty data directory. Separately, b.network.dns.resolver's validate: true DNSSEC gate was skipped on the serve-stale path, so an upstream outage could downgrade a DNSSEC-strict lookup (including DANE TLSA resolution) to unauthenticated stale data; the gate now holds on the stale path too. And a group of guards resolved a host, scheme, tag, method, or agent name against a plain-object lookup table with a truthiness read, so a name colliding with an inherited object member (constructor) was misclassified -- over-rejected as reserved/forbidden, or, for the mail-server method catalogue, passed the catalogue gate unregistered. All now match against the table's own keys. **Security:** *Restore refuses a zero-file backup instead of wiping the data directory* — b.backupManifest.validate accepted a manifest with an empty files array. The bundle writer never emits one, but the validator is the single guard the restore path parses an untrusted manifest through, so a tampered unsigned bundle that stripped every file entry (while keeping the still-valid wrapped vault key, which needs no passphrase knowledge) parsed cleanly, extracted an empty staging directory, and had the atomic swap move that empty directory over the live data directory -- a silent, destructive wipe of every file, reported to the operator as a successful restore with a file count of zero. The validator now refuses an empty file list (fixing every consumer -- parse, create, serialize -- at once), so a tampered manifest is rejected before extraction. As defense in depth, b.restore.run now also refuses to swap a zero-file extract over a non-empty data directory, which additionally catches an opts.filter that matched no manifest entry; the swap is refused with the data directory left intact rather than destroyed. · *DNSSEC-strict resolution stays enforced on the serve-stale path* — b.network.dns.resolver's validate: true option refuses a response that is not DNSSEC-authenticated (AD=0), but the RFC 8767 serve-stale short-circuit returned a cached stale answer before that gate ran. An attacker who can force an upstream outage (denying the DoH endpoint) could therefore downgrade a DNSSEC-strict lookup -- including b.network.dns.resolver.queryTlsa for DANE, queryDs, or a CNAME chase -- to unauthenticated stale data cached earlier at AD=0, with the caller seeing a success rather than the documented validate-failed refusal. The AD-bit gate now applies on the serve-stale path too (the DNSSEC verdict is per-response), so a stale answer that was not authenticated is refused rather than served to a validating caller. · *Name-lookup guards reject a prototype-member name instead of misclassifying it* — A group of guards tested whether a caller- or peer-supplied name was in a fixed table -- a reserved-HELO-name set, a reserved-local-host set, a dangerous-URL-scheme denylist, a reserved agent-name set, a Tiny-PS forbidden-element set, an HTML void-element set, and the mail-server method catalogue -- with a plain-object truthiness read (TABLE[name]) that walks the prototype chain. A name that collides with an inherited object member -- constructor is the one that survives a lowercased key -- read the inherited value as truthy and was misclassified: over-rejected as reserved or forbidden (b.mail.helo, b.guardListUnsubscribe, b.guardAgentRegistry, b.mailBimi, b.htmlBalance), or, for the mail-server method catalogue, passed the catalogue gate as if it were a registered method (a fail-open at registration). Every site now matches against the table's own keys with an own-property check, so a prototype-member name is treated as any other unknown name; supported names are unaffected.
|
|
12
|
+
|
|
13
|
+
- v0.17.4 (2026-07-17) — **Three fail-open holes closed: a raw cross-border write could evade the data-residency gate by wedging a comment or eliding whitespace, safe decompression returned undecompressed bytes for an unknown algorithm name, and the shared anti-replay helper admitted a replay when its store signalled a duplicate with a non-boolean value.** A raw SQL write submitted through b.db.runSql / b.db.prepare().run() could slip past the cross-border data-residency gate: the gate's write-detection matched tokens on whitespace boundaries, but SQL lets tokens abut with no whitespace when a comment or a quoted identifier sits between them, so INSERT/**/INTO, an INSERT whose quoted table name abuts INTO, and UPDATE/**/table all executed while the gate never engaged -- landing or moving a row across a residency boundary under a regulated posture. b.safeDecompress resolved its algorithm allowlist with a truthiness lookup, so an algorithm name that collides with an inherited object member (constructor, toString) reached the dispatch and returned the input undecompressed instead of refusing the unknown algorithm -- a fail-open for any caller that maps a client Content-Encoding onto the algorithm. And b.nonceStore.enforceReplay -- the anti-replay helper JWT and DPoP verification use -- detected a replay only when its store returned a literal false, so a store signalling a duplicate with another falsy value (a Redis SETNX null, a SQL INSERT rowcount of 0) let the replayed token through. **Added:** *b.safeSql.normalizeForScan* — A parse-only SQL normalizer that produces a copy whose token boundaries are real whitespace, so a scanner built with whitespace-anchored patterns cannot be evaded by a comment or a quoted-identifier boundary that lets two tokens abut. It collapses line and block comments to a single space and inserts a separating space where a quoted string or identifier follows a word character, in the same quote- and comment-aware single pass as the existing b.safeSql placeholder scanners (a comment marker inside a string literal is preserved, never collapsed). The executed SQL is never derived from this copy -- it only feeds a scanner. **Security:** *Data-residency gate catches a raw write that hides behind a comment or elided whitespace* — The cross-border data-residency gate that guards raw SQL writes (b.db.runSql, b.db.prepare().run()) detected the write and extracted its target table with regexes that assume tokens are separated by whitespace. SQL does not require whitespace between tokens when a comment or a quoted-identifier boundary separates them, so three forms that every engine executes as ordinary writes slipped the detection entirely and the gate never engaged: an INSERT whose double-quoted table name abuts INTO with no space, an INSERT with a block comment wedged between INSERT and INTO, and the same for UPDATE and its table. Under a regulated residency posture a row could therefore be inserted or moved across a residency boundary with no tag check (CWE-863; a GDPR Chapter V transfer-restriction bypass). The gate now normalizes a parse-only copy of the statement through the new b.safeSql.normalizeForScan before matching -- collapsing comments to a space and inserting a boundary where a quoted token abuts a word character -- so every write is detected and gated regardless of how its tokens are spaced. The executed SQL is unchanged. · *Safe decompression refuses an unknown algorithm instead of returning the input undecompressed* — b.safeDecompress resolved opts.algorithm against its allowlist of supported codecs (gzip, deflate, deflate-raw, brotli) with a truthiness lookup on a plain object. An algorithm name that names an inherited object member -- constructor, toString, valueOf -- read back a truthy inherited value, slipped the unsupported-algorithm refusal, and reached the dispatch, where invoking the inherited member returned the raw input buffer. The primitive therefore silently returned undecompressed bytes with no error, contrary to its contract that any algorithm outside the allowlist is refused -- a fail-open for a consumer that maps a client-supplied Content-Encoding token onto the algorithm, which also sidesteps the decompression-bomb size and ratio caps. The allowlist is now resolved with an own-property check, so any unsupported name -- however it collides with a built-in member -- is refused. · *Shared anti-replay helper fails closed on a non-boolean store result* — b.nonceStore.enforceReplay is the anti-replay helper that b.auth.jwt and b.auth.dpop route their replay defense through; it calls the store's checkAndInsert and treated the result as a replay only when it was the literal false. The recommended stores signal a duplicate with a different falsy value -- a Redis SET ... NX returns null and a SQL INSERT ... ON CONFLICT returns a rowcount of 0 -- so a store using either would have its duplicate signal missed and the replayed nonce admitted (a replay-protection bypass for a JWT or DPoP proof). enforceReplay now admits a nonce only on a truthy first-seen confirmation and treats every other result as a replay, matching the fail-closed check the framework's inline anti-replay consumers already use.
|
|
14
|
+
|
|
11
15
|
- v0.17.3 (2026-07-17) — **A family of security guards that compared a token case-sensitively where the browser or HTTP stack matches it case-insensitively now normalize first, closing a CSP unsafe-keyword bypass and a cookie-prefix bypass and fixing several over-strict refusals of spec-compliant input.** Browsers, HTTP stacks, and URL parsers match a whole family of security-relevant tokens ASCII case-insensitively -- CSP source keywords and scheme sources, the __Secure-/__Host- cookie name prefixes, HTTP auth schemes, media types, and URL schemes. Several framework guards compared those tokens case-sensitively, so a mixed-case variant behaved differently in the guard than it does downstream. In the bypass direction this let a case-variant unsafe CSP keyword reach a header the browser still honors, and let a prefix-violating cookie past the invariant that keeps the browser from silently dropping it. In the over-strict direction it wrongly refused spec-compliant input (a lowercase auth scheme, a mixed-case media type, an uppercase URL scheme, a mixed-case operator allowlist entry). Every case now normalizes the token to ASCII lowercase before the membership, equality, or prefix test. Separately, two crypto algorithm-name lookups and a CBOR integer-boundary round-trip are corrected. **Fixed:** *Auth scheme, media type, and URL scheme comparisons accept spec-compliant case variants* — Three guards refused input that a case-insensitive downstream would accept. The CIBA notification endpoint (b.auth.ciba) matched the Authorization Bearer scheme case-sensitively, rejecting a spec-compliant 'bearer <token>' sender (RFC 7235 auth-schemes are case-insensitive); it now matches the scheme case-insensitively while comparing the token itself verbatim. The tus upload middleware's creation-with-upload path compared the application/offset+octet-stream media type as an exact string, so a compliant 'Application/Offset+Octet-Stream' (or a variant carrying a parameter) created the upload but silently dropped the body; it now compares the lowercased media type with parameters stripped (RFC 7231 §3.1.1.1). The NEL middleware required its collector URL to begin with a lowercase 'https://', rejecting an equivalent 'HTTPS://'; it now compares the scheme case-insensitively (RFC 3986). · *Redirect allowlist matches a mixed-case operator origin or host* — b.safeRedirect.resolve compared the parser-canonicalized (lowercased) origin and host of a candidate URL against the operator's allowedOrigins/allowedHosts entries verbatim. A mixed-case operator entry such as 'Example.COM' or 'HTTPS://Example.com' therefore silently never matched, and a legitimate redirect fell through to the fallback. The operator entries are now canonicalized to lowercase before the comparison. This only makes the operator's intended allowlist work; because the attacker-controlled target is already normalized by the parser, it never widens the allowlist. · *Streaming-hash and OPRF suite selection reject an unknown algorithm name cleanly* — b.crypto.hashStream (and b.crypto.hashFile, which composes it) and b.crypto.oprf.suite resolved a caller-supplied algorithm/suite name against a plain-object lookup table with a truthiness check. A name colliding with an inherited Object.prototype member -- 'constructor' or '__proto__' -- read back a truthy inherited value and slipped the guard: hashStream then threw a synchronous TypeError from a function documented to return a rejected Promise (an unhandled exception for a caller wired only to .catch()), and oprf.suite returned a malformed suite object instead of throwing its documented bad-suite error. Both now resolve the name through an own-property check, matching the guarding used for the SRI algorithm table, so any unknown name is refused with the documented error. · *Deterministic CBOR re-encodes a negative integer at the -2^53 boundary as an integer* — The CBOR decoder returned the negative integer at the -2^53 boundary as a plain Number, but that value is one below the safe-integer range, so the deterministic encoder's integer branch re-emitted it as a float -- breaking round-trip and falsely tripping the requireDeterministic check on a value that is canonically an integer. The decoder now promotes a negative integer that is not a safe integer to a BigInt, so the encoder re-emits the integer head (RFC 8949 §3). **Security:** *CSP builder refuses unsafe keywords and catch-all schemes in any case* — b.csp.build screened its source tokens ('unsafe-inline', 'unsafe-eval', 'unsafe-hashes', the catch-all '*'/'https:', and 'data:' in img/media/font) against its guard sets with a case-sensitive comparison. A User-Agent matches CSP keywords and scheme sources ASCII case-insensitively (CSP3 §2.3/§6.7.2), so a case-variant such as 'Unsafe-Inline', 'HTTPS:', or 'DATA:' slipped the guard and was emitted verbatim into a Content-Security-Policy header the browser still enforces -- reintroducing the exact XSS-defense hole the guard exists to refuse, without the required acknowledgement opt-in. The builder now lowercases each token before the membership test; the original case is still emitted for real hosts and paths, only the guard comparison is normalized. mergeDirectives routes added sources through the same guard, so a case-variant in a merged policy is refused too. · *Cookie name-prefix invariants hold for any case of __Secure-/__Host-* — Browsers apply the __Secure-/__Host- cookie name-prefix requirements (RFC 6265bis §4.1.3) case-insensitively -- they lowercase the cookie name before the prefix test. b.cookies.serialize and the CSRF middleware's cookie-name safety check compared the prefix case-sensitively, so a case-variant name like __host- or __SECURE- dodged the framework's Secure/Path=//no-Domain invariant while still being subject to the browser's enforcement -- the cookie would then be silently rejected by the browser (never set), defeating the middleware or session it belonged to. Both now compare a lowercased copy of the name, so a prefix-violating cookie is refused at the source for every case. The CSRF middleware, which builds its own Set-Cookie header rather than routing through serialize, additionally gained the __Secure- branch it was missing entirely.
|
|
12
16
|
|
|
13
17
|
- v0.17.2 (2026-07-17) — **Recurrence expansion refuses to spin or crash on an out-of-range interval, the CIDR guard rejects the whole of the ULA and link-local IPv6 ranges, and a device-bound session assertion no longer accepts a far-future issued-at.** b.calendar.expandRecurrence accepted an unbounded, unvalidated recurrence interval; a large interval drove its date arithmetic past the representable range, which either spun the expansion loop forever at full CPU (a denial of service reachable from any JSCalendar event in a request body) or threw an uncaught error that crashed the caller. The expander now stops as soon as the date arithmetic overflows. b.guardCidr tested IPv6 reserved-range membership on whole hex nibbles, so it missed the parts of the ULA (fc00::/7) and link-local (fe80::/10) ranges that do not fall on a nibble boundary -- accepting fd00::/8 and several fe80::/10 sub-ranges as clean under the strict profile that is supposed to refuse them. And b.dbsc.verifyBindingAssertion bounded the assertion's issued-at only from below, so a far-future issued-at was accepted and never aged out of the replay window. **Security:** *Recurrence expansion refuses an out-of-range interval instead of hanging or crashing* — b.calendar.expandRecurrence validated a recurrence rule's frequency but not its interval, so a caller-supplied interval was used unbounded. A large interval drives the expander's date arithmetic past the representable ECMAScript date range and yields a non-finite date, with two consequences from the one root: with a by-set-position rule the outer loop computed a non-finite period whose inner day-enumeration ran zero times, so the shared step budget never decremented and the not-after break comparisons (which compare against a non-finite value) never fired -- an infinite loop pinning a CPU at 100%; without by-set-position, the loop advanced to a non-finite date and threw an uncaught error when serializing it. Because the rule passed validation, any attacker supplying a JSCalendar event (a JMAP request body, an imported calendar) could trigger it. The expander now stops stepping as soon as the date arithmetic overflows the representable range -- no further instances can exist -- so a hostile interval yields the finite instances that fit and returns, rather than hanging or crashing. · *CIDR guard rejects the full ULA and link-local IPv6 ranges under the strict profile* — b.guardCidr tested whether an IPv6 address falls in a reserved range by comparing hex-nibble prefixes with a string prefix match. Reserved membership is a bit-prefix relation, and the unique-local (fc00::/7, 7 bits) and link-local (fe80::/10, 10 bits) ranges end mid-nibble, so the nibble comparison covered only fc00-fcff and fe80-fe8f -- it missed fd00::/8 (the half of the ULA block that real deployments actually assign) and the fe90::/16 through febf::/16 sub-ranges of link-local. Under the strict profile, which refuses reserved ranges, those CIDRs were accepted as clean and sanitize normalized them into place instead of refusing them. The reserved-range check now compares the whole nibbles and then the remaining prefix bits of the boundary nibble under a mask -- the same bit-prefix relation the IPv4 path and the SSRF guard already use -- so the entire reserved range is caught; nibble-aligned ranges (documentation, multicast, loopback) are unaffected. · *Device-bound session assertions reject a far-future issued-at* — b.dbsc.verifyBindingAssertion enforced the assertion's issued-at only as a lower bound (refusing one older than the configured max age), with no upper bound. A forward-dated issued-at makes the age check compare a negative interval, which never trips, so an assertion carrying an issued-at far in the future was accepted and could not age out of the replay window on the default 300-second path. verifyBindingAssertion now also refuses an issued-at more than a small clock-skew allowance in the future, matching the future-issued-at bound the JWT, DPoP, and client-attestation verifiers already enforce.
|
package/lib/backup/manifest.js
CHANGED
|
@@ -177,6 +177,14 @@ function validate(manifest) {
|
|
|
177
177
|
}
|
|
178
178
|
if (!Array.isArray(manifest.files)) {
|
|
179
179
|
errors.push("files: required array");
|
|
180
|
+
} else if (manifest.files.length === 0) {
|
|
181
|
+
// A backup that describes zero files is never legitimate — the bundle
|
|
182
|
+
// writer refuses to emit one. Refuse it here too: this is the single
|
|
183
|
+
// guard the restore path parses an untrusted manifest through, and a
|
|
184
|
+
// tampered manifest that strips every entry (while keeping a valid
|
|
185
|
+
// wrapped vault key) would otherwise restore to an empty staging dir
|
|
186
|
+
// that the swap moves over the live dataDir — a silent destructive wipe.
|
|
187
|
+
errors.push("files: required non-empty array");
|
|
180
188
|
} else {
|
|
181
189
|
// Detect duplicate relativePath / encryptedPath — would corrupt restore
|
|
182
190
|
var seenRel = Object.create(null);
|
package/lib/db-query.js
CHANGED
|
@@ -1430,6 +1430,29 @@ function _stripLeadingSqlComments(sql) {
|
|
|
1430
1430
|
return s;
|
|
1431
1431
|
}
|
|
1432
1432
|
|
|
1433
|
+
// _normalizeForWriteParse — the single parse-copy normalizer the residency
|
|
1434
|
+
// write-detection regexes run against. The regexes hand-roll SQL tokenization
|
|
1435
|
+
// with `\s+` (whitespace-separated) token boundaries, but SQL lets two tokens
|
|
1436
|
+
// abut with NO whitespace whenever a comment or a quoted-identifier boundary
|
|
1437
|
+
// separates them — so a real write escaped the whitespace-strict detection
|
|
1438
|
+
// entirely (the gate was skipped → a cross-border write landed ungated):
|
|
1439
|
+
//
|
|
1440
|
+
// an INSERT whose quoted table name abuts INTO with no space
|
|
1441
|
+
// an INSERT with a slash-star comment wedged between INSERT and INTO
|
|
1442
|
+
// an UPDATE with a slash-star comment wedged between UPDATE and the table
|
|
1443
|
+
//
|
|
1444
|
+
// All are valid SQLite (the engine tokenizes the comment / quote as a
|
|
1445
|
+
// separator), so the executed statement writes rows while `_rawWriteTable`
|
|
1446
|
+
// returned null and the gate never engaged (CWE-863; GDPR Art. 44-46 transfer
|
|
1447
|
+
// restriction bypass). safeSql.normalizeForScan produces a parse-only copy
|
|
1448
|
+
// where every token boundary is real whitespace (comments collapsed to a space,
|
|
1449
|
+
// a separating space inserted where a quoted token abuts a word character);
|
|
1450
|
+
// leading comments then strip so the ^-anchored regexes see the statement head.
|
|
1451
|
+
// The executed SQL is unchanged; this copy only feeds the gate's parse.
|
|
1452
|
+
function _normalizeForWriteParse(sql) {
|
|
1453
|
+
return _stripLeadingSqlComments(safeSql.normalizeForScan(sql));
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1433
1456
|
// Non-anchored write-target scan for the writable-CTE / EXPLAIN-prefixed case.
|
|
1434
1457
|
// A SQLite `WITH c AS (...) INSERT INTO residents ...` / `WITH ... UPDATE residents
|
|
1435
1458
|
// SET ...` is a real write, but its effective verb is hidden behind the prefix so
|
|
@@ -1452,10 +1475,12 @@ function _firstResidencyWriteTarget(s) {
|
|
|
1452
1475
|
}
|
|
1453
1476
|
|
|
1454
1477
|
function _rawWriteTable(sql) {
|
|
1455
|
-
// The ^-anchored regexes scan only the statement head (constant-time).
|
|
1456
|
-
//
|
|
1478
|
+
// The ^-anchored regexes scan only the statement head (constant-time).
|
|
1479
|
+
// Normalize first so a leading OR internal comment, and a quoted table that
|
|
1480
|
+
// abuts the keyword with no whitespace, can't hide the write from the
|
|
1481
|
+
// whitespace-anchored detection (a gate-skip → cross-border-write bypass).
|
|
1457
1482
|
if (typeof sql !== "string") return null;
|
|
1458
|
-
var s =
|
|
1483
|
+
var s = _normalizeForWriteParse(sql);
|
|
1459
1484
|
if (_RAW_WRITE_KEYWORD_RE.test(s)) { // allow:regex-no-length-cap
|
|
1460
1485
|
var m = _RAW_TABLE_RE.exec(s); // allow:regex-no-length-cap
|
|
1461
1486
|
return m ? _unquoteIdent(m[1] || m[2]) : null;
|
|
@@ -1546,11 +1571,13 @@ function _assertRawWriteResidency(sql, boundParams) {
|
|
|
1546
1571
|
if (!cryptoField.getPerRowResidency(table) && !cryptoField.getColumnResidency(table)) return;
|
|
1547
1572
|
boundParams = _flattenRunParams(boundParams);
|
|
1548
1573
|
|
|
1549
|
-
//
|
|
1550
|
-
//
|
|
1551
|
-
//
|
|
1574
|
+
// Normalize the parse copy the same way engagement did: a leading OR internal
|
|
1575
|
+
// comment, and a quoted table/column that abuts the keyword with no whitespace,
|
|
1576
|
+
// must not hide the INSERT/UPDATE body from the whitespace-anchored regexes
|
|
1577
|
+
// below (that would let a residency-restricted write parse as unmodelled and,
|
|
1578
|
+
// for the value it can't read, skip the tag check). The executed SQL is
|
|
1552
1579
|
// unchanged; this normalized copy is only for residency parsing.
|
|
1553
|
-
var norm =
|
|
1580
|
+
var norm = _normalizeForWriteParse(sql);
|
|
1554
1581
|
|
|
1555
1582
|
// The INSERT/UPDATE body regexes below scan with [\s\S]+; bound the input
|
|
1556
1583
|
// first and fail CLOSED on an over-long statement - a residency write the
|
|
@@ -102,7 +102,7 @@ function _checkName(name, profile) {
|
|
|
102
102
|
throw new GuardAgentRegistryError("agent-registry/name-too-long",
|
|
103
103
|
"guardAgentRegistry.validate: name exceeds maxNameBytes=" + profile.maxNameBytes);
|
|
104
104
|
}
|
|
105
|
-
if (RESERVED_EXACT
|
|
105
|
+
if (Object.prototype.hasOwnProperty.call(RESERVED_EXACT, name)) {
|
|
106
106
|
throw new GuardAgentRegistryError("agent-registry/reserved-name",
|
|
107
107
|
"guardAgentRegistry.validate: name '" + name + "' is framework-reserved");
|
|
108
108
|
}
|
|
@@ -174,7 +174,7 @@ function _isRefusedAutoFetchHost(hostname, allowedHosts) {
|
|
|
174
174
|
}
|
|
175
175
|
if (lower.length === 0) return "missing-host";
|
|
176
176
|
if (IPV4_LITERAL_RE.test(lower) || IPV6_LITERAL_RE.test(lower)) return "ip-literal";
|
|
177
|
-
if (RESERVED_LOCAL_HOSTS
|
|
177
|
+
if (Object.prototype.hasOwnProperty.call(RESERVED_LOCAL_HOSTS, lower)) return "reserved-local-host";
|
|
178
178
|
// Hostname suffix refusal — RFC 6761 reserved / mDNS / single-network.
|
|
179
179
|
if (lower === "local" || lower.endsWith(".local")) return "reserved-local-suffix";
|
|
180
180
|
if (lower === "lan" || lower.endsWith(".lan")) return "reserved-local-suffix";
|
|
@@ -277,7 +277,7 @@ function validate(headers, opts) {
|
|
|
277
277
|
return _verdict("refuse", "URI '" + _trunc(u) + "' has no scheme (RFC 3986 §3.1)",
|
|
278
278
|
{ uris: classified, hasHttpsUri: hasHttpsUri, hasMailtoUri: hasMailtoUri, postHeaderOk: false });
|
|
279
279
|
}
|
|
280
|
-
if (DANGEROUS_SCHEMES
|
|
280
|
+
if (Object.prototype.hasOwnProperty.call(DANGEROUS_SCHEMES, scheme)) {
|
|
281
281
|
return _verdict("refuse", "URI scheme '" + scheme + "' is on the always-refused list (XSS / file-read class)",
|
|
282
282
|
{ uris: classified, hasHttpsUri: hasHttpsUri, hasMailtoUri: hasMailtoUri, postHeaderOk: false });
|
|
283
283
|
}
|
package/lib/html-balance.js
CHANGED
|
@@ -184,7 +184,7 @@ function check(html) {
|
|
|
184
184
|
// Self-closing: `<foo />` or `<foo/>` or HTML5 void elements.
|
|
185
185
|
var selfClose = html.charCodeAt(j - 1) === 0x2F /* / */;
|
|
186
186
|
if (isClose) {
|
|
187
|
-
if (VOID_TAGS
|
|
187
|
+
if (Object.prototype.hasOwnProperty.call(VOID_TAGS, tag)) {
|
|
188
188
|
// Stray closer for a void tag — surface as error.
|
|
189
189
|
var pcv = _posToLineColumn(html, i);
|
|
190
190
|
return {
|
|
@@ -221,7 +221,7 @@ function check(html) {
|
|
|
221
221
|
stack.pop();
|
|
222
222
|
} else {
|
|
223
223
|
// Open tag (or self-closing).
|
|
224
|
-
if (selfClose || VOID_TAGS
|
|
224
|
+
if (selfClose || Object.prototype.hasOwnProperty.call(VOID_TAGS, tag)) {
|
|
225
225
|
// No push.
|
|
226
226
|
} else if (Object.prototype.hasOwnProperty.call(RAW_TEXT_TAGS, tag)) {
|
|
227
227
|
// Skip raw-text content AND its closing tag entirely. Inside
|
package/lib/mail-bimi.js
CHANGED
|
@@ -398,7 +398,7 @@ function validateTinyPsSvg(svgBytes) {
|
|
|
398
398
|
|
|
399
399
|
if (t.type === "tag") {
|
|
400
400
|
var name = t.name;
|
|
401
|
-
if (TINY_PS_FORBIDDEN_TAGS
|
|
401
|
+
if (Object.prototype.hasOwnProperty.call(TINY_PS_FORBIDDEN_TAGS, name)) {
|
|
402
402
|
_vio("element-forbidden",
|
|
403
403
|
"<" + name + "> is forbidden in Tiny-PS (script / style / animation / filter / image / foreign-content class)");
|
|
404
404
|
}
|
package/lib/mail-helo.js
CHANGED
|
@@ -280,7 +280,12 @@ async function evaluate(ctx, opts) {
|
|
|
280
280
|
|
|
281
281
|
// Not an address-literal; must be a domain.
|
|
282
282
|
var lower = claimed.toLowerCase();
|
|
283
|
-
|
|
283
|
+
// Own-property membership test — a bare `LOCALHOST_REFUSED[lower]`
|
|
284
|
+
// walks the prototype chain, so a claim that collides with an
|
|
285
|
+
// Object.prototype key (`constructor`, `__proto__`, ...) reads the
|
|
286
|
+
// inherited value as truthy and gets mislabelled a reserved-name
|
|
287
|
+
// claim. Match the table strictly against its own keys.
|
|
288
|
+
if (Object.prototype.hasOwnProperty.call(LOCALHOST_REFUSED, lower)) {
|
|
284
289
|
return _emit(auditImpl, "reject-shape", {
|
|
285
290
|
shape: "invalid",
|
|
286
291
|
reason: "localhost-class claim '" + lower + "' refused (RFC 6761 §6.3 reserved name)",
|
|
@@ -197,12 +197,18 @@ function create(opts) {
|
|
|
197
197
|
"register: '" + name + "' entry.maxHandlerMs must be a positive integer ≤ " +
|
|
198
198
|
MAX_HANDLER_MS_CEILING + " (got " + entry.maxHandlerMs + ")");
|
|
199
199
|
}
|
|
200
|
-
|
|
200
|
+
// Own-property membership, not `catalogue[name]` truthiness: the frozen
|
|
201
|
+
// plain-object catalogues (IMAP_VERBS / JMAP_METHODS / MANAGESIEVE_VERBS)
|
|
202
|
+
// inherit Object.prototype, so a handler name colliding with a prototype
|
|
203
|
+
// member (`constructor`, `toString`) would read a truthy inherited value
|
|
204
|
+
// and silently pass the catalogue gate without allowExperimental.
|
|
205
|
+
var inCatalogue = Object.prototype.hasOwnProperty.call(catalogue, name);
|
|
206
|
+
if (!inCatalogue && entry.allowExperimental !== true) {
|
|
201
207
|
throw new MailServerRegistryError("mail-server-registry/unknown-method",
|
|
202
208
|
"register: '" + name + "' is not in the " + opts.protocol + " catalogue; pass " +
|
|
203
209
|
"allowExperimental: true to opt out of the catalogue gate (audited)");
|
|
204
210
|
}
|
|
205
|
-
if (entry.allowExperimental === true && !
|
|
211
|
+
if (entry.allowExperimental === true && !inCatalogue) {
|
|
206
212
|
try {
|
|
207
213
|
audit.safeEmit({
|
|
208
214
|
action: "mail.serverRegistry.experimental_registration",
|
|
@@ -323,7 +323,17 @@ function create(opts) {
|
|
|
323
323
|
var filled;
|
|
324
324
|
try { filled = await entry.promise; }
|
|
325
325
|
finally { if (inflight.get(key) === entry) inflight.delete(key); }
|
|
326
|
-
if (filled.stale)
|
|
326
|
+
if (filled.stale) {
|
|
327
|
+
// The validate: true AD-bit gate holds on the serve-stale path too —
|
|
328
|
+
// otherwise an upstream outage silently downgrades a DNSSEC-strict
|
|
329
|
+
// lookup to unauthenticated stale data (the verdict is per-response,
|
|
330
|
+
// RFC 4035 §3.2.3, so a stale entry cached AD=0 must still be refused).
|
|
331
|
+
if (validate && !filled.result.validated) {
|
|
332
|
+
throw new ResolverError("resolver/validate-failed",
|
|
333
|
+
"query: validate: true but served-stale response was AD=0 for " + name + "/" + qtype);
|
|
334
|
+
}
|
|
335
|
+
return filled.result;
|
|
336
|
+
}
|
|
327
337
|
if (validate && !filled.validated) {
|
|
328
338
|
throw new ResolverError("resolver/validate-failed",
|
|
329
339
|
"query: validate: true but upstream returned AD=0 for " + name + "/" + qtype);
|
package/lib/nonce-store.js
CHANGED
|
@@ -291,7 +291,15 @@ async function enforceReplay(store, jti, expireAtMs, opts) {
|
|
|
291
291
|
throw new opts.errorClass(opts.storeFailedCode,
|
|
292
292
|
"replayStore.checkAndInsert threw: " + ((e && e.message) || String(e)));
|
|
293
293
|
}
|
|
294
|
-
|
|
294
|
+
// Fail CLOSED on ANY non-truthy result, not just a literal `false`. The
|
|
295
|
+
// recommended backends (Redis SETNX, SQL INSERT ... ON CONFLICT) signal a
|
|
296
|
+
// duplicate with a non-`false` falsy value — `SET ... NX` returns null, the
|
|
297
|
+
// SETNX command / an INSERT returns 0 — so an `=== false` compare would miss
|
|
298
|
+
// the replay and admit the token (fail open). Only a truthy result is a
|
|
299
|
+
// positive "first-seen" confirmation; anything else is treated as a replay.
|
|
300
|
+
// Mirrors the truthiness guard every inline consumer already uses
|
|
301
|
+
// (b.auth.oauth `!inserted`, api-encrypt `!freshNonce`, webhook `!fresh`).
|
|
302
|
+
if (!inserted) {
|
|
295
303
|
throw new opts.errorClass(opts.replayCode,
|
|
296
304
|
opts.tokenLabel + " jti='" + jti + "' has been seen before — replay refused");
|
|
297
305
|
}
|
package/lib/restore.js
CHANGED
|
@@ -310,6 +310,25 @@ function create(opts) {
|
|
|
310
310
|
"extract failed: " + ((e && e.message) || String(e)));
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
+
// Defense-in-depth: never swap a zero-file extract over a populated
|
|
314
|
+
// dataDir. A zero-file staging dir moved over the live dataDir is a
|
|
315
|
+
// silent full wipe. The manifest validator now refuses an empty
|
|
316
|
+
// manifest (the attacker path), but an opts.filter that matched no
|
|
317
|
+
// entry also yields fileCount 0 — an operator mistake that must not
|
|
318
|
+
// destroy live data with a success report. If the dataDir is already
|
|
319
|
+
// empty a zero-file swap is a harmless no-op, so gate on non-empty.
|
|
320
|
+
var dataDirEntries = [];
|
|
321
|
+
try { dataDirEntries = nodeFs.readdirSync(dataDir); } catch (_e) { dataDirEntries = []; }
|
|
322
|
+
if (extracted.fileCount === 0 && dataDirEntries.length > 0) {
|
|
323
|
+
_cleanupTmp();
|
|
324
|
+
_emitAudit("restore.failure",
|
|
325
|
+
{ bundleId: bundleId, reason: "refusing zero-file restore over a non-empty dataDir" },
|
|
326
|
+
"failure");
|
|
327
|
+
throw new RestoreError("restore/empty-extract-refused",
|
|
328
|
+
"refusing to swap a zero-file restore over the non-empty dataDir '" + dataDir +
|
|
329
|
+
"' (a filter matched no manifest entry, or the manifest is empty) — this would wipe live data");
|
|
330
|
+
}
|
|
331
|
+
|
|
313
332
|
// 3. Atomic swap. On swap failure, the stagingDir is preserved so
|
|
314
333
|
// an operator can recover manually — we do NOT delete it here.
|
|
315
334
|
var swapResult;
|
package/lib/safe-decompress.js
CHANGED
|
@@ -165,8 +165,14 @@ function safeDecompress(input, opts) {
|
|
|
165
165
|
"windowBits", "audit", "ctx"],
|
|
166
166
|
"safeDecompress");
|
|
167
167
|
|
|
168
|
-
// Algorithm — required, must be in allowlist
|
|
169
|
-
|
|
168
|
+
// Algorithm — required, must be in allowlist. Use an own-property check,
|
|
169
|
+
// not `!_algorithms[algo]`: a bare truthiness/`in` lookup inherits
|
|
170
|
+
// Object.prototype members, so a non-own key ("constructor", "toString",
|
|
171
|
+
// …) would resolve to a prototype function and get invoked below —
|
|
172
|
+
// `Object(buf)` returns the raw input, silently bypassing the allowlist
|
|
173
|
+
// and returning un-decompressed bytes (fail-open).
|
|
174
|
+
if (typeof opts.algorithm !== "string" ||
|
|
175
|
+
!Object.prototype.hasOwnProperty.call(_algorithms, opts.algorithm)) {
|
|
170
176
|
throw new SafeDecompressError(
|
|
171
177
|
"safe-decompress/unsupported-algorithm",
|
|
172
178
|
"safeDecompress: algorithm must be one of " +
|
package/lib/safe-sql.js
CHANGED
|
@@ -494,6 +494,93 @@ function toPositional(sql, dialect) {
|
|
|
494
494
|
return out;
|
|
495
495
|
}
|
|
496
496
|
|
|
497
|
+
/**
|
|
498
|
+
* @primitive b.safeSql.normalizeForScan
|
|
499
|
+
* @signature b.safeSql.normalizeForScan(sql)
|
|
500
|
+
* @since 0.17.4
|
|
501
|
+
* @status stable
|
|
502
|
+
* @related b.safeSql.countPlaceholders, b.safeSql.toPositional, b.safeSql.assertSingleStatement
|
|
503
|
+
*
|
|
504
|
+
* Produce a parse-only copy of `sql` whose token boundaries are real
|
|
505
|
+
* whitespace, so a regex tokenizer that assumes whitespace-separated tokens
|
|
506
|
+
* cannot be evaded. SQL lets two tokens abut with NO whitespace whenever a
|
|
507
|
+
* comment OR a quoted-identifier boundary separates them (an `INSERT` whose
|
|
508
|
+
* quoted table name abuts `INTO`, or a slash-star comment wedged between a
|
|
509
|
+
* keyword and the table); a keyword/table detector hand-rolled with `\s+`
|
|
510
|
+
* boundaries silently misses those forms even though the engine executes them.
|
|
511
|
+
* This scan replaces every line (`--`) and slash-star block comment with a
|
|
512
|
+
* single space and inserts a separating space wherever a quoted string /
|
|
513
|
+
* identifier (`'...'` / `"..."` / a backtick-quoted name) abuts a word
|
|
514
|
+
* character on either side — a word char directly before the opening quote OR
|
|
515
|
+
* directly after the closing quote. The same quote- and comment-aware single
|
|
516
|
+
* pass as `countPlaceholders` / `toPositional` (doubled-quote escapes
|
|
517
|
+
* respected), so a comment marker inside a string literal is copied verbatim,
|
|
518
|
+
* never collapsed. The executed SQL is unchanged — this copy only feeds a
|
|
519
|
+
* scanner.
|
|
520
|
+
*
|
|
521
|
+
* @example
|
|
522
|
+
* var b = require("blamejs");
|
|
523
|
+
* b.safeSql.normalizeForScan('INSERT INTO"t"(a) VALUES(?)');
|
|
524
|
+
* // → 'INSERT INTO "t"(a) VALUES(?)'
|
|
525
|
+
*
|
|
526
|
+
* b.safeSql.normalizeForScan('UPDATE"residents"SET x=1');
|
|
527
|
+
* // → 'UPDATE "residents" SET x=1'
|
|
528
|
+
*
|
|
529
|
+
* b.safeSql.normalizeForScan("SELECT 1-- note");
|
|
530
|
+
* // → "SELECT 1 "
|
|
531
|
+
*/
|
|
532
|
+
function normalizeForScan(sql) {
|
|
533
|
+
var s = String(sql);
|
|
534
|
+
var out = "";
|
|
535
|
+
var i = 0;
|
|
536
|
+
var len = s.length;
|
|
537
|
+
while (i < len) {
|
|
538
|
+
var c = s.charAt(i);
|
|
539
|
+
var nx = i + 1 < len ? s.charAt(i + 1) : "";
|
|
540
|
+
if (c === "'" || c === '"' || c === "`") {
|
|
541
|
+
// A quoted token abutting a word character gets a separating space so the
|
|
542
|
+
// downstream whitespace-anchored tokenizer sees the boundary — on BOTH
|
|
543
|
+
// sides: before the opening quote when a word char precedes it
|
|
544
|
+
// (`INTO"t"`), and after the closing quote when a word char follows it
|
|
545
|
+
// (`"t"SET` / `UPDATE"residents"SET`). A quoted identifier separates
|
|
546
|
+
// tokens with no whitespace in either direction, so a one-sided boundary
|
|
547
|
+
// still lets a write hide from the scan. Between, copy the whole quoted
|
|
548
|
+
// run verbatim (doubled-quote escapes preserved) so a comment marker
|
|
549
|
+
// inside the literal is never collapsed.
|
|
550
|
+
if (out.length > 0 && /\w/.test(out.charAt(out.length - 1))) out += " ";
|
|
551
|
+
out += c;
|
|
552
|
+
i += 1;
|
|
553
|
+
while (i < len) {
|
|
554
|
+
var q = s.charAt(i);
|
|
555
|
+
out += q;
|
|
556
|
+
if (q === c) {
|
|
557
|
+
if (s.charAt(i + 1) === c) { out += c; i += 2; continue; }
|
|
558
|
+
i += 1; break;
|
|
559
|
+
}
|
|
560
|
+
i += 1;
|
|
561
|
+
}
|
|
562
|
+
if (i < len && /\w/.test(s.charAt(i))) out += " ";
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
if (c === "-" && nx === "-") { // line comment → one space
|
|
566
|
+
i += 2;
|
|
567
|
+
while (i < len && s.charAt(i) !== "\n") i += 1;
|
|
568
|
+
out += " ";
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
if (c === "/" && nx === "*") { // block comment → one space
|
|
572
|
+
i += 2;
|
|
573
|
+
while (i < len && !(s.charAt(i) === "*" && s.charAt(i + 1) === "/")) i += 1;
|
|
574
|
+
i += 2;
|
|
575
|
+
out += " ";
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
out += c;
|
|
579
|
+
i += 1;
|
|
580
|
+
}
|
|
581
|
+
return out;
|
|
582
|
+
}
|
|
583
|
+
|
|
497
584
|
/**
|
|
498
585
|
* @primitive b.safeSql.DEFAULT_IDENTIFIER_RE
|
|
499
586
|
* @signature b.safeSql.DEFAULT_IDENTIFIER_RE
|
|
@@ -693,6 +780,7 @@ module.exports = {
|
|
|
693
780
|
assertOneOf: assertOneOf,
|
|
694
781
|
countPlaceholders: countPlaceholders,
|
|
695
782
|
toPositional: toPositional,
|
|
783
|
+
normalizeForScan: normalizeForScan,
|
|
696
784
|
SafeSqlError: SafeSqlError,
|
|
697
785
|
// Exposed so consumers can compose their own validators
|
|
698
786
|
DEFAULT_IDENTIFIER_RE: DEFAULT_IDENTIFIER_RE,
|
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:360eade5-cc2d-4f7b-b52b-9e326e021f8a",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-17T13:58:58.122Z",
|
|
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.17.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.17.5",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.17.
|
|
25
|
+
"version": "0.17.5",
|
|
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.17.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.17.5",
|
|
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.17.
|
|
57
|
+
"ref": "@blamejs/core@0.17.5",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|