@blamejs/core 0.15.10 → 0.15.12
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/acme.js +7 -11
- package/lib/client-hints.js +3 -1
- package/lib/cluster.js +4 -2
- package/lib/crypto-field.js +30 -0
- package/lib/db-declare-row-policy.js +20 -1
- package/lib/db.js +7 -0
- package/lib/guard-csv.js +13 -4
- package/lib/guard-filename.js +6 -2
- package/lib/http-client-cache.js +3 -1
- package/lib/http-message-signature.js +25 -8
- package/lib/log-stream-otlp-grpc.js +12 -1
- package/lib/log-stream-syslog.js +6 -0
- package/lib/log.js +24 -2
- package/lib/mail-bimi.js +16 -3
- package/lib/mail.js +21 -9
- package/lib/mcp.js +28 -6
- package/lib/middleware/body-parser.js +48 -6
- package/lib/middleware/bot-disclose.js +7 -5
- package/lib/middleware/speculation-rules.js +6 -4
- package/lib/network-dns.js +22 -26
- package/lib/network-heartbeat.js +3 -3
- package/lib/network-proxy.js +3 -7
- package/lib/network-tls.js +34 -13
- package/lib/network.js +2 -6
- package/lib/notify.js +7 -12
- package/lib/parsers/safe-env.js +6 -3
- package/lib/parsers/safe-yaml.js +6 -6
- package/lib/safe-buffer.js +69 -1
- package/lib/seeders.js +5 -10
- package/lib/structured-fields.js +38 -1
- 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.15.x
|
|
10
10
|
|
|
11
|
+
- v0.15.12 (2026-06-14) — **Hardens a set of defense-in-depth seams: a single-pass structured-field unescape, a constant-time content-digest member match, complete reserved-character stripping, a Trojan-Source escape on the boot logger, generic body-parse error responses, and an audit trail whenever outbound TLS certificate validation is disabled.** A sweep of low-severity but real hardening items. RFC 8941 structured-field string values (HTTP Message Signatures, Client Hints, Cache-Control) were un-escaped with two chained replaces that mis-decoded an escaped backslash adjacent to another escape; they now use one left-to-right pass that decodes each escape exactly once. The HTTP Message Signature content-digest check dropped a dead identity-replace and now matches the sha3-512 member by an exact, top-level, constant-time comparison instead of an unanchored substring scan. b.guardFilename's reserved-character strip used a non-global regex that left every separator after the first; it now strips all of them. The boot logger's TTY branch wrote raw text, bypassing the Trojan-Source / control-character escape the main logger applies — it now escapes the bidi and C0/newline control classes on every sink. The body parser no longer echoes a caught exception's detail (an fs errno + temp path, or a parse hook's thrown message) to the HTTP client — the client gets a generic status phrase while the full detail stays on the audit chain. And any outbound TLS connection that runs with peer-certificate validation disabled (an explicit operator opt-in, never a default) now emits a tls.insecure_skip_verify audit + observability event so the degraded posture is visible for compliance and incident response. **Added:** *b.structuredFields.unescapeSfStringBody(body)* — A single-pass decode of the RFC 8941 §3.3.3 quoted-string backslash escapes (the bytes between the surrounding quotes). It replaces the chained two-`.replace()` form, which is not equivalent to one decode — whichever pass runs first can rewrite a backslash the other escape sequence owns, so a lone escaped backslash decoded to two. The HTTP Message Signature, Client Hints, and Cache-Control sf-string readers now route through it. · *tls.insecure_skip_verify audit event* — b.network.tls.auditInsecureTls(meta) emits an audit + observability event at the point an outbound TLS connection honors rejectUnauthorized:false / allowInsecure. The connectWithEch, OTLP-gRPC log stream, syslog-TLS log stream, and SMTP transports all emit it when an operator disables certificate validation — parallel to the existing tls.classical_downgrade audit. No default changes; the framework never disables validation itself. **Security:** *Single-pass structured-field string unescape* — The RFC 8941 sf-string readers in HTTP Message Signatures (Signature-Input covered-component names), Client Hints, and Cache-Control directive values un-escaped with `.replace(/\\\\/g,"\\").replace(/\\"/g,'"')` — two sequential passes that mis-decode adjacent escapes (a lone escaped backslash became two). All four sites now use the single-pass b.structuredFields.unescapeSfStringBody. It is fail-closed (a mis-decoded covered-component name just fails the signature check, never bypasses it); the fix restores RFC-conformant interop with peers that legitimately escape these values. · *Constant-time, member-anchored content-digest verification* — b.crypto.httpSig.verify's covered content-digest check dropped a dead no-op replace and now parses the Content-Digest header into its top-level members and matches the sha3-512 member EXACTLY, in constant time (b.crypto.timingSafeEqual), rather than scanning for the digest text as a substring anywhere in the header. The Content-Digest header is already bound by the signature, so the substring form was not reachably exploitable; the change removes the latent ambiguity and the timing channel. · *Reserved-character filename strip removes every occurrence* — b.guardFilename's reservedCharPolicy:"strip" (the permissive profile) used a non-global regex, so only the FIRST reserved character — including path separators — was replaced and the rest leaked through. The strip is now global: every reserved character is removed. Not a traversal bypass (the unconditional security floor still throws on `..`, null bytes, NTFS ADS, UNC, overlong UTF-8), but the strip is now complete and consistent. · *Boot logger escapes control + bidi characters on every sink* — The boot-time logger's TTY branch wrote the raw message, bypassing the Trojan-Source (bidi) and control-character escapes the main logger applies — a hostile message could forge extra log lines on a terminal (CWE-117) or re-order the visible line (CVE-2021-42574). Both boot branches now escape the bidi and C0/newline control classes, matching the create() path and the logger's advertised guarantee. · *Body-parser error responses never echo internal detail* — The body-parser's terminal error path surfaced a caught exception's message verbatim to the HTTP client — a multipart filesystem error leaked the errno + temp path, and a parse hook's thrown error (which can carry secrets) was echoed back. The client now gets a curated message only for a framework-classified 4xx error and a generic status phrase otherwise; the parse-hook wrapper carries a fixed message, and full diagnostics stay on the audit chain server-side (CWE-209). The cluster leader-discovery endpoint's error body is generalized the same way.
|
|
12
|
+
|
|
13
|
+
- v0.15.11 (2026-06-14) — **Replaces a family of quadratic-time regexes that hostile input could use to stall a worker with linear scans, refuses a relocatable sealed-cell downgrade on the read side, fails closed when enabling row-level security behind a non-native driver, and scans the vendored crypto for known CVEs on every build.** Several text-handling primitives stripped trailing whitespace or extracted a mail address with a regex whose backtracking is quadratic in the input length on adversarial strings — a request body, a YAML document, a CSV cell, or a From header crafted as a long run of spaces could pin a worker's CPU. Each is now a linear character scan with identical output. The HTML-content check the MCP tool surface applies gained the vbscript: and data:text/html vectors it was missing. On the data-at-rest side, an AAD-bound (or per-row-key) column now refuses a plain, unbound vault cell on read — a relocatable envelope an attacker with write access could copy in from another row defeats the cross-row binding, so the field is nulled rather than surfaced; operators mid-migration opt back in with registerTable({ allowPlainMigration: true }). declareRowPolicy now treats row-level-security as enabled only on a value that unambiguously means true, so a non-native Postgres driver that returns the string "f" can no longer be read as "already on" and silently skip the ENABLE that protects the table's rows. Finally, because the framework's crypto is vendored rather than installed, npm audit and Dependabot never see it: every build now matches the vendored versions against the OSV vulnerability database, with a complementary Semgrep pass and workflow-file static analysis alongside. **Added:** *b.safeBuffer.indexAfterOpenTag(html, tagName)* — A linear helper that returns the offset just past a `<tag ...>` opening tag (case-insensitive), or -1 when absent or unterminated — the insertion point a response rewriter uses to splice content after <body> or <head> without a regex. It replaces the O(n^2) html.match(/<body[^>]*>/i) shape and is stricter than it: a real tag boundary is required after the name, so <bodyfoo> is not mistaken for <body>. **Security:** *Linear-time replacements across a family of quadratic regexes (ReDoS class)* — Several primitives located or stripped text with a regex whose backtracking is quadratic in V8 on adversarial input (CWE-1333): b.safeBuffer and the safe-env / safe-yaml / guard-csv parsers stripped trailing horizontal whitespace with /[ \t]+$/; b.mail extracted the address from a `Name <addr>` header with /<([^>]+)>/; the bot-disclosure and speculation-rules response middleware found the <body> insertion point with /<body[^>]*>/i; and the BIMI certificate-chain splitter walked PEM blocks with a lazy /BEGIN[\s\S]*?END/ scan. A crafted field — a long run of spaces, an unterminated bracket, a body carrying many <body starts with no closing >, a chain of BEGIN markers — could drive a worker's CPU to seconds of work. Each is now a linear scan: a shared b.safeBuffer.stripTrailingHspace (backward char walk), b.safeBuffer.indexAfterOpenTag (forward indexOf walk for the tag insertion point), a forward indexOf for address extraction, and an indexOf walk for the PEM split. Output is byte-identical (the tag-find is stricter — it no longer mistakes <bodyfoo> for <body>), and 400K-character adversarial inputs that took 8–85 seconds now complete in under 2 ms. · *MCP HTML-content check covers vbscript: and data:text/html* — The dangerous-markup check applied to text/html tool content matched <script>/<iframe>/<object>/<embed> and javascript: URLs but not the vbscript: scheme or data:text/html payloads. Both are now refused; data: URLs carrying non-HTML media (data:image/png and similar) are unaffected. · *AAD-bound columns refuse a plain sealed cell on read* — b.cryptoField.unsealRow now refuses a plain, unbound vault: envelope found on an AAD-bound (or per-row-key) column and nulls the field instead of returning it. A plain envelope carries no per-cell binding, so a writer who could place one — copied from anywhere under the same vault root — would otherwise relocate a value across rows or columns and defeat the copy-protection the AAD binding advertises. Operators migrating pre-AAD rows up to bound ciphertext opt into a bounded acceptance window with registerTable({ allowPlainMigration: true }) and clear it when migration completes. · *Row-level-security enablement fails closed on non-native drivers* — b.db.declareRowPolicy read pg_class.relrowsecurity to skip a redundant ENABLE ROW LEVEL SECURITY, but tested it with a bare truthiness check. A native pg driver returns a JS boolean; a proxy or ORM may return the string "f" for a disabled table — and "f" is truthy, so the check read it as already-enabled and silently skipped the ENABLE, leaving every row in the table unprotected while the migration reported success. RLS now counts as enabled only on a value that unambiguously means true (true, 1, or "t"/"true"/"1"/"on"/"yes"); every other shape re-issues ENABLE, a harmless no-op on an already-enabled table. · *Vendored-crypto CVE scanning, complementary SAST, and workflow static analysis in CI* — The framework ships zero npm runtime dependencies — its crypto (the noble suite, the WebAuthn server, the PKI layer) is vendored under lib/vendor/, where npm audit, Dependabot, and Socket cannot see it. Every build now generates a CycloneDX SBOM of the vendored tree (each library carrying an npm purl) and runs it through OSV-Scanner, matching the exact pinned version against the OSV vulnerability database; a published CVE or GHSA affecting a vendored version fails the build so the copy is refreshed before merge. A Semgrep pass (registry security-audit + javascript packs at ERROR severity) runs alongside CodeQL as complementary SAST, and actionlint statically checks the workflow files. All three install the OSS tool from its upstream release, matching the existing secret-scan gate's posture. **Detectors:** *Quadratic trailing-whitespace and tag-find regex detectors* — Two codebase-pattern detectors refuse reintroduction of the quadratic shapes: the /[ \t]+$/ trailing-whitespace strip (as .replace, .test, or via the named TRAILING_HSPACE_RE export) outside the linear helper that owns it, and the str.match(/<tag[^>]*>/) document-tag find that the response middleware must route through b.safeBuffer.indexAfterOpenTag. Each is proven to fire on the removed shape and stay silent on the linear replacement, so the ReDoS class cannot creep back into a new parser, guard, or response rewriter.
|
|
14
|
+
|
|
11
15
|
- v0.15.10 (2026-06-13) — **Makes S3 Object-Lock version erasure reachable through the object store, and pins the build toolchain's native binary to a reviewed hash.** The object store gains the versioned-delete surface its S3 Object Lock support always needed for real erasure. An unversioned delete on a versioning-enabled (Object-Lock) bucket only writes a delete-marker — the data version survives — so the framework's own delete could report success while a record protected for compliance, or one a data subject asked to erase, stayed on disk. b.objectStore / b.storage now carry a versionId: put and saveRaw return the version they created, deleteFile(key, { versionId, bypassGovernanceRetention }) targets a specific version (refused — not silently delete-markered — when it is under an active retention), and listVersions enumerates versions and delete-markers so an erasure workflow can find them. Backends with no version surface (the filesystem backend, and the current Azure and GCS adapters) refuse a versioned delete loudly rather than silently dropping the current object. Separately, the build toolchain's native bundler binary is now verified against a reviewed SHA-256 pin so a tampered or drifted binary is caught before it bundles the framework. **Added:** *Versioned object delete + listVersions for S3 Object-Lock erasure* — b.storage.deleteFile and the b.objectStore sigv4 backend now accept opts.versionId to erase a specific object version, and opts.bypassGovernanceRetention to lift a GOVERNANCE-mode retention for callers with the permission (COMPLIANCE stays immutable to everyone). b.storage.saveRaw and the backend put now return the versionId they created on a versioning-enabled bucket, and a new b.storage.listVersions(prefix) / backend listVersions enumerates every version and delete-marker (key, versionId, isLatest, deleteMarker, size, lastModified, etag) so a right-to-erasure or crypto-shred workflow can target prior versions. On a backend with no version surface, listVersions throws VERSIONS_UNSUPPORTED and a versioned delete throws VERSIONID_UNSUPPORTED rather than silently acting on the current object. · *b.localDb.thin reaches SQLite resource-limit parity (limits option)* — b.localDb.thin now opens its node:sqlite handle with the same parse-time statement-size cap as b.db and the CLI — a SQL statement over 1 MiB is rejected at parse time, the SQLITE_LIMIT_LENGTH floor that guards prepare()/exec() of raw SQL against an attacker-influenced megaquery (SQLite's default is 1 GB). The cap is on by default; a new limits option (e.g. { sqlLength: 2 * 1024 * 1024 } or other SQLITE_LIMIT_* keys) lets an operator raise or extend it. Previously the thin opener had no limits plumbing, so a consumer on that path could not reach parity with the rest of the framework's SQLite surface. **Fixed:** *S3 Object-Lock version erasure is reachable through the framework delete path* — On a versioning-enabled (Object-Lock) bucket, an unversioned DELETE only writes a delete-marker — the protected data version survives untouched — yet the framework's delete had no versionId surface, so it issued the unversioned form and reported success while the bytes the lock protects remained. A retention or legal hold could therefore look enforced to the framework caller while the operation WORM actually blocks was unreachable. The delete path now targets the exact version: deleting a version under a COMPLIANCE retention is refused (it throws, even with bypassGovernanceRetention), a no-retention version erases cleanly, and the enforcement is proven end-to-end against MinIO through the framework's own API. · *b.configDrift.verifyVendorIntegrity is now working-directory-independent* — The vendored-dependency integrity check resolved each manifest file path against process.cwd(), so it only worked when run from the application root. Run from anywhere else it read-failed every entry (reporting ok:false), and under a crafted working directory that happened to contain a clean vendor tree it could hash a different tree than the one actually loaded. It now resolves each file under the framework's own vendor directory by default — the tree loaded at runtime — and honors an explicit libVendorDir for verifying a deployed tree elsewhere, so the result no longer depends on where the process was started. **Security:** *Build toolchain native binary pinned to a reviewed hash* — The native bundler binary the build toolchain runs (esbuild's per-platform compiler, a development dependency that never ships in the runtime) is now verified against a SHA-256 pin captured by diffing the published package tarballs and hashing the binary. The build gate fails if the on-disk binary does not match the reviewed hash for its (version, platform); for a version that has not been reviewed it notes the gap and skips rather than trusting an unverified binary. A cross-artifact check keeps the version in agreement across package.json, the CI install step, and the hash map, so the gate can never quietly test a version that was never diffed — closing a real drift where CI had been installing an older patch than package.json declared. The reviewed diff is benign: version strings plus an installer size-bound and error-message hardening, no new install hooks, files, or network paths, and no runtime-dependency impact. **Detectors:** *Object-store erasure guard, esbuild-pin agreement, + structural re-anchoring of the lint detectors* — A new guard locks the object-store delete path to the versioned-erasure contract: b.storage.deleteFile must thread versionId to the backend, so it can never silently revert to the WORM-blind unversioned delete. A second guard enforces that the esbuild build-tool version agrees across package.json, the CI install step, and the binary-hash map, so a future bump can't update one and leave the gate testing an unreviewed version. Separately, the framework's internal codebase-pattern lint detectors were re-anchored from fixed character spans to structural code boundaries, so they keep matching the code they guard as those functions grow rather than aging out of range; reviving them surfaced a few internal validation and transaction sites that now route through shared helpers (a required positive-integer-with-range validator and an async transaction wrapper) instead of hand-rolling the check. No public API change.
|
|
12
16
|
|
|
13
17
|
- v0.15.9 (2026-06-13) — **Raises the Node floor to 24.16, adds SQLite parse-time resource caps, retries Windows rename locks on every atomic write and download, and ships a one-call secure logout that wipes client-side state.** This release moves the engines floor to the current Node 24 LTS patch level and adds three hardening primitives. node:sqlite handles now construct with SQLITE_LIMIT_* caps: a statement over 1 MiB is rejected at parse time (a DoS floor on the raw-SQL surface, complementary to the existing row-count gate) and ATTACH DATABASE is denied. Every final temp-to-destination rename — the file written by an atomic write, a downloaded file, a sealed vault key, a rotated log, an extracted archive entry — now routes through a single retry that rides out a transient Windows lock (antivirus, the search indexer, or a file-sync client briefly holding the destination), instead of surfacing the lock as a hard failure; the retry, previously hand-rolled and unreachable, is now the reusable b.atomicFile.renameWithRetry. And b.session.logout destroys a session and tells the browser to wipe its client-side state in one call: it emits an RFC 9527 Clear-Site-Data header and expires the session cookie before destroying the row, the secure-default logout that previously had to be assembled by hand. **Added:** *b.session.logout — one-call secure logout* — `b.session.logout(res, token, opts?)` destroys the server-side session AND tells the browser to wipe its client-side state in one call: it emits an RFC 9527 Clear-Site-Data response header (cookies + storage + cache + execution contexts by default) and expires the session cookie, then destroys the session row. `b.session.destroy` alone is a store operation with no response object, so it could not wipe the browser's cached pages, storage, or a stale tab still holding the now-revoked cookie — that wiring previously had to be mounted by hand. Pass `cookieName` to match a non-default cookie and `types` to choose the Clear-Site-Data directives. **Changed:** *Node engines floor raised to >=24.16.0* — The minimum supported Node is now 24.16.0 (the current Node 24 LTS patch level), up from 24.14.1. This is an LTS-currency bump — there are no Node CVE fixes between 24.14.1 and 24.16.0 (24.14.1 already carried the CVE-2026-21713 HMAC fix); it keeps the framework on the latest patched LTS and makes the node:sqlite resource-cap hardening below available everywhere. Pre-1.0, operators upgrade across the floor; Node 26 continues to satisfy it. **Fixed:** *b.watcher canonicalizes its root on Windows* — `b.watcher.create` now resolves its `root` to the real long path before watching. On Windows a root with an 8.3 short-name component (the system temp directory commonly resolves to one) made the native recursive backend deliver long-name event paths that no longer prefix-matched the watched root, which could abort the process under a strict libuv fs-event assertion. The watcher now canonicalizes the root (expanding short names and resolving symlinks), so events match the watched directory on Windows. **Security:** *SQLite parse-time statement-size cap* — Every node:sqlite database the framework opens — the main db handle and the CLI's handle — now constructs with a SQLITE_LIMIT_LENGTH cap: a SQL statement over 1 MiB is rejected at parse time. Because the query builder parameterizes every value, the size cap guards the raw-SQL surface (`b.db.runSql`) against an attacker-influenced megaquery the parser would otherwise process (SQLite's default is 1 GB); it is a parse-time DoS floor complementary to the existing row-count gate. Legitimate framework and operator statements are far under the cap. · *Windows rename-lock retry on every atomic rename and download* — On Windows a freshly-written file's destination is briefly held by antivirus, the search indexer, or a file-sync client (Dropbox, OneDrive), surfacing as a transient EPERM / EACCES / EBUSY on rename even though the temp file is fine. `b.atomicFile.writeSync` already retried this, but `b.httpClient.downloadStream` did not — a download into a cloud-synced or AV-scanned directory could fail hard on the lock. The retry is now the reusable `b.atomicFile.renameWithRetry`, and every final temp-to-destination rename in the framework routes through it: downloads, sealed vault keys, CA key/cert writes, log rotation, archive extraction, config-drift sidecars, the self-update binary swap, and restore/rollback moves. A non-transient error still throws immediately; POSIX renames are unaffected. **Detectors:** *Rename-retry, SQLite-limits, and Clear-Site-Data guards* — Three recurrence detectors ship with the fixes: a bare `nodeFs.renameSync` final rename that doesn't route through `atomicFile.renameWithRetry`; a main `DatabaseSync` handle constructed without the SQLITE_LIMIT_LENGTH `limits`; and a hand-rolled Clear-Site-Data header value that skips the shared RFC 9527 builder.
|
package/lib/acme.js
CHANGED
|
@@ -92,10 +92,6 @@ function _emitAudit(audit, action, outcome, metadata) {
|
|
|
92
92
|
} catch (_e) { /* audit best-effort */ }
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
function _emitObs(name, fields) {
|
|
96
|
-
try { observability().safeEvent(name, 1, fields || {}); } catch (_e) { /* obs best-effort */ }
|
|
97
|
-
}
|
|
98
|
-
|
|
99
95
|
// JWK shape for an ES256 (P-256) public key — RFC 7518 §6.2.1.
|
|
100
96
|
function _publicJwkFromKeyObject(keyObject) {
|
|
101
97
|
if (!keyObject || typeof keyObject.export !== "function") {
|
|
@@ -479,7 +475,7 @@ function create(opts) {
|
|
|
479
475
|
state.directory = body;
|
|
480
476
|
_emitAudit(audit, "acme.directory.fetched", "success",
|
|
481
477
|
{ directoryUrl: state.directoryUrl, hasAri: typeof body.renewalInfo === "string" });
|
|
482
|
-
|
|
478
|
+
observability().safeEvent("acme.directory.fetched", 1, { hasAri: typeof body.renewalInfo === "string" });
|
|
483
479
|
return body;
|
|
484
480
|
}
|
|
485
481
|
|
|
@@ -675,7 +671,7 @@ function create(opts) {
|
|
|
675
671
|
}
|
|
676
672
|
_emitAudit(audit, "acme.cert.issued", "success",
|
|
677
673
|
{ orderUrl: order.url, bytes: pem.length });
|
|
678
|
-
|
|
674
|
+
observability().safeEvent("acme.cert.issued", 1, { bytes: pem.length });
|
|
679
675
|
return pem;
|
|
680
676
|
}
|
|
681
677
|
|
|
@@ -1042,7 +1038,7 @@ function create(opts) {
|
|
|
1042
1038
|
windowEnd: ari.suggestedWindow.end,
|
|
1043
1039
|
nowIso: new Date(nowMs).toISOString(),
|
|
1044
1040
|
});
|
|
1045
|
-
|
|
1041
|
+
observability().safeEvent("acme.cert.renew.skipped", 1, { reason: "before-window" });
|
|
1046
1042
|
var ret = { shouldRenew: false, reason: "before-window", ari: ari };
|
|
1047
1043
|
if (jitter) ret.renewAt = new Date(renewAtMs).toISOString();
|
|
1048
1044
|
return ret;
|
|
@@ -1053,7 +1049,7 @@ function create(opts) {
|
|
|
1053
1049
|
reason: "past-window",
|
|
1054
1050
|
windowEnd: ari.suggestedWindow.end,
|
|
1055
1051
|
});
|
|
1056
|
-
|
|
1052
|
+
observability().safeEvent("acme.cert.renew.scheduled", 1, { reason: "past-window" });
|
|
1057
1053
|
var rp = { shouldRenew: true, reason: "past-window", ari: ari };
|
|
1058
1054
|
if (jitter) rp.renewAt = new Date(renewAtMs).toISOString();
|
|
1059
1055
|
return rp;
|
|
@@ -1064,7 +1060,7 @@ function create(opts) {
|
|
|
1064
1060
|
windowEnd: ari.suggestedWindow.end,
|
|
1065
1061
|
renewAt: jitter ? new Date(renewAtMs).toISOString() : null,
|
|
1066
1062
|
});
|
|
1067
|
-
|
|
1063
|
+
observability().safeEvent("acme.cert.renew.scheduled", 1, { reason: "in-window" });
|
|
1068
1064
|
var ri = { shouldRenew: true, reason: "in-window", ari: ari };
|
|
1069
1065
|
if (jitter) ri.renewAt = new Date(renewAtMs).toISOString();
|
|
1070
1066
|
return ri;
|
|
@@ -1140,7 +1136,7 @@ function create(opts) {
|
|
|
1140
1136
|
"revokeCert returned " + rsp.statusCode, true, rsp.statusCode);
|
|
1141
1137
|
}
|
|
1142
1138
|
_emitAudit(audit, "acme.cert.revoked", "success", { reason: ropts.reason || null });
|
|
1143
|
-
|
|
1139
|
+
observability().safeEvent("acme.cert.revoked", 1, { reason: ropts.reason || 0 });
|
|
1144
1140
|
return true;
|
|
1145
1141
|
}
|
|
1146
1142
|
|
|
@@ -1190,7 +1186,7 @@ function create(opts) {
|
|
|
1190
1186
|
privateKey = newPrivateKey;
|
|
1191
1187
|
publicJwk = newPublicJwk;
|
|
1192
1188
|
_emitAudit(audit, "acme.account.key_rotated", "success", { accountUrl: state.accountUrl });
|
|
1193
|
-
|
|
1189
|
+
observability().safeEvent("acme.account.key_rotated", 1, {});
|
|
1194
1190
|
return true;
|
|
1195
1191
|
}
|
|
1196
1192
|
|
package/lib/client-hints.js
CHANGED
|
@@ -106,7 +106,9 @@ function _parseSfString(s) {
|
|
|
106
106
|
if (t.length === 0) return "";
|
|
107
107
|
if (t.charAt(0) === "\"") {
|
|
108
108
|
if (t.charAt(t.length - 1) !== "\"") return null;
|
|
109
|
-
|
|
109
|
+
// Single-pass RFC 8941 unescape — chained .replace() mis-decodes
|
|
110
|
+
// an escaped backslash adjacent to another escape.
|
|
111
|
+
return structuredFields.unescapeSfStringBody(t.slice(1, -1));
|
|
110
112
|
}
|
|
111
113
|
return t;
|
|
112
114
|
}
|
package/lib/cluster.js
CHANGED
|
@@ -1089,8 +1089,10 @@ function discoveryHandler() {
|
|
|
1089
1089
|
body = { leader: null, self: selfInfo };
|
|
1090
1090
|
status = 503;
|
|
1091
1091
|
}
|
|
1092
|
-
} catch (
|
|
1093
|
-
|
|
1092
|
+
} catch (_e) {
|
|
1093
|
+
// Generic client-facing reason — the caught error's message (a DB error
|
|
1094
|
+
// detail / DSN / host:port) is not echoed to the client (CWE-209).
|
|
1095
|
+
body = { leader: null, self: selfInfo, error: "leader lookup unavailable" };
|
|
1094
1096
|
status = 503;
|
|
1095
1097
|
}
|
|
1096
1098
|
var json = JSON.stringify(body);
|
package/lib/crypto-field.js
CHANGED
|
@@ -331,6 +331,15 @@ function isRowSealed(value) {
|
|
|
331
331
|
* // threaded into AAD. Default "1". Bump
|
|
332
332
|
* // when the column layout changes to
|
|
333
333
|
* // invalidate all prior ciphertext.
|
|
334
|
+
* allowPlainMigration: boolean, // default false. On an aad / per-row-key
|
|
335
|
+
* // table the read path refuses a PLAIN
|
|
336
|
+
* // (unbound) vault: cell — a relocatable
|
|
337
|
+
* // envelope an attacker could copy in from
|
|
338
|
+
* // another row defeats the AAD copy-
|
|
339
|
+
* // protection, so it is nulled, not surfaced.
|
|
340
|
+
* // Set true ONLY for the bounded window while
|
|
341
|
+
* // migrating pre-AAD rows up to AAD-bound
|
|
342
|
+
* // ciphertext; clear it once migration ends.
|
|
334
343
|
*
|
|
335
344
|
* @example
|
|
336
345
|
* b.cryptoField.registerTable("patients", {
|
|
@@ -403,6 +412,13 @@ function registerTable(name, opts) {
|
|
|
403
412
|
rowIdField: rowIdField,
|
|
404
413
|
schemaVersion: schemaVersion,
|
|
405
414
|
derivedHashMode: derivedHashMode,
|
|
415
|
+
// allowPlainMigration — read-side downgrade window. On an aad / per-row-key
|
|
416
|
+
// table the read path refuses a plain `vault:` cell (no AAD = relocatable,
|
|
417
|
+
// which would defeat the cross-row/cross-column copy-protection the AAD
|
|
418
|
+
// binding advertises). Operators with genuine pre-AAD rows opt into a
|
|
419
|
+
// bounded lazy-migration window with { allowPlainMigration: true }; a
|
|
420
|
+
// re-seal (sealRow) then re-emits the cell AAD-bound. Default closed.
|
|
421
|
+
allowPlainMigration: opts.allowPlainMigration === true,
|
|
406
422
|
};
|
|
407
423
|
}
|
|
408
424
|
|
|
@@ -1240,6 +1256,20 @@ function unsealRow(table, row, actor, dbHandle) {
|
|
|
1240
1256
|
unsealed = _decodeTyped(vaultAad.unseal(out[field],
|
|
1241
1257
|
_aadParts(s, table, field, out)));
|
|
1242
1258
|
} else if (typeof out[field] === "string" && out[field].startsWith(VAULT_PREFIX)) {
|
|
1259
|
+
// A plain `vault:` cell (no AAD) on an AAD-bound / per-row-key table
|
|
1260
|
+
// is a downgrade: plain vault.seal carries no AAD, so a DB-write
|
|
1261
|
+
// attacker could relocate such a cell from another row/column into
|
|
1262
|
+
// this one and the read would silently accept it — defeating the
|
|
1263
|
+
// cross-row/cross-column copy-protection the AAD binding advertises.
|
|
1264
|
+
// Refuse (throw -> catch nulls the field + audits) unless the table
|
|
1265
|
+
// opted into the documented pre-AAD lazy-migration window.
|
|
1266
|
+
if ((s.aad || perRowKeyTables[table]) && !s.allowPlainMigration) {
|
|
1267
|
+
throw new CryptoFieldError("crypto-field/aad-downgrade-refused",
|
|
1268
|
+
"unsealRow: '" + table + "'.'" + field + "' is AAD-bound but the stored " +
|
|
1269
|
+
"cell is a plain (unbound) vault envelope — refusing a relocatable-seal " +
|
|
1270
|
+
"downgrade (set registerTable({ allowPlainMigration: true }) for a " +
|
|
1271
|
+
"documented pre-AAD migration window)");
|
|
1272
|
+
}
|
|
1243
1273
|
unsealed = _decodeTyped(vault.unseal(out[field]));
|
|
1244
1274
|
} else {
|
|
1245
1275
|
// Not a sealed value — pass through.
|
|
@@ -73,6 +73,18 @@ function _err(code, message) {
|
|
|
73
73
|
return new DeclareRowPolicyError(code, message);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// _isRlsEnabled — fail-closed coercion of pg_class.relrowsecurity. Native pg
|
|
77
|
+
// drivers return a JS boolean; a proxy / ORM / non-native driver may return
|
|
78
|
+
// "t"/"f", "true"/"false", or 1/0. RLS counts as enabled ONLY on a value that
|
|
79
|
+
// unambiguously means true; every other shape (including the truthy string
|
|
80
|
+
// "f") reads as not-enabled, so ENABLE is (re-)issued rather than silently
|
|
81
|
+
// skipped — a skipped ENABLE would leave the table's rows unprotected.
|
|
82
|
+
function _isRlsEnabled(v) {
|
|
83
|
+
if (v === true || v === 1) return true;
|
|
84
|
+
if (typeof v === "string") return /^(t|true|1|on|yes)$/i.test(v.trim());
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
76
88
|
function _validateIdent(where, value) {
|
|
77
89
|
try {
|
|
78
90
|
safeSql.validateIdentifier(value, { allowReserved: true });
|
|
@@ -227,7 +239,14 @@ function declareRowPolicy(opts) {
|
|
|
227
239
|
"source table '" + spec.schema + "." + spec.table +
|
|
228
240
|
"' not found (does it exist? does the migration role have visibility?)");
|
|
229
241
|
}
|
|
230
|
-
|
|
242
|
+
// relrowsecurity is a Postgres boolean. Native pg drivers return true/false,
|
|
243
|
+
// but a proxy / ORM / non-native driver may hand back "t"/"f", "true"/"false",
|
|
244
|
+
// or 1/0. The string "f" is TRUTHY, so a bare `!rows[0].relrowsecurity` would
|
|
245
|
+
// read "f" as "already enabled" and silently SKIP ENABLE — leaving the table's
|
|
246
|
+
// rows unprotected while the migration reports success. Treat RLS as enabled
|
|
247
|
+
// ONLY on a value that unambiguously means true; anything else fails closed and
|
|
248
|
+
// (re-)issues ENABLE, which is a harmless no-op on an already-enabled table.
|
|
249
|
+
if (!_isRlsEnabled(rows[0].relrowsecurity)) {
|
|
231
250
|
var enableStmt = sql.enableRowLevelSecurity(tableRef, { dialect: "postgres" });
|
|
232
251
|
await xdb.query(enableStmt.sql, enableStmt.params);
|
|
233
252
|
}
|
package/lib/db.js
CHANGED
|
@@ -1402,6 +1402,13 @@ async function init(opts) {
|
|
|
1402
1402
|
aad: t.aad,
|
|
1403
1403
|
rowIdField: t.rowIdField,
|
|
1404
1404
|
schemaVersion: t.schemaVersion,
|
|
1405
|
+
// The read-side pre-AAD migration opt-in must pass through too —
|
|
1406
|
+
// otherwise a schema declaring { aad: true, allowPlainMigration: true }
|
|
1407
|
+
// registers with the default (false) via this declarative db.init path,
|
|
1408
|
+
// and legacy plain vault: cells are nulled on read despite the operator
|
|
1409
|
+
// opting into the migration window. registerTable defaults this to false,
|
|
1410
|
+
// so non-migrating tables are unaffected.
|
|
1411
|
+
allowPlainMigration: t.allowPlainMigration,
|
|
1405
1412
|
});
|
|
1406
1413
|
tableMetadata[t.name] = {
|
|
1407
1414
|
primaryKey: _normalizePk(t),
|
package/lib/guard-csv.js
CHANGED
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
|
|
57
57
|
var codepointClass = require("./codepoint-class");
|
|
58
58
|
var csv = require("./csv");
|
|
59
|
+
var safeBuffer = require("./safe-buffer");
|
|
59
60
|
var C = require("./constants");
|
|
60
61
|
var lazyRequire = require("./lazy-require");
|
|
61
62
|
var numericBounds = require("./numeric-bounds");
|
|
@@ -432,7 +433,9 @@ function _stripIssues(text, opts) {
|
|
|
432
433
|
out = out.replace(ZW_RE_G, "");
|
|
433
434
|
if (opts.trailingWhitespacePolicy === "trim") {
|
|
434
435
|
out = out.split("\n").map(function (line) {
|
|
435
|
-
|
|
436
|
+
// Linear per-line trailing-whitespace trim — .replace(/[ \t]+$/) is
|
|
437
|
+
// O(n^2) in V8 on adversarial input (untrusted CSV here).
|
|
438
|
+
return safeBuffer.stripTrailingHspace(line);
|
|
436
439
|
}).join("\n");
|
|
437
440
|
}
|
|
438
441
|
return out;
|
|
@@ -521,9 +524,15 @@ function escapeCell(value, opts) {
|
|
|
521
524
|
if (opts.bidiCharPolicy === "strip") str = str.replace(BIDI_RE_G, "");
|
|
522
525
|
|
|
523
526
|
if (opts.trailingWhitespacePolicy === "trim") {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
+
// Linear strip — .replace(/[ \t]+$/) is O(n^2) on adversarial untrusted CSV.
|
|
528
|
+
str = safeBuffer.stripTrailingHspace(str);
|
|
529
|
+
} else if (opts.trailingWhitespacePolicy === "reject") {
|
|
530
|
+
// Linear "ends in space/tab?" check — /[ \t]+$/.test is ALSO O(n^2) (the
|
|
531
|
+
// engine scans from every offset when there is no trailing run).
|
|
532
|
+
var lastCode = str.length > 0 ? str.charCodeAt(str.length - 1) : 0;
|
|
533
|
+
if (lastCode === 0x20 || lastCode === 0x09) {
|
|
534
|
+
throw _err("csv.trailing-whitespace", "cell has trailing whitespace");
|
|
535
|
+
}
|
|
527
536
|
}
|
|
528
537
|
|
|
529
538
|
if (typeof value === "number" &&
|
package/lib/guard-filename.js
CHANGED
|
@@ -80,7 +80,10 @@ var _err = GuardFilenameError.factory;
|
|
|
80
80
|
// Windows: < > : " / \ | ? *
|
|
81
81
|
// Unix: /
|
|
82
82
|
// Both: null and C0 controls (handled separately via codepoint-class)
|
|
83
|
-
|
|
83
|
+
// Global so reservedCharPolicy:"strip" replaces EVERY reserved char, not just
|
|
84
|
+
// the first (CodeQL js/incomplete-multi-character-sanitization). Only consumer
|
|
85
|
+
// is the .replace() in _sanitize — no stateful .test()/.exec() lastIndex hazard.
|
|
86
|
+
var RESERVED_CHARS_RE = /[<>:"/\\|?*]/g;
|
|
84
87
|
|
|
85
88
|
// Windows reserved device names (case-insensitive). Match either the
|
|
86
89
|
// bare name or `<name>.<anything>`.
|
|
@@ -586,8 +589,9 @@ function _sanitize(input, opts) {
|
|
|
586
589
|
|
|
587
590
|
// Strip reserved chars when policy says strip.
|
|
588
591
|
if (opts.reservedCharPolicy === "strip") {
|
|
592
|
+
// Single global strip — RESERVED_CHARS_RE (now /g) covers the whole
|
|
593
|
+
// reserved class INCLUDING path separators, so no second pass is needed.
|
|
589
594
|
name = name.replace(RESERVED_CHARS_RE, "_"); // allow:dynamic-regex — RESERVED_CHARS_RE is a compile-time literal
|
|
590
|
-
name = name.replace(/[<>:"|?*]/g, "_");
|
|
591
595
|
} else if (opts.reservedCharPolicy === "reject") {
|
|
592
596
|
if (/[<>:"|?*]/.test(name)) {
|
|
593
597
|
throw _err("filename.reserved-char", "filename contains reserved character");
|
package/lib/http-client-cache.js
CHANGED
|
@@ -107,7 +107,9 @@ function _parseCacheControl(value) {
|
|
|
107
107
|
else { k = p.slice(0, eq).trim(); v = p.slice(eq + 1).trim(); }
|
|
108
108
|
// Strip surrounding quotes from value.
|
|
109
109
|
if (v.length >= 2 && v.charAt(0) === '"' && v.charAt(v.length - 1) === '"') {
|
|
110
|
-
|
|
110
|
+
// Single-pass RFC 8941 unescape (chained .replace() mis-decodes
|
|
111
|
+
// an escaped backslash adjacent to another escape).
|
|
112
|
+
v = structuredFields.unescapeSfStringBody(v.slice(1, v.length - 1));
|
|
111
113
|
}
|
|
112
114
|
out[k.toLowerCase()] = v;
|
|
113
115
|
}
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
*/
|
|
61
61
|
|
|
62
62
|
var nodeCrypto = require("node:crypto");
|
|
63
|
+
var bCrypto = require("./crypto");
|
|
63
64
|
var safeUrl = require("./safe-url");
|
|
64
65
|
var safeBuffer = require("./safe-buffer");
|
|
65
66
|
var C = require("./constants");
|
|
@@ -406,7 +407,9 @@ function _parseSignatureInput(headerValue) {
|
|
|
406
407
|
throw _err("BAD_HEADER",
|
|
407
408
|
"httpSig: Signature-Input: unterminated quoted token");
|
|
408
409
|
}
|
|
409
|
-
|
|
410
|
+
// Single-pass RFC 8941 §3.3.3 unescape — NOT two chained .replace() passes,
|
|
411
|
+
// which mis-decode an escaped backslash adjacent to another escape.
|
|
412
|
+
var bareName = structuredFields.unescapeSfStringBody(coveredRaw.slice(qStart, qEnd));
|
|
410
413
|
i2 = qEnd + 1;
|
|
411
414
|
// Optional ;param=value;param=... suffix immediately following.
|
|
412
415
|
var suffixStart = i2;
|
|
@@ -525,13 +528,27 @@ function verify(msg, opts) {
|
|
|
525
528
|
if (!presented) {
|
|
526
529
|
return { valid: false, reason: "content-digest-header-missing" };
|
|
527
530
|
}
|
|
528
|
-
|
|
529
|
-
// RFC 9530
|
|
530
|
-
// sha-256
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
|
|
531
|
+
// contentDigest() returns the canonical structured-field form
|
|
532
|
+
// `sha3-512=:<base64>:`. RFC 9530 permits a multi-member header
|
|
533
|
+
// (e.g. `sha-256=:...:, sha3-512=:...:`); split on top-level commas and
|
|
534
|
+
// match the sha3-512 member EXACTLY, in constant time, rather than by an
|
|
535
|
+
// unanchored substring scan that could spuriously match the digest text
|
|
536
|
+
// buried inside another member's value or parameters. Peer-supplied
|
|
537
|
+
// sha-512 / sha-256 identifiers stay the operator's responsibility.
|
|
538
|
+
var expectedDigest = contentDigest(m.body); // "sha3-512=:<b64>:"
|
|
539
|
+
var matchedDigest = false;
|
|
540
|
+
var digestMembers = structuredFields.splitTopLevel(presented, ",");
|
|
541
|
+
for (var di = 0; di < digestMembers.length; di++) {
|
|
542
|
+
var member = digestMembers[di].trim();
|
|
543
|
+
var deq = member.indexOf("=");
|
|
544
|
+
if (deq < 1) continue;
|
|
545
|
+
if (member.slice(0, deq).trim().toLowerCase() !== "sha3-512") continue;
|
|
546
|
+
var memberCanonical = "sha3-512=" + member.slice(deq + 1).trim();
|
|
547
|
+
// crypto.timingSafeEqual is the length-tolerant constant-time wrapper
|
|
548
|
+
// (returns false for unequal lengths without leaking via a length branch).
|
|
549
|
+
if (bCrypto.timingSafeEqual(memberCanonical, expectedDigest)) { matchedDigest = true; break; }
|
|
550
|
+
}
|
|
551
|
+
if (!matchedDigest) {
|
|
535
552
|
return { valid: false, reason: "content-digest-mismatch" };
|
|
536
553
|
}
|
|
537
554
|
}
|
|
@@ -38,6 +38,9 @@ var lazyRequire = require("./lazy-require");
|
|
|
38
38
|
// scrub attribute values through the telemetry redactor before they cross the
|
|
39
39
|
// OTLP egress boundary (CWE-532).
|
|
40
40
|
var observability = lazyRequire(function () { return require("./observability"); });
|
|
41
|
+
// Lazy — network-tls is widely required; audit an insecure (cert-validation-
|
|
42
|
+
// disabled) outbound TLS session at honor time, same surface as connectWithEch.
|
|
43
|
+
var networkTls = lazyRequire(function () { return require("./network-tls"); });
|
|
41
44
|
|
|
42
45
|
var _err = LogStreamError.factory;
|
|
43
46
|
var _log = boot("log-stream-otlp-grpc");
|
|
@@ -215,7 +218,14 @@ function _makeClient(cfg) {
|
|
|
215
218
|
var sessionOpts = {};
|
|
216
219
|
if (cfg.ca) sessionOpts.ca = cfg.ca;
|
|
217
220
|
if (cfg.servername) sessionOpts.servername = cfg.servername;
|
|
218
|
-
if (cfg.allowInsecure
|
|
221
|
+
if (cfg.allowInsecure && url.protocol === "https:") {
|
|
222
|
+
// allowInsecure only has meaning on a TLS session. For an h2c endpoint
|
|
223
|
+
// (http://, cleartext HTTP/2) there is no certificate to validate and
|
|
224
|
+
// nothing to skip, so neither rejectUnauthorized nor the insecure-TLS
|
|
225
|
+
// audit applies — emitting it there would be a false security event.
|
|
226
|
+
sessionOpts.rejectUnauthorized = false;
|
|
227
|
+
networkTls().auditInsecureTls({ host: authority, source: "log-stream.otlp-grpc" });
|
|
228
|
+
}
|
|
219
229
|
var session = http2.connect(authority, sessionOpts);
|
|
220
230
|
session.on("error", function () { /* surfaced through request err */ });
|
|
221
231
|
if (typeof session.unref === "function") session.unref();
|
|
@@ -409,6 +419,7 @@ module.exports = {
|
|
|
409
419
|
create: create,
|
|
410
420
|
// Exposed for layer-0 tests that verify the wire encoding without
|
|
411
421
|
// standing up an HTTP/2 server.
|
|
422
|
+
_makeClient: _makeClient,
|
|
412
423
|
_encodeAnyValue: _encodeAnyValue,
|
|
413
424
|
_encodeKeyValue: _encodeKeyValue,
|
|
414
425
|
_encodeLogRecord: _encodeLogRecord,
|
package/lib/log-stream-syslog.js
CHANGED
|
@@ -37,6 +37,9 @@ var safeAsync = require("./safe-async");
|
|
|
37
37
|
var safeBuffer = require("./safe-buffer");
|
|
38
38
|
var safeUrl = require("./safe-url");
|
|
39
39
|
var { LogStreamError } = require("./framework-error");
|
|
40
|
+
var lazyRequire = require("./lazy-require");
|
|
41
|
+
// Lazy — audit a cert-validation-disabled syslog/TLS session at honor time.
|
|
42
|
+
var networkTls = lazyRequire(function () { return require("./network-tls"); });
|
|
40
43
|
|
|
41
44
|
var _err = LogStreamError.factory;
|
|
42
45
|
var log = boot("log-stream-syslog");
|
|
@@ -223,6 +226,9 @@ function create(config) {
|
|
|
223
226
|
});
|
|
224
227
|
if (cfg.ca) tlsOpts.ca = cfg.ca;
|
|
225
228
|
if (cfg.servername) tlsOpts.servername = cfg.servername;
|
|
229
|
+
if (cfg.rejectUnauthorized === false) {
|
|
230
|
+
networkTls().auditInsecureTls({ host: cfg.host, port: cfg.port, source: "log-stream.syslog" });
|
|
231
|
+
}
|
|
226
232
|
sock = nodeTls.connect(tlsOpts, onConnect);
|
|
227
233
|
} else {
|
|
228
234
|
sock = net.connect(connectOpts, onConnect);
|
package/lib/log.js
CHANGED
|
@@ -464,7 +464,11 @@ function boot(name) {
|
|
|
464
464
|
var stream = (LEVELS[levelName] >= LEVELS.warn) ? process.stderr : process.stdout;
|
|
465
465
|
var isTty = !!(stream && stream.isTTY);
|
|
466
466
|
if (isTty) {
|
|
467
|
-
|
|
467
|
+
// Raw human-readable line — escape BOTH the C0/newline (line-forging)
|
|
468
|
+
// and bidi (re-ordering) control classes the create() path neutralizes,
|
|
469
|
+
// so a hostile boot message can't inject lines or re-order the visible
|
|
470
|
+
// line on a TTY / syslog reader (CWE-117 / Trojan-Source CVE-2021-42574).
|
|
471
|
+
sink(_escapeBidiControls(_escapeC0Controls(prefix + String(msg))));
|
|
468
472
|
return;
|
|
469
473
|
}
|
|
470
474
|
var entry = {
|
|
@@ -474,7 +478,9 @@ function boot(name) {
|
|
|
474
478
|
component: name,
|
|
475
479
|
boot: true,
|
|
476
480
|
};
|
|
477
|
-
|
|
481
|
+
// JSON.stringify already escapes C0/newlines; bidi/format controls survive
|
|
482
|
+
// raw into a piped aggregator, so apply the same bidi escape create() uses.
|
|
483
|
+
sink(_escapeBidiControls(JSON.stringify(entry)));
|
|
478
484
|
}
|
|
479
485
|
|
|
480
486
|
function debug(msg, fields) {
|
|
@@ -560,6 +566,22 @@ function _escapeBidiControls(s) {
|
|
|
560
566
|
});
|
|
561
567
|
}
|
|
562
568
|
|
|
569
|
+
// C0 control chars (incl. CR / LF / TAB) + DEL — escaped to `\uXXXX` so a
|
|
570
|
+
// hostile message can't forge extra log lines on a raw (non-JSON) TTY sink
|
|
571
|
+
// (log-injection, CWE-117). The create() path gets this for free from
|
|
572
|
+
// JSON.stringify; the boot() TTY branch writes raw text and needs it
|
|
573
|
+
// explicitly. Pairs with _escapeBidiControls (which only covers the bidi set).
|
|
574
|
+
var _C0_CONTROL_RE = /[\u0000-\u001f\u007f]/g; // eslint-disable-line no-control-regex -- the C0/DEL set is what we escape
|
|
575
|
+
|
|
576
|
+
function _escapeC0Controls(s) {
|
|
577
|
+
if (typeof s !== "string" || s.length === 0) return s;
|
|
578
|
+
return s.replace(_C0_CONTROL_RE, function (ch) {
|
|
579
|
+
var code = ch.charCodeAt(0).toString(16);
|
|
580
|
+
while (code.length < 4) code = "0" + code;
|
|
581
|
+
return "\\u" + code;
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
563
585
|
// runs before safeEnv on the boot path; safeEnv requires log, so log
|
|
564
586
|
// can't go through safeEnv to read its own level.
|
|
565
587
|
function _bootMinLevel() {
|
package/lib/mail-bimi.js
CHANGED
|
@@ -799,10 +799,23 @@ async function fetchAndVerifyMark(opts) {
|
|
|
799
799
|
|
|
800
800
|
function _splitPemChain(pemText) {
|
|
801
801
|
if (typeof pemText !== "string") return [];
|
|
802
|
+
// Linear indexOf walk over non-overlapping BEGIN..END blocks — NOT
|
|
803
|
+
// /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g. The
|
|
804
|
+
// lazy-quantifier scan re-walks from every BEGIN marker that has no
|
|
805
|
+
// matching END, which is superlinear on a chain of BEGIN-only markers;
|
|
806
|
+
// the indexOf walk advances monotonically and never re-scans.
|
|
807
|
+
var BEGIN = "-----BEGIN CERTIFICATE-----";
|
|
808
|
+
var END = "-----END CERTIFICATE-----";
|
|
802
809
|
var out = [];
|
|
803
|
-
var
|
|
804
|
-
|
|
805
|
-
|
|
810
|
+
var from = 0;
|
|
811
|
+
for (;;) {
|
|
812
|
+
var b = pemText.indexOf(BEGIN, from);
|
|
813
|
+
if (b === -1) break;
|
|
814
|
+
var e = pemText.indexOf(END, b + BEGIN.length);
|
|
815
|
+
if (e === -1) break; // unterminated final block — no further certs
|
|
816
|
+
out.push(pemText.slice(b, e + END.length));
|
|
817
|
+
from = e + END.length;
|
|
818
|
+
}
|
|
806
819
|
return out;
|
|
807
820
|
}
|
|
808
821
|
|
package/lib/mail.js
CHANGED
|
@@ -76,6 +76,8 @@ var networkDns = lazyRequire(function () { return require("./network-dns"); });
|
|
|
76
76
|
var nodeUrl = require("node:url");
|
|
77
77
|
var numericBounds = require("./numeric-bounds");
|
|
78
78
|
var nodeTls = lazyRequire(function () { return require("node:tls"); });
|
|
79
|
+
// Lazy — audit a cert-validation-disabled SMTP/TLS session at honor time.
|
|
80
|
+
var networkTls = lazyRequire(function () { return require("./network-tls"); });
|
|
79
81
|
var safeJson = require("./safe-json");
|
|
80
82
|
var safeSchema = require("./safe-schema");
|
|
81
83
|
var validateOpts = require("./validate-opts");
|
|
@@ -313,10 +315,9 @@ function _normalizeRecipientList(value, label) {
|
|
|
313
315
|
label + "[" + i + "] contains forbidden control characters", true);
|
|
314
316
|
}
|
|
315
317
|
// Accept "Name <email@addr>" form too — extract the angle-bracket
|
|
316
|
-
// address for validation; preserve the full
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
if (!_isValidEmail(addr.trim())) {
|
|
318
|
+
// address for validation (linear, via _extractAddr); preserve the full
|
|
319
|
+
// string in the message.
|
|
320
|
+
if (!_isValidEmail(_extractAddr(arr[i]))) {
|
|
320
321
|
throw new MailError("mail/invalid-recipient",
|
|
321
322
|
label + " '" + arr[i] + "' is not a valid email address", true);
|
|
322
323
|
}
|
|
@@ -423,9 +424,7 @@ function _validateMessage(message) {
|
|
|
423
424
|
throw new MailError("mail/invalid-from",
|
|
424
425
|
"message.from contains forbidden control characters", true);
|
|
425
426
|
}
|
|
426
|
-
|
|
427
|
-
var fromAddr = fromBracket ? fromBracket[1] : message.from;
|
|
428
|
-
if (!_isValidEmail(fromAddr.trim())) {
|
|
427
|
+
if (!_isValidEmail(_extractAddr(message.from))) {
|
|
429
428
|
throw new MailError("mail/invalid-from",
|
|
430
429
|
"message.from '" + message.from + "' is not a valid email address", true);
|
|
431
430
|
}
|
|
@@ -557,8 +556,18 @@ function _mergeMessage(defaults, message) {
|
|
|
557
556
|
|
|
558
557
|
function _extractAddr(s) {
|
|
559
558
|
if (s === undefined || s === null) return s;
|
|
560
|
-
|
|
561
|
-
|
|
559
|
+
s = String(s);
|
|
560
|
+
// Linear angle-bracket extraction — NOT s.match(/<([^>]+)>/), which is O(n^2)
|
|
561
|
+
// in V8 on a long run of '<' with no '>' (the engine retries the greedy
|
|
562
|
+
// [^>]+ from every '<' offset; 200K '<' ~ 11s). Recipient/from addresses on
|
|
563
|
+
// b.mail.send can be caller/request-supplied, so this is a reachable DoS.
|
|
564
|
+
// Mirrors the regex: the chars between the first '<' and the next '>'.
|
|
565
|
+
var lt = s.indexOf("<");
|
|
566
|
+
if (lt !== -1) {
|
|
567
|
+
var gt = s.indexOf(">", lt + 1);
|
|
568
|
+
if (gt > lt + 1) return s.slice(lt + 1, gt).trim();
|
|
569
|
+
}
|
|
570
|
+
return s.trim();
|
|
562
571
|
}
|
|
563
572
|
|
|
564
573
|
function _toArray(v) {
|
|
@@ -771,6 +780,9 @@ function smtpTransport(opts) {
|
|
|
771
780
|
var port = opts.port || 587;
|
|
772
781
|
var useImplicitTLS = port === 465 || opts.implicitTls === true;
|
|
773
782
|
var rejectUnauthorized = opts.rejectUnauthorized !== false;
|
|
783
|
+
if (rejectUnauthorized === false) {
|
|
784
|
+
networkTls().auditInsecureTls({ host: opts.host, port: port, source: "mail.smtp" });
|
|
785
|
+
}
|
|
774
786
|
var ehloName = opts.ehloName || "blamejs";
|
|
775
787
|
// GHSA-c7w3-x93f-qmm8 / GHSA-vvjj-xcjg-gr5g (nodemailer CRLF-injection
|
|
776
788
|
// class) — any string concatenated into an outbound SMTP wire command
|