@blamejs/blamejs-shop 0.4.85 → 0.4.86

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.
Files changed (59) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/lib/asset-manifest.json +1 -1
  3. package/lib/order.js +4 -2
  4. package/lib/security-middleware.js +1 -0
  5. package/lib/vendor/MANIFEST.json +71 -39
  6. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  7. package/lib/vendor/blamejs/api-snapshot.json +11 -3
  8. package/lib/vendor/blamejs/lib/atomic-file.js +34 -0
  9. package/lib/vendor/blamejs/lib/auth/fido-mds3.js +10 -0
  10. package/lib/vendor/blamejs/lib/auth/password.js +1 -0
  11. package/lib/vendor/blamejs/lib/auth/saml.js +11 -3
  12. package/lib/vendor/blamejs/lib/daemon.js +4 -1
  13. package/lib/vendor/blamejs/lib/external-db.js +131 -0
  14. package/lib/vendor/blamejs/lib/graphql-federation.js +25 -15
  15. package/lib/vendor/blamejs/lib/log-stream-cloudwatch.js +1 -0
  16. package/lib/vendor/blamejs/lib/log-stream-local.js +14 -1
  17. package/lib/vendor/blamejs/lib/log-stream-otlp.js +1 -0
  18. package/lib/vendor/blamejs/lib/log-stream-webhook.js +1 -0
  19. package/lib/vendor/blamejs/lib/mail-auth.js +69 -14
  20. package/lib/vendor/blamejs/lib/mail-bimi.js +6 -0
  21. package/lib/vendor/blamejs/lib/mail-crypto-smime.js +10 -0
  22. package/lib/vendor/blamejs/lib/mail-dkim.js +68 -15
  23. package/lib/vendor/blamejs/lib/mail.js +39 -0
  24. package/lib/vendor/blamejs/lib/middleware/api-encrypt.js +6 -2
  25. package/lib/vendor/blamejs/lib/network-dns-resolver.js +61 -11
  26. package/lib/vendor/blamejs/lib/network-dns.js +47 -2
  27. package/lib/vendor/blamejs/lib/network-nts.js +16 -0
  28. package/lib/vendor/blamejs/lib/network-proxy.js +55 -2
  29. package/lib/vendor/blamejs/lib/object-store/azure-blob-bucket-ops.js +1 -0
  30. package/lib/vendor/blamejs/lib/object-store/gcs-bucket-ops.js +1 -0
  31. package/lib/vendor/blamejs/lib/object-store/http-request.js +4 -0
  32. package/lib/vendor/blamejs/lib/outbox.js +29 -0
  33. package/lib/vendor/blamejs/lib/queue-sqs.js +1 -0
  34. package/lib/vendor/blamejs/lib/request-helpers.js +25 -6
  35. package/lib/vendor/blamejs/lib/session-device-binding.js +46 -24
  36. package/lib/vendor/blamejs/lib/session.js +85 -28
  37. package/lib/vendor/blamejs/package.json +1 -1
  38. package/lib/vendor/blamejs/release-notes/v0.15.16.json +94 -0
  39. package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +51 -0
  40. package/lib/vendor/blamejs/test/layer-0-primitives/atomic-file-open-append-nofollow.test.js +87 -0
  41. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +48 -0
  42. package/lib/vendor/blamejs/test/layer-0-primitives/external-db-non-atomic-backend.test.js +200 -0
  43. package/lib/vendor/blamejs/test/layer-0-primitives/fido-mds3-cert-bad-validity.test.js +159 -0
  44. package/lib/vendor/blamejs/test/layer-0-primitives/graphql-federation.test.js +49 -2
  45. package/lib/vendor/blamejs/test/layer-0-primitives/mail-auth-dmarc-policy-failclosed.test.js +139 -0
  46. package/lib/vendor/blamejs/test/layer-0-primitives/mail-bimi-cert-validity-unparseable.test.js +137 -0
  47. package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto-smime-bad-validity.test.js +134 -0
  48. package/lib/vendor/blamejs/test/layer-0-primitives/mail-dkim-numericdate-failclosed.test.js +155 -0
  49. package/lib/vendor/blamejs/test/layer-0-primitives/mail-proxy-framing-bounds.test.js +263 -0
  50. package/lib/vendor/blamejs/test/layer-0-primitives/network-dns-lookup-timeout-default.test.js +116 -0
  51. package/lib/vendor/blamejs/test/layer-0-primitives/network-dns-resolver-timeout.test.js +126 -0
  52. package/lib/vendor/blamejs/test/layer-0-primitives/network-nts-handshake-byte-cap.test.js +127 -0
  53. package/lib/vendor/blamejs/test/layer-0-primitives/saml-subjectconfirmation-notbefore.test.js +238 -0
  54. package/lib/vendor/blamejs/test/layer-0-primitives/session-destroy-all-store-backed.test.js +128 -0
  55. package/lib/vendor/blamejs/test/layer-0-primitives/session-device-binding-ipv6-canonical-and-no-store.test.js +202 -0
  56. package/lib/vendor/blamejs/test/layer-0-primitives/session-device-binding.test.js +18 -9
  57. package/lib/vendor/blamejs/test/layer-0-primitives/webhook-verify-nonce-atomic.test.js +169 -0
  58. package/lib/webhooks.js +38 -9
  59. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.15.15",
3
+ "version": "0.15.16",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -0,0 +1,94 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-schema.json",
3
+ "version": "0.15.16",
4
+ "date": "2026-06-22",
5
+ "headline": "A correctness and hardening release: a broad set of credential and certificate verifiers now fail closed on a present-but-unparseable timestamp instead of silently skipping the check, outbound network clients gain an overall wall-clock timeout and a bounded framing buffer, DMARC refuses a record with no usable policy, append-only log sinks refuse a symlinked path, and the interactive-transaction and replay-store contracts refuse a backend that cannot honor them",
6
+ "summary": "This release closes a family of fail-open and resource-exhaustion gaps without adding opt-ins to the request path. A recurring pattern across the verifiers — a timestamp parsed with a lenient parser, then gated by isFinite() so an unparseable value disables the check — is converted to fail-closed everywhere it appears: DKIM x=/t=/l=, ARC AMS/AS t=/x=, SAML Bearer and holder-of-key NotBefore, and the FIDO MDS3 / BIMI / S/MIME certificate validity windows now refuse a malformed value rather than accepting the credential. DMARC now validates and requires a usable p= policy and never recommends delivery for a failing message with an absent or unrecognized policy. Outbound clients that exposed a single timeout but applied it only as an idle (zero-progress) timer now also enforce it as an overall wall-clock cap, so a peer that trickles bytes within the idle window can no longer hold a request open indefinitely: the encrypted HTTP client, the object-store and SQS request paths, the CloudWatch / OTLP / webhook log sinks, and the password breach check. The DNS resolver and the lower-level DNS client gain a per-query wall-clock deadline that also tears the socket down, the SMTP client and the proxy CONNECT tunnel bound their framing buffers and add an overall deadline, and the NTS key-establishment handshake bounds its accumulator. The append-only local log sink and the daemon log open with O_NOFOLLOW so a symlink planted at the path is refused rather than followed. b.externalDb.transaction and b.outbox now refuse a backend that declares it cannot provide an interactive transaction instead of silently running a non-atomic block, and the GraphQL-federation replay store adopts the framework's atomic check-and-insert contract.",
7
+ "sections": [
8
+ {
9
+ "heading": "Security",
10
+ "items": [
11
+ {
12
+ "title": "Certificate and credential verifiers fail closed on an unparseable timestamp",
13
+ "body": "A present-but-unparseable date no longer silently disables a validity check. DKIM x= / t= (signature expiry, future-date, ordering) and l= (body-length cap), ARC AMS/AS t= / x=, SAML Bearer and holder-of-key SubjectConfirmationData NotBefore, and the FIDO MDS3, BIMI, and S/MIME certificate validity windows previously parsed the value, then guarded the comparison with isFinite() — so a value that did not parse (NaN) skipped the check and the credential was accepted. Each now refuses a present-but-unparseable value (a permerror / not-valid result) and only an absent optional field is ignored, matching the existing fail-closed handling of the SAML Conditions block."
14
+ },
15
+ {
16
+ "title": "DMARC refuses a record with no usable policy",
17
+ "body": "The DMARC policy tag p= (and the subdomain sp=) were taken verbatim with no validation and p= was never required, so the disposition mapped a null or typo'd policy to the catch-all \"deliver\". A failing, unaligned message against a domain whose record omitted p= or carried an unrecognized value was therefore delivered. p= and sp= are now validated against none|quarantine|reject, p= is required for a record to carry a policy at all, an invalid record is a permerror (not a transient temperror), and the disposition is an explicit fail-closed mapping that never delivers a failing message on an absent or unrecognized policy."
18
+ },
19
+ {
20
+ "title": "Outbound clients enforce an overall wall-clock timeout, not only an idle timeout",
21
+ "body": "Several clients exposed a single timeout but forwarded it to the underlying request only as idleTimeoutMs (a zero-progress cap that resets on every received byte), leaving no overall bound — a peer that trickles one byte inside each idle window holds the call open indefinitely. b.httpClient.encrypted (which dropped timeoutMs entirely, and now also forwards the caller's AbortSignal), the object-store request path and Azure/GCS bucket operations, the SQS queue client, the CloudWatch / OTLP / webhook log sinks, and the password HIBP breach check now set the overall wall-clock timeout alongside the idle timeout."
22
+ },
23
+ {
24
+ "title": "DNS lookups gain a default wall-clock deadline and tear the socket down",
25
+ "body": "The DNS resolver's DoH transport had no time bound of any kind and the lower-level DNS client defaulted its lookup timeout to zero (no deadline). A non-responsive or slow-trickle upstream — reachable while resolving DKIM/SPF/BIMI records for inbound mail — held the lookup pending forever. The resolver now takes a per-query timeout (default 10s) wrapping every query and CNAME hop, the DNS client defaults its lookup timeout to 10s (operators opt out with a zero timeout), and the raw DoH/DoT request paths arm a socket timeout that destroys the connection on a stall rather than leaking the descriptor."
26
+ },
27
+ {
28
+ "title": "SMTP, proxy-CONNECT, and NTS clients bound their read buffers and add deadlines",
29
+ "body": "The SMTP client accumulated the server's reply without a byte cap and relied on an idle timer alone; a hostile or broken MX that never sent a line terminator could exhaust memory, and a slow trickle held the transaction open. It now caps the accumulated reply (refusing once it exceeds the response ceiling) and enforces an overall transaction deadline. The proxy CONNECT-tunnel client, which accumulated the proxy's reply unbounded with no socket timeout, now caps the header buffer and times out the connect. The NTS key-establishment handshake reader caps its record accumulator so a server streaming non-terminating records cannot exhaust memory before the handshake timer fires."
30
+ },
31
+ {
32
+ "title": "Append-only log sinks refuse a symlinked path",
33
+ "body": "The local log sink and the daemon log opened the active file in append mode without O_NOFOLLOW, so a symlink planted at the log path (including one re-planted in the race window after a caller's pre-check) was followed, redirecting log writes to an attacker-chosen file. Both now open with O_NOFOLLOW via the new b.atomicFile.openAppendNoFollowSync, refusing a symlinked final path component."
34
+ },
35
+ {
36
+ "title": "Transaction and replay-store contracts refuse a backend that cannot honor them",
37
+ "body": "b.externalDb.transaction and b.outbox.create now refuse a backend that declares it cannot provide an interactive transaction (a stateless / autocommit-per-statement adapter) instead of silently running BEGIN, the body, and COMMIT on different sessions — no isolation, no rollback — while reporting success. b.graphqlFederation.guardSdl now consults its replay nonce store through the framework's atomic checkAndInsert(nonce, expireAt) contract (the same one b.webhook and b.nonceStore use) rather than a non-atomic has()-then-remember() check that raced concurrent redeliveries; a store lacking checkAndInsert is refused at construction."
38
+ }
39
+ ]
40
+ },
41
+ {
42
+ "heading": "Fixed",
43
+ "items": [
44
+ {
45
+ "title": "b.session.destroyAllForUser works for pluggable-store consumers",
46
+ "body": "A consumer configuring sessions through b.session.useStore without calling b.db.init() got a db/not-initialized error from destroyAllForUser — every \"log out everywhere\" / suspend / delete path rejected with a 500 after the store rows had already been deleted, because the stateless valid-from boundary write was routed only to the framework database. The boundary now prefers the framework database when one is initialized and otherwise falls back to the configured session store (provisioning its table on demand), so store-backed deployments raise the stateless boundary successfully; it still fails closed when neither a framework database nor a store is available."
47
+ }
48
+ ]
49
+ },
50
+ {
51
+ "heading": "Added",
52
+ "items": [
53
+ {
54
+ "title": "b.atomicFile.openAppendNoFollowSync — symlink-refusing append open",
55
+ "body": "Opens a long-lived append target (an active log file kept open across appends and reopened on rotation) with O_WRONLY | O_APPEND | O_CREAT | O_NOFOLLOW: the file is created or appended normally, but a symlink at the final path component fails the open closed (ELOOP) rather than redirecting writes. The append-sink counterpart to openNoFollowSync (read) and the exclusive-create temp open."
56
+ },
57
+ {
58
+ "title": "b.externalDb.supportsTransactions() and stateless-adapter declaration",
59
+ "body": "A backend adapter may declare supportsTransactions: false (a stateless / autocommit-per-statement adapter) and/or provide a batch(client, statements) hook for an atomic multi-statement path. b.externalDb.supportsTransactions() reports whether the picked backend can provide an interactive transaction, so a consumer built on the dual-write guarantee can refuse a non-atomic backend up front."
60
+ },
61
+ {
62
+ "title": "Store-free device fingerprint",
63
+ "body": "b.sessionDeviceBinding.fingerprint(req, opts) is now a static entry point returning the request-shape digest with no store, and b.sessionDeviceBinding.create() with neither a bindingStore nor storeInSession returns an instance whose stateless fingerprint() works while bind()/verify() throw a clear \"no store configured\". Soft device binding for self-validating tokens (a sealed cookie or JWT carrying the fingerprint) no longer needs a fabricated no-op store."
64
+ }
65
+ ]
66
+ },
67
+ {
68
+ "heading": "Changed",
69
+ "items": [
70
+ {
71
+ "title": "Device fingerprint IP masking is canonical",
72
+ "body": "b.sessionDeviceBinding masked the client IP into its prefix bucket by textual group slicing with no IPv6 normalization, so equivalent forms of one address (a ::-compressed form vs its fully-expanded form) hashed to different fingerprints and a roaming client could be logged out on a false drift. It now masks through the same canonical prefix helper b.session uses, preserving the configured prefix width (the IPv6 default stays /48, and ipPrefixBits is honored). b.requestHelpers.ipPrefix accepts { v4Bits, v6Bits } to override the /24 + /64 default for a function-form fingerprint or other reuse."
73
+ }
74
+ ]
75
+ },
76
+ {
77
+ "heading": "Migration",
78
+ "items": [
79
+ {
80
+ "title": "DNS lookups now time out by default",
81
+ "body": "The lower-level DNS client's lookup timeout previously defaulted to zero (no deadline); it now defaults to 10 seconds. A deployment that intentionally ran DNS lookups with no wall-clock bound must set the lookup timeout to zero explicitly to restore the old behavior. The b.network.dns.resolver default timeout is also 10 seconds and is configurable via its timeoutMs option."
82
+ },
83
+ {
84
+ "title": "b.graphqlFederation.guardSdl nonceStore contract",
85
+ "body": "guardSdl's optional replay nonceStore now requires the atomic checkAndInsert(nonce, expireAt) contract (b.nonceStore.create is the reference store) and refuses the previous { has, remember } shape at construction. Operators using the old shape should pass b.nonceStore.create(...) or an adapter exposing checkAndInsert."
86
+ },
87
+ {
88
+ "title": "Interactive transactions on a stateless backend are refused",
89
+ "body": "If an externalDb backend adapter declares supportsTransactions: false, b.externalDb.transaction and b.outbox.create now throw rather than running a silently non-atomic block. Existing stateful adapters (Postgres / MySQL / SQLite, and any adapter that supplies beginTx/commit/rollback or omits the flag) are unaffected. A stateless / autocommit-per-statement adapter should supply interactive transaction hooks or a batch adapter."
90
+ }
91
+ ]
92
+ }
93
+ ]
94
+ }
@@ -1508,6 +1508,7 @@ async function run() {
1508
1508
  await testApiEncryptDerivedPruneInterval();
1509
1509
  await testApiEncryptHttpClientHelperShape();
1510
1510
  await testApiEncryptHttpClientRoundTrip();
1511
+ await testApiEncryptHttpClientWallClockTimeout();
1511
1512
  await testApiEncryptEncryptedErrorReadback();
1512
1513
 
1513
1514
  // v0.7.3 — per-session keying mode
@@ -1530,6 +1531,56 @@ async function run() {
1530
1531
  await testApiEncryptObservabilityCounters();
1531
1532
  }
1532
1533
 
1534
+ // #355 — b.httpClient.encrypted must forward timeoutMs (overall wall-clock cap),
1535
+ // not only idleTimeoutMs. A peer that trickles bytes within the idle window
1536
+ // (never tripping the idle timeout) but never completes the response would
1537
+ // otherwise hold the encrypted call open indefinitely. RED before the fix
1538
+ // (timeoutMs absent from the passable forwarding set): the request never settles
1539
+ // inside the wall-clock bound. GREEN after: it rejects at ~timeoutMs.
1540
+ async function testApiEncryptHttpClientWallClockTimeout() {
1541
+ var http = require("http");
1542
+ var keypair = _serverKeypair();
1543
+ var timer = null;
1544
+ var server = http.createServer(function (req, res) {
1545
+ req.on("data", function () {});
1546
+ req.on("end", function () {
1547
+ res.writeHead(200, { "Content-Type": "application/json" });
1548
+ // Trickle one byte every 50ms forever — socket never idle long enough to
1549
+ // trip idleTimeoutMs, response never ends → only a wall-clock cap bounds it.
1550
+ timer = setInterval(function () { try { res.write("x"); } catch (_e) { /* socket gone */ } }, 50);
1551
+ res.on("close", function () { if (timer) { clearInterval(timer); timer = null; } });
1552
+ });
1553
+ });
1554
+ var port = await helpers.listenOnRandomPort(server);
1555
+ try {
1556
+ var enc = b.httpClient.encrypted({
1557
+ pubkey: keypair,
1558
+ baseUrl: "http://127.0.0.1:" + port,
1559
+ method: "POST",
1560
+ });
1561
+ var started = Date.now();
1562
+ var rejected = false;
1563
+ try {
1564
+ await enc.request({
1565
+ path: "/slow",
1566
+ body: { user: "alice" },
1567
+ timeoutMs: 400, // overall wall-clock cap
1568
+ allowedProtocols: b.safeUrl.ALLOW_HTTP_ALL,
1569
+ allowInternal: true,
1570
+ });
1571
+ } catch (_e) {
1572
+ rejected = true;
1573
+ }
1574
+ var elapsed = Date.now() - started;
1575
+ check("httpClient.encrypted: slow-trickle request is bounded (rejects)", rejected === true);
1576
+ check("httpClient.encrypted: bounded by the wall-clock cap (~timeoutMs, not idle)",
1577
+ elapsed < 3000);
1578
+ } finally {
1579
+ if (timer) clearInterval(timer);
1580
+ server.close();
1581
+ }
1582
+ }
1583
+
1533
1584
  module.exports = { run: run };
1534
1585
 
1535
1586
  if (require.main === module) {
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+ // b.atomicFile.openAppendNoFollowSync — O_NOFOLLOW append open for long-lived
3
+ // append sinks (active log file kept open across appends + reopened on
4
+ // rotation). Creates the file if absent and appends if it is a regular file,
5
+ // but refuses a symlink at the final path component (ELOOP) instead of
6
+ // following it — so log writes cannot be redirected to an attacker-chosen
7
+ // file (CWE-59). RED before 0.15.16: log-stream-local opened the active log
8
+ // with a bare `openSync(path, "a")`, following a symlink planted at the path.
9
+
10
+ var fs = require("node:fs");
11
+ var os = require("node:os");
12
+ var path = require("node:path");
13
+ var helpers = require("../helpers");
14
+ var b = helpers.b;
15
+ var check = helpers.check;
16
+ var localProto = require("../../lib/log-stream-local");
17
+
18
+ function run() {
19
+ check("b.atomicFile.openAppendNoFollowSync is a function",
20
+ typeof b.atomicFile.openAppendNoFollowSync === "function");
21
+
22
+ var dir = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-append-nofollow-"));
23
+ try {
24
+ // --- primitive: create-if-absent + append to a regular file ---
25
+ var p = path.join(dir, "active.log");
26
+ var fd = b.atomicFile.openAppendNoFollowSync(p, 0o600);
27
+ check("returns a numeric fd, creating the file if absent",
28
+ typeof fd === "number" && fd >= 0 && fs.existsSync(p));
29
+ fs.writeSync(fd, Buffer.from("line-1\n"));
30
+ fs.closeSync(fd);
31
+ var fd2 = b.atomicFile.openAppendNoFollowSync(p, 0o600);
32
+ fs.writeSync(fd2, Buffer.from("line-2\n"));
33
+ fs.closeSync(fd2);
34
+ check("appends rather than truncates an existing regular file",
35
+ fs.readFileSync(p, "utf8") === "line-1\nline-2\n");
36
+
37
+ // --- primitive: symlink at the final component is refused (POSIX) ---
38
+ if (process.platform !== "win32") {
39
+ var victim = path.join(dir, "victim.txt");
40
+ fs.writeFileSync(victim, "ORIGINAL");
41
+ var linkPath = path.join(dir, "evil.log");
42
+ var symlinkMade = false;
43
+ try { fs.symlinkSync(victim, linkPath); symlinkMade = true; } catch (_e) { /* no symlink priv */ }
44
+ if (symlinkMade) {
45
+ var loopCode = null;
46
+ try {
47
+ var bad = b.atomicFile.openAppendNoFollowSync(linkPath, 0o600);
48
+ fs.closeSync(bad);
49
+ } catch (e) { loopCode = e && e.code; }
50
+ check("symlink final component refused with ELOOP (not followed)", loopCode === "ELOOP");
51
+ check("the symlink target was never written through",
52
+ fs.readFileSync(victim, "utf8") === "ORIGINAL");
53
+ }
54
+
55
+ // --- consumer path (#356): log-stream-local refuses a symlinked active log ---
56
+ var logDir = path.join(dir, "logs");
57
+ fs.mkdirSync(logDir, { recursive: true });
58
+ var logVictim = path.join(dir, "log-victim.txt");
59
+ fs.writeFileSync(logVictim, "VICTIM-LOG");
60
+ var activePath = path.join(logDir, "blamejs.log"); // <prefix>.log
61
+ var planted = false;
62
+ try { fs.symlinkSync(logVictim, activePath); planted = true; } catch (_e) { /* no priv */ }
63
+ if (planted) {
64
+ var refusedCode = null;
65
+ var sink = null;
66
+ try { sink = localProto.create({ dir: logDir }); }
67
+ catch (e) { refusedCode = e && e.code; }
68
+ if (sink) { try { sink.close(); } catch (_c) { /* ignore */ } }
69
+ check("log-stream-local refuses to open a symlinked active log path",
70
+ refusedCode === "SYMLINK_REFUSED");
71
+ check("log-stream-local never writes through the symlink to the victim",
72
+ fs.readFileSync(logVictim, "utf8") === "VICTIM-LOG");
73
+ }
74
+ }
75
+ } finally {
76
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_e) { /* best-effort */ }
77
+ }
78
+ return Promise.resolve();
79
+ }
80
+
81
+ module.exports = { run: run };
82
+ if (require.main === module) {
83
+ Promise.resolve(run()).then(
84
+ function () { console.log("OK — atomicFile.openAppendNoFollowSync + log-stream-local symlink refusal (" + helpers.getChecks() + " checks)"); process.exit(0); },
85
+ function (e) { console.error("FAIL: " + (e && e.message)); process.exit(1); }
86
+ );
87
+ }
@@ -8546,6 +8546,54 @@ var KNOWN_ANTIPATTERNS = [
8546
8546
  ],
8547
8547
  reason: "Symlink-follow / torn-write class (CWE-59 / CWE-377). A raw nodeFs.writeFileSync / nodeFs.createWriteStream to a destination path follows a symlink an attacker pre-planted there and can leave a half-written file on a crash. The §1 sweep routed every hand-rolled write through an atomic-file primitive: writeSync (buffer payloads — cookie-jar flush, archive/tar extract, backup payloads, p12 export, api-snapshot, sealed-pem marker), writeStream (object-store streaming put — staged into a no-follow exclusive temp + atomic rename, capped by maxBytes), and writeExclSync (the vault seal/unseal/rotate staged write that must re-read + verify the bytes before the rename — clears any stale entry then O_EXCL|O_NOFOLLOW creates so a re-planted symlink fails closed). The three allowlisted files are the genuinely-safe forms: atomic-file.js owns the primitives (its createWriteStream binds an _openExclTemp fd); backup/index.js writeFileSync passes { flag: 'wx' } (O_EXCL refuses a pre-planted target); http-client.js opens its download temp with explicit O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW flags. Any new raw nodeFs.writeFileSync/createWriteStream elsewhere trips this — call the matching atomic-file primitive instead.",
8548
8548
  },
8549
+ {
8550
+ id: "raw-fs-append-open-without-nofollow",
8551
+ primitive: "b.atomicFile.openAppendNoFollowSync — O_WRONLY|O_APPEND|O_CREAT|O_NOFOLLOW append open",
8552
+ // The append-sink sibling of raw-fs-write-without-exclusive-nofollow. A
8553
+ // long-lived append target (an active log file kept open across appends and
8554
+ // reopened on rotation) can't use a one-shot atomic write, but a bare
8555
+ // nodeFs.openSync(path, "a") / appendFileSync(path, ...) still FOLLOWS a
8556
+ // symlink an attacker pre-planted at `path` (CWE-59) — redirecting the
8557
+ // append stream to a victim file. openAppendNoFollowSync adds O_NOFOLLOW so
8558
+ // the symlink is refused (ELOOP) atomically with the open, closing the
8559
+ // caller-pre-check-then-unlink race. The primitive itself opens with a
8560
+ // numeric flags variable (not the "a" string), so its own home does not
8561
+ // match and needs no allowlist.
8562
+ scanScope: "lib",
8563
+ skipCommentLines: true,
8564
+ regex: /\bnodeFs\.(?:openSync\s*\(\s*[^,]+,\s*"a"|appendFileSync\s*\(|appendFile\s*\()/,
8565
+ allowlist: [],
8566
+ reason: "Symlink-follow append class (CWE-59). A bare nodeFs.openSync(path, \"a\") or nodeFs.appendFileSync(path, ...) follows a symlink an attacker pre-planted at the append target and redirects the log/append stream to an attacker-chosen file; a caller's pre-check-then-unlink can't close the race because the sink's own open is what follows the link. The fix routes every long-lived append open through b.atomicFile.openAppendNoFollowSync (O_WRONLY|O_APPEND|O_CREAT|O_NOFOLLOW): the file is created/appended normally but a symlink at the final component fails the open closed with ELOOP. The v0.15.16 sweep converted log-stream-local.js (active log sink) and daemon.js (detached-process stdout/stderr log). Zero allowlist — the primitive opens with a numeric flags variable, so any new nodeFs.openSync(..., \"a\") / appendFileSync / appendFile in lib/ trips this; call b.atomicFile.openAppendNoFollowSync instead.",
8567
+ },
8568
+ {
8569
+ id: "httpclient-request-idle-timeout-without-wall-clock",
8570
+ primitive: "forward BOTH timeoutMs (overall wall-clock) and idleTimeoutMs to httpClient.request — never idle-only",
8571
+ // #355 class. httpClient distinguishes timeoutMs (overall wall-clock cap)
8572
+ // from idleTimeoutMs (zero-progress cap). A consumer that maps its single
8573
+ // configured timeout to idleTimeoutMs ONLY has no overall bound: a peer
8574
+ // trickling bytes within the idle window holds the request open forever
8575
+ // (slow-loris). Every httpClient request object that sets idleTimeoutMs must
8576
+ // also set timeoutMs on the line immediately before it. This detector flags
8577
+ // an `idleTimeoutMs:` property (identifier/member value) whose preceding line
8578
+ // is NOT a `timeoutMs:` line. The allowlist is the SERVER-side / pool idle
8579
+ // timeouts — inbound connection inactivity disconnect (RFC 5321 §4.5.3.2.7
8580
+ // for SMTP, IMAP/POP3/ManageSieve/submission/TLS) and the external-db
8581
+ // connection pool — a structurally different concept (a long-lived server
8582
+ // socket has no per-request wall-clock to cap), NOT an outbound httpClient
8583
+ // request.
8584
+ scanScope: "lib",
8585
+ skipCommentLines: true,
8586
+ regex: /\n[ \t]*(?!timeoutMs\b)[A-Za-z_$][\w$]*:[^\n]*\n[ \t]*idleTimeoutMs:[ \t]*[A-Za-z_$]/,
8587
+ allowlist: [
8588
+ "lib/mail-server-imap.js", // inbound IMAP server socket idle disconnect
8589
+ "lib/mail-server-managesieve.js", // inbound ManageSieve server socket idle disconnect
8590
+ "lib/mail-server-mx.js", // inbound SMTP/MX server socket idle (RFC 5321 §4.5.3.2.7)
8591
+ "lib/mail-server-pop3.js", // inbound POP3 server socket idle disconnect
8592
+ "lib/mail-server-submission.js", // inbound submission server socket idle disconnect
8593
+ "lib/mail-server-tls.js", // inbound TLS server socket idle (re-armed post-handshake)
8594
+ ],
8595
+ reason: "Slow-trickle hold-open class (#355, CWE-400). httpClient.request takes timeoutMs (overall wall-clock cap) AND idleTimeoutMs (zero-progress cap) as DISTINCT bounds. A consumer wrapping httpClient that forwards only idleTimeoutMs leaves the request unbounded in wall-clock time: a peer that emits one byte just under the idle window keeps resetting the idle timer forever, holding the call open indefinitely. The v0.15.16 sweep set timeoutMs alongside idleTimeoutMs in every httpClient consumer (api-encrypt httpClient, the object-store shared request wrapper + azure/gcs bucket-ops, queue-sqs, the cloudwatch/otlp/webhook log sinks, the HIBP password breach check). The detector requires timeoutMs on the line immediately before every idleTimeoutMs property; the only allowlisted files are inbound SERVER socket idle timeouts and the external-db pool idle — a long-lived server connection's inactivity disconnect, not an outbound request needing a per-request wall-clock cap. A new httpClient consumer forwarding idle-only trips this — set timeoutMs too.",
8596
+ },
8549
8597
  {
8550
8598
  id: "allowlist-map-indexed-by-untrusted-key-without-hasown",
8551
8599
  primitive: "Object.prototype.hasOwnProperty.call(MAP, key) — proto-shadow-safe allowlist membership",
@@ -0,0 +1,200 @@
1
+ "use strict";
2
+ // #343: externalDb.transaction (and the b.outbox built on it) silently
3
+ // non-atomic on a stateless / autocommit-per-statement adapter.
4
+ //
5
+ // transaction(fn) defaults its tx verbs to query(client,"BEGIN") /
6
+ // query(client,"COMMIT") when the adapter supplies no beginTx/commit/
7
+ // rollback. On a stateless adapter — connect() returns a sentinel and
8
+ // every query() is an independent round-trip (e.g. an HTTP bridge to
9
+ // Cloudflare D1) — BEGIN, the body statements, and COMMIT each land on a
10
+ // DIFFERENT session: no isolation, no rollback. Yet the call resolves, so
11
+ // the consumer believes the block was atomic. b.outbox is built entirely
12
+ // on this (enqueue requires the txClient), so its dual-write guarantee is
13
+ // void on such a backend.
14
+ //
15
+ // FIX: a backend declares supportsTransactions:false (the stateless
16
+ // declaration). transaction() and outbox.create() then REFUSE LOUDLY with
17
+ // a typed error (fail closed) instead of running BEGIN/COMMIT as separate
18
+ // no-ops and resolving.
19
+ //
20
+ // RED on the buggy tree: transaction(fn) runs (BEGIN/COMMIT as independent
21
+ // no-op round-trips) and RESOLVES; outbox.create() succeeds. GREEN after
22
+ // the fix: both throw NON_ATOMIC_BACKEND / outbox/non-atomic-backend.
23
+
24
+ var helpers = require("../helpers");
25
+ var b = helpers.b;
26
+ var check = helpers.check;
27
+
28
+ // A faithful STATELESS adapter: connect() returns a fresh sentinel each
29
+ // call, every query() is an independent round-trip against a shared store,
30
+ // and there is NO beginTx/commit/rollback hook. BEGIN/COMMIT/ROLLBACK are
31
+ // accepted as harmless no-ops (exactly what an autocommit bridge does).
32
+ function _makeStatelessAdapter() {
33
+ var store = {};
34
+ var connectCount = 0;
35
+ var sawBegin = false;
36
+ return {
37
+ sawBegin: function () { return sawBegin; },
38
+ connect: async function () { connectCount += 1; return { sentinel: connectCount }; },
39
+ query: async function (_client, sql, params) {
40
+ if (/^\s*(BEGIN|COMMIT|ROLLBACK)/i.test(sql)) { sawBegin = true; return { rows: [], rowCount: 0 }; }
41
+ if (/^INSERT INTO kv/i.test(sql)) { store[params[0]] = params[1]; return { rows: [], rowCount: 1 }; }
42
+ if (/^SELECT/i.test(sql)) {
43
+ var v = store[params && params[0]];
44
+ return v === undefined ? { rows: [], rowCount: 0 } : { rows: [{ value: v }], rowCount: 1 };
45
+ }
46
+ return { rows: [], rowCount: 0 };
47
+ },
48
+ close: async function () {},
49
+ };
50
+ }
51
+
52
+ async function run() {
53
+ // ---- transaction() refuses a stateless backend ----
54
+ b.externalDb._resetForTest();
55
+ var stateless = _makeStatelessAdapter();
56
+ b.externalDb.init({
57
+ backends: {
58
+ d1: {
59
+ dialect: "sqlite",
60
+ connect: stateless.connect,
61
+ query: stateless.query,
62
+ close: stateless.close,
63
+ supportsTransactions: false, // stateless / autocommit-per-statement declaration
64
+ },
65
+ },
66
+ });
67
+
68
+ var threw = null;
69
+ var bodyRan = false;
70
+ try {
71
+ await b.externalDb.transaction(async function (tx) {
72
+ bodyRan = true;
73
+ await tx.query("INSERT INTO kv (id, value) VALUES ($1, $2)", ["k", "v"]);
74
+ });
75
+ } catch (e) { threw = e; }
76
+ check("#343 transaction() refuses a stateless backend (typed NON_ATOMIC_BACKEND)",
77
+ threw && /NON_ATOMIC_BACKEND/.test(threw.code || ""));
78
+ check("#343 transaction() refuses BEFORE running the body / any BEGIN",
79
+ bodyRan === false && stateless.sawBegin() === false);
80
+
81
+ // write.transaction routes through transaction() — must refuse too.
82
+ var threwWrite = null;
83
+ try {
84
+ await b.externalDb.write.transaction(async function (tx) { await tx.query("SELECT 1", []); });
85
+ } catch (e) { threwWrite = e; }
86
+ check("#343 write.transaction() also refuses a stateless backend",
87
+ threwWrite && /NON_ATOMIC_BACKEND/.test(threwWrite.code || ""));
88
+
89
+ // supportsTransactions probe reflects the declaration.
90
+ check("#343 supportsTransactions() probe returns false for the stateless backend",
91
+ b.externalDb.supportsTransactions() === false);
92
+
93
+ // ---- outbox.create() refuses a stateless backend ----
94
+ var outboxThrew = null;
95
+ try {
96
+ b.outbox.create({
97
+ externalDb: b.externalDb,
98
+ table: "outbox_events",
99
+ publisher: async function () {},
100
+ audit: false,
101
+ });
102
+ } catch (e) { outboxThrew = e; }
103
+ check("#343 outbox.create() refuses a stateless backend (typed error)",
104
+ outboxThrew && /non-atomic-backend/.test(outboxThrew.code || ""));
105
+
106
+ // A custom externalDb object that declares supportsTransactions:false is
107
+ // refused identically (outbox accepts the namespace OR a faithful object).
108
+ var customOutboxThrew = null;
109
+ try {
110
+ b.outbox.create({
111
+ externalDb: {
112
+ dialect: "sqlite",
113
+ query: async function () { return { rows: [] }; },
114
+ transaction: async function (fn) { return fn({ query: async function () { return { rows: [] }; } }); },
115
+ supportsTransactions: false,
116
+ },
117
+ table: "outbox_events",
118
+ publisher: async function () {},
119
+ audit: false,
120
+ });
121
+ } catch (e) { customOutboxThrew = e; }
122
+ check("#343 outbox.create() refuses a custom externalDb declaring supportsTransactions:false",
123
+ customOutboxThrew && /non-atomic-backend/.test(customOutboxThrew.code || ""));
124
+
125
+ // ---- no regression: a STATEFUL backend (the historical default) still works ----
126
+ b.externalDb._resetForTest();
127
+ var stateful = helpers._makeFakeDriver();
128
+ b.externalDb.init({
129
+ backends: { main: { connect: stateful.connect, query: stateful.query, close: stateful.close, ping: stateful.ping } },
130
+ });
131
+ var statefulOk = false;
132
+ var got = await b.externalDb.transaction(async function (tx) {
133
+ await tx.query("INSERT INTO kv (id, value) VALUES ($1, $2)", ["a", "1"]);
134
+ var r = await tx.query("SELECT id, value FROM kv WHERE id = $1", ["a"]);
135
+ statefulOk = r.rowCount === 1;
136
+ return r.rows[0];
137
+ });
138
+ check("#343 a stateful backend (no flag, no hooks) still runs transactions",
139
+ statefulOk && got && got.value === "1");
140
+ check("#343 supportsTransactions() probe returns true for a stateful backend",
141
+ b.externalDb.supportsTransactions() === true);
142
+ check("#343 outbox.create() succeeds on a stateful backend",
143
+ (function () {
144
+ try {
145
+ b.outbox.create({ externalDb: b.externalDb, table: "outbox_events",
146
+ publisher: async function () {}, audit: false });
147
+ return true;
148
+ } catch (_e) { return false; }
149
+ })());
150
+
151
+ // ---- an explicit supportsTransactions:true (stateful operator assertion) is permitted ----
152
+ b.externalDb._resetForTest();
153
+ var asserted = helpers._makeFakeDriver();
154
+ b.externalDb.init({
155
+ backends: {
156
+ mainAsserted: {
157
+ connect: asserted.connect,
158
+ query: asserted.query,
159
+ close: asserted.close,
160
+ ping: asserted.ping,
161
+ supportsTransactions: true, // operator asserts the default BEGIN/COMMIT runs on a stateful client
162
+ },
163
+ },
164
+ });
165
+ check("#343 supportsTransactions:true is honored (transaction not refused)",
166
+ b.externalDb.supportsTransactions() === true);
167
+
168
+ // ---- a stateless adapter declaring supportsTransactions:false is refused even WITH a batch hook ----
169
+ // (batch is a separate static-statement atomic path; it cannot make an
170
+ // interactive transaction(fn) safe, so it does NOT flip the capability.)
171
+ b.externalDb._resetForTest();
172
+ var batchAdapter = _makeStatelessAdapter();
173
+ b.externalDb.init({
174
+ backends: {
175
+ d1batch: {
176
+ dialect: "sqlite",
177
+ connect: batchAdapter.connect,
178
+ query: batchAdapter.query,
179
+ close: batchAdapter.close,
180
+ supportsTransactions: false,
181
+ batch: async function () { return; },
182
+ },
183
+ },
184
+ });
185
+ var batchThrew = null;
186
+ try {
187
+ await b.externalDb.transaction(async function (tx) { await tx.query("SELECT 1", []); });
188
+ } catch (e) { batchThrew = e; }
189
+ check("#343 a stateless adapter with only a batch hook still refuses interactive transaction()",
190
+ batchThrew && /NON_ATOMIC_BACKEND/.test(batchThrew.code || ""));
191
+
192
+ b.externalDb._resetForTest();
193
+ console.log("OK — externalDb non-atomic backend refusal (#343)");
194
+ }
195
+
196
+ module.exports = { run: run };
197
+ if (require.main === module) {
198
+ run().then(function () { process.exit(0); })
199
+ .catch(function (err) { process.stderr.write(String(err && err.stack || err) + "\n"); process.exit(1); });
200
+ }