@blamejs/core 0.18.6 → 0.18.8

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/lib/crypto.js CHANGED
@@ -98,8 +98,56 @@ function hash(data, algorithm, outputLength) {
98
98
  return nodeCrypto.createHash(algorithm, opts).update(data).digest();
99
99
  }
100
100
 
101
+ // HMAC hash allowlist — SHA-2 + SHA-3 families. SHA-1 / MD5 are refused: they
102
+ // are collision-weak and no legitimate scheme needs a new one. External wire
103
+ // schemes (Stripe / Tailscale webhooks require HMAC-SHA256) fix the algorithm,
104
+ // so SHA-2 is admitted for interop even though the framework's own default is
105
+ // the PQC-first SHA3-512.
106
+ var HMAC_ALGS = {
107
+ "sha256": 1, "sha384": 1, "sha512": 1,
108
+ "sha3-256": 1, "sha3-384": 1, "sha3-512": 1,
109
+ };
110
+
111
+ /**
112
+ * @primitive b.crypto.hmac
113
+ * @signature b.crypto.hmac(key, data, algorithm?)
114
+ * @since 0.18.8
115
+ * @status stable
116
+ * @related b.crypto.timingSafeEqual, b.crypto.sha3Hash
117
+ *
118
+ * Lowercase-hex HMAC of `data` keyed by `key`. The `algorithm` defaults to the
119
+ * framework's PQC-first SHA3-512 — call `b.crypto.hmac(key, data)` for keyed
120
+ * integrity (webhook signatures, request-auth tags, audit-chain links) and it
121
+ * is strong by default. Pass an explicit weaker algorithm ONLY to interop with
122
+ * an external scheme that fixes it — e.g. Stripe / Tailscale webhook signatures
123
+ * require `"sha256"`. The algorithm is validated against a SHA-2 / SHA-3
124
+ * allowlist, so a typo or a broken choice (SHA-1 / MD5) throws at the entry tier
125
+ * rather than silently signing under a surprise hash. `key` and `data` accept a
126
+ * Buffer or string. Compare tags with `b.crypto.timingSafeEqual`, never `==`.
127
+ *
128
+ * @example
129
+ * var tag = b.crypto.hmac("shared-secret", "POST /webhook|123");
130
+ * // → SHA3-512 HMAC (128 hex chars) — the PQC-first default
131
+ * var stripe = b.crypto.hmac(process.env.WHSEC, ts + "." + rawBody, "sha256");
132
+ * // → HMAC-SHA256 (64 hex chars) — explicit opt-down for external interop
133
+ */
101
134
  function hmac(key, data, algorithm) {
102
- return nodeCrypto.createHmac(algorithm, key).update(data).digest("hex");
135
+ var alg = algorithm === undefined ? "sha3-512" : algorithm;
136
+ if (typeof alg !== "string" || !Object.prototype.hasOwnProperty.call(HMAC_ALGS, alg)) {
137
+ throw new TypeError(
138
+ "crypto.hmac: algorithm must be one of " + Object.keys(HMAC_ALGS).join(", ") +
139
+ " (SHA-1 / MD5 are refused), got " +
140
+ (alg === null ? "null" : JSON.stringify(alg)));
141
+ }
142
+ if (typeof key !== "string" && !Buffer.isBuffer(key)) {
143
+ throw new TypeError("crypto.hmac: key must be a Buffer or string, got " +
144
+ (key === null ? "null" : typeof key));
145
+ }
146
+ if (typeof data !== "string" && !Buffer.isBuffer(data)) {
147
+ throw new TypeError("crypto.hmac: data must be a Buffer or string, got " +
148
+ (data === null ? "null" : typeof data));
149
+ }
150
+ return nodeCrypto.createHmac(alg, key).update(data).digest("hex");
103
151
  }
104
152
 
105
153
  /**
@@ -480,7 +528,7 @@ function generateKeyPair(algorithm, options) {
480
528
  * @primitive b.crypto.timingSafeEqual
481
529
  * @signature b.crypto.timingSafeEqual(a, b)
482
530
  * @since 0.1.0
483
- * @related b.crypto.hmacSha3
531
+ * @related b.crypto.hmac
484
532
  *
485
533
  * Constant-time equality comparison. Accepts only Buffer or string
486
534
  * inputs — non-string non-Buffer arguments throw at the entry tier so
@@ -493,7 +541,7 @@ function generateKeyPair(algorithm, options) {
493
541
  * where a timing oracle would leak bits.
494
542
  *
495
543
  * @example
496
- * var expected = b.crypto.hmacSha3("server-key", "payload");
544
+ * var expected = b.crypto.hmac("server-key", "payload");
497
545
  * var supplied = "ab12...e9"; // from request header / body
498
546
  * var ok = b.crypto.timingSafeEqual(supplied, expected);
499
547
  * // → true when bytes match, false otherwise (no early exit on mismatch)
@@ -531,7 +579,7 @@ function timingSafeEqual(a, b) {
531
579
  * @primitive b.crypto.sha3Hash
532
580
  * @signature b.crypto.sha3Hash(data)
533
581
  * @since 0.1.0
534
- * @related b.crypto.hmacSha3, b.crypto.kdf, b.crypto.hashFile
582
+ * @related b.crypto.hmac, b.crypto.kdf, b.crypto.hashFile
535
583
  *
536
584
  * Returns the lowercase-hex SHA3-512 digest of the input. SHA3-512 is
537
585
  * the framework's default hash — collision-resistant, sponge-based,
@@ -545,23 +593,6 @@ function timingSafeEqual(a, b) {
545
593
  */
546
594
  function sha3Hash(data) { return hash(data, "sha3-512").toString("hex"); }
547
595
 
548
- /**
549
- * @primitive b.crypto.hmacSha3
550
- * @signature b.crypto.hmacSha3(key, data)
551
- * @since 0.1.0
552
- * @related b.crypto.sha3Hash, b.crypto.timingSafeEqual
553
- *
554
- * Returns the lowercase-hex HMAC-SHA3-512 of `data` keyed by `key`.
555
- * Use for keyed integrity checks (webhook signatures, request
556
- * authentication tags, audit-chain links). Pair with
557
- * `b.crypto.timingSafeEqual` when comparing supplied vs computed tags.
558
- *
559
- * @example
560
- * var tag = b.crypto.hmacSha3("shared-secret", "POST /webhook|123");
561
- * // → "8f1c...d4e2" (128 hex chars, HMAC-SHA3-512 = 64 bytes)
562
- */
563
- function hmacSha3(key, data) { return hmac(key, data, "sha3-512"); }
564
-
565
596
  // (SHA-1 is intentionally NOT exported from b.crypto. The framework's
566
597
  // only legitimate SHA-1 use is the HaveIBeenPwned k-anonymity API in
567
598
  // lib/auth/password.js, which imports lib/framework-sha1-hibp.js
@@ -2364,8 +2395,8 @@ function selfTest(opts) {
2364
2395
  });
2365
2396
  record("HMAC-SHA3-512 determinism", function () {
2366
2397
  var k = Buffer.from("self-test-hmac-key", "utf8");
2367
- assert(timingSafeEqual(hmacSha3(k, "abc"), hmacSha3(k, "abc")), "HMAC-SHA3-512 is not deterministic");
2368
- assert(!timingSafeEqual(hmacSha3(k, "abc"), hmacSha3(k, "abd")), "HMAC-SHA3-512 collided on distinct inputs");
2398
+ assert(timingSafeEqual(hmac(k, "abc"), hmac(k, "abc")), "HMAC-SHA3-512 is not deterministic");
2399
+ assert(!timingSafeEqual(hmac(k, "abc"), hmac(k, "abd")), "HMAC-SHA3-512 collided on distinct inputs");
2369
2400
  });
2370
2401
  record("XChaCha20-Poly1305 round-trip + tamper-detect", function () {
2371
2402
  var key = generateBytes(C.BYTES.bytes(32));
@@ -2422,7 +2453,7 @@ module.exports = {
2422
2453
  selfTest: selfTest,
2423
2454
  // Hashing
2424
2455
  sha3Hash: sha3Hash,
2425
- hmacSha3: hmacSha3,
2456
+ hmac: hmac,
2426
2457
  hashFile: hashFile,
2427
2458
  hashFilesParallel: hashFilesParallel,
2428
2459
  hashStream: hashStream,
@@ -523,6 +523,13 @@ function collection(name, opts) {
523
523
  // write back. Single-row default; many-row when opts.many.
524
524
  var qFetch = db().from(name);
525
525
  _applyQuery(qFetch, query || {});
526
+ // The per-row writes below carry a WHERE (by _id), so they slip past
527
+ // db-query's unconditional-write guard. Re-assert it on the READ: an
528
+ // overflow update with no filter conditions is refused exactly like a
529
+ // real-column one, rather than silently rewriting every row.
530
+ if (typeof qFetch._hasConditions === "function" && !qFetch._hasConditions()) {
531
+ throw new Error("refusing unconditional update — call where(...) first");
532
+ }
526
533
  if (single) qFetch.limit(1);
527
534
  var rows = qFetch.all();
528
535
  for (var ri = 0; ri < rows.length; ri += 1) {
@@ -0,0 +1,347 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ /**
5
+ * @module b.localHttp
6
+ * @nav HTTP
7
+ * @title Local-daemon HTTP
8
+ * @order 118
9
+ *
10
+ * @intro
11
+ * An HTTP client for a LOCAL daemon reached over a non-network transport —
12
+ * a Unix domain socket (Docker <code>/var/run/docker.sock</code>, systemd,
13
+ * containerd, <code>tailscaled</code>), a Windows named pipe, or a
14
+ * loopback TCP port + bearer token (the sandboxed-macOS shape). Distinct
15
+ * from <code>b.httpClient</code>, which does DNS + TCP/TLS + the SSRF gate.
16
+ *
17
+ * This client is <strong>SSRF-safe by construction</strong>: a socket-path
18
+ * request never resolves DNS and never touches an IP, so it cannot be
19
+ * steered at an internal address; the loopback-TCP mode refuses any host
20
+ * that is not a loopback address. It always sets the caller-chosen
21
+ * <code>Host</code> header (many local APIs require an exact value such as
22
+ * <code>local-tailscaled.sock</code>) and NEVER sends <code>Origin</code> or
23
+ * <code>Referer</code> — the two headers a local daemon uses to reject
24
+ * drive-by / DNS-rebinding requests from a browser.
25
+ *
26
+ * Responses are size-bounded and typed: <code>{ statusCode, headers, body,
27
+ * text(), json() }</code>. The vendor glue (a tailscaled Host value,
28
+ * <code>.whois/.status</code> wrappers) belongs in the consumer, not here.
29
+ *
30
+ * @card
31
+ * SSRF-safe HTTP over a local Unix socket / Windows named pipe / loopback-TCP+token — caller-set Host, no Origin/Referer, bounded typed responses. For Docker / systemd / tailscaled-style local daemons.
32
+ */
33
+
34
+ var http = require("node:http");
35
+ var net = require("node:net");
36
+ var safeBuffer = require("./safe-buffer");
37
+ var safeJson = require("./safe-json");
38
+ var numericBounds = require("./numeric-bounds");
39
+ var validateOpts = require("./validate-opts");
40
+ var C = require("./constants");
41
+ var { defineClass } = require("./framework-error");
42
+
43
+ // Transport / timeout / response-stream failures are TRANSIENT — the local
44
+ // daemon may be briefly down, restarting, or slow, so a caller's retry policy
45
+ // should be free to retry them. Config / input / size-cap errors are permanent
46
+ // (a retry cannot fix a bad path, a non-loopback host, or an over-cap response).
47
+ var LocalHttpError = defineClass("LocalHttpError", {
48
+ permanentClassifier: function (code) {
49
+ return !(code === "local-http/request-error" ||
50
+ code === "local-http/timeout" ||
51
+ code === "local-http/response-error");
52
+ },
53
+ });
54
+
55
+ var DEFAULT_TIMEOUT_MS = C.TIME.seconds(10);
56
+ var DEFAULT_MAX_RESPONSE = C.BYTES.mib(8);
57
+ var DEFAULT_HOST_HEADER = "localhost";
58
+
59
+ // A local daemon must never see these — they are the browser-origin signals a
60
+ // daemon relies on to reject cross-site / DNS-rebinding requests, so a client
61
+ // that quietly forwarded them would defeat the daemon's own CSRF defense.
62
+ var FORBIDDEN_HEADERS = ["origin", "referer"];
63
+
64
+ // Canonicalize a host for both the loopback check and the actual connect:
65
+ // lowercase, drop a single trailing root-zone dot ("localhost." → "localhost"),
66
+ // and strip a surrounding IPv6 bracket pair ("[::1]" → "::1", the form
67
+ // http.request's `host` option expects — a bracketed literal would otherwise be
68
+ // resolved as a hostname).
69
+ function _canonHost(host) {
70
+ var h = String(host).toLowerCase().replace(/\.$/, "");
71
+ if (h.length >= 2 && h.charAt(0) === "[" && h.charAt(h.length - 1) === "]") h = h.slice(1, -1);
72
+ return h;
73
+ }
74
+
75
+ // Loopback-only guard for the TCP mode: keeps the "SSRF-safe by construction"
76
+ // property. The host MUST be a loopback IP LITERAL — net.isIP rejects every
77
+ // spelling http.request would instead send through name resolution: a hostname
78
+ // like "localhost", or a non-canonical form such as "127.001.002.003" (net.isIP
79
+ // returns 0 for it), either of which a poisoned resolver / hosts file could
80
+ // steer off-loopback. Only a canonical 127.0.0.0/8 IPv4 or ::1 is accepted.
81
+ function _isLoopbackHost(host) {
82
+ if (typeof host !== "string") return false;
83
+ var h = _canonHost(host);
84
+ var fam = net.isIP(h);
85
+ if (fam === 6) return h === "::1";
86
+ if (fam === 4) return /^127\./.test(h); // net.isIP already validated octets 0..255, no leading zeros
87
+ return false; // not an IP literal (hostnames, non-canonical spellings) → refuse
88
+ }
89
+
90
+ function _lowerHeaderKeys(headers) {
91
+ var out = {};
92
+ if (headers && typeof headers === "object") {
93
+ var keys = Object.keys(headers);
94
+ for (var i = 0; i < keys.length; i += 1) out[keys[i].toLowerCase()] = headers[keys[i]];
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /**
100
+ * @primitive b.localHttp.create
101
+ * @signature b.localHttp.create(opts)
102
+ * @since 0.18.8
103
+ * @status stable
104
+ * @related b.localHttp.request, b.httpClient
105
+ *
106
+ * Build a client bound to ONE local transport. Provide EITHER
107
+ * <code>socketPath</code> (a Unix socket path or a Windows named pipe like
108
+ * <code>\\.\pipe\name</code>) OR <code>host</code> + <code>port</code> (which
109
+ * must be a loopback address). Returns a client with <code>request</code>,
110
+ * <code>get</code>, and <code>postJson</code> — every call sends the configured
111
+ * <code>hostHeader</code> and omits <code>Origin</code>/<code>Referer</code>.
112
+ *
113
+ * @opts
114
+ * socketPath: string, // Unix socket path OR Windows named pipe (exclusive with host/port)
115
+ * host: string, // loopback IP LITERAL for the TCP+token mode (127.0.0.0/8 or ::1 — a hostname like "localhost" is refused)
116
+ * port: number, // TCP port (with host)
117
+ * hostHeader: string, // the Host header to send (default: "localhost")
118
+ * bearerToken: string, // Authorization: Bearer <token> on every request
119
+ * defaultHeaders: object, // headers merged into every request (Origin/Referer stripped)
120
+ * timeoutMs: number, // per-request timeout (default: 10s)
121
+ * maxResponseBytes: number, // response body cap; over-cap aborts (default: 8 MiB)
122
+ *
123
+ * @example
124
+ * var d = b.localHttp.create({ socketPath: "/run/tailscale/tailscaled.sock",
125
+ * hostHeader: "local-tailscaled.sock" });
126
+ * var r = await d.get("/localapi/v0/status");
127
+ * // → { statusCode: 200, headers, body, text(), json() }
128
+ */
129
+ function create(opts) {
130
+ opts = validateOpts.requireObject(opts, "localHttp.create", LocalHttpError, "local-http/bad-opts");
131
+ validateOpts(opts,
132
+ ["socketPath", "host", "port", "hostHeader", "bearerToken", "defaultHeaders", "timeoutMs", "maxResponseBytes"],
133
+ "localHttp.create");
134
+
135
+ var hasSocket = opts.socketPath !== undefined && opts.socketPath !== null;
136
+ var hasTcp = opts.host !== undefined && opts.host !== null;
137
+ if (hasSocket === hasTcp) {
138
+ throw new LocalHttpError("local-http/bad-transport",
139
+ "create: provide EXACTLY one of opts.socketPath OR opts.host+opts.port");
140
+ }
141
+ var socketPath = null;
142
+ var host = null;
143
+ var port = null;
144
+ if (hasSocket) {
145
+ validateOpts.requireNonEmptyString(opts.socketPath, "localHttp.create: opts.socketPath",
146
+ LocalHttpError, "local-http/bad-socket-path");
147
+ socketPath = opts.socketPath;
148
+ } else {
149
+ if (!_isLoopbackHost(opts.host)) {
150
+ throw new LocalHttpError("local-http/non-loopback-host",
151
+ "create: opts.host must be a loopback IP literal (127.0.0.0/8 or ::1; a hostname like 'localhost' is refused) — a non-loopback " +
152
+ "host would defeat the SSRF-safe-by-construction guarantee; use b.httpClient for network hosts");
153
+ }
154
+ numericBounds.requirePositiveFiniteInt(opts.port, "port", LocalHttpError, "local-http/bad-port");
155
+ if (opts.port > 65535) {
156
+ throw new LocalHttpError("local-http/bad-port", "create: opts.port must be 1..65535");
157
+ }
158
+ // Connect to the canonical host (unbracketed IPv6 / no trailing dot) — the
159
+ // form http.request's `host` option expects.
160
+ host = _canonHost(opts.host);
161
+ port = opts.port;
162
+ }
163
+
164
+ var hostHeader = opts.hostHeader !== undefined ? opts.hostHeader : DEFAULT_HOST_HEADER;
165
+ validateOpts.requireNonEmptyString(hostHeader, "localHttp.create: opts.hostHeader",
166
+ LocalHttpError, "local-http/bad-host-header");
167
+ validateOpts.optionalNonEmptyString(opts.bearerToken, "localHttp.create: opts.bearerToken",
168
+ LocalHttpError, "local-http/bad-token");
169
+ if (opts.defaultHeaders !== undefined && (opts.defaultHeaders === null || typeof opts.defaultHeaders !== "object")) {
170
+ throw new LocalHttpError("local-http/bad-default-headers", "create: opts.defaultHeaders must be an object");
171
+ }
172
+ numericBounds.requirePositiveFiniteIntIfPresent(opts.timeoutMs, "timeoutMs", LocalHttpError, "local-http/bad-timeout");
173
+ numericBounds.requirePositiveFiniteIntIfPresent(opts.maxResponseBytes, "maxResponseBytes", LocalHttpError, "local-http/bad-max-response");
174
+ var timeoutMs = typeof opts.timeoutMs === "number" ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;
175
+ var maxResponseBytes = typeof opts.maxResponseBytes === "number" ? opts.maxResponseBytes : DEFAULT_MAX_RESPONSE;
176
+ var defaultHeaders = _lowerHeaderKeys(opts.defaultHeaders);
177
+ FORBIDDEN_HEADERS.forEach(function (h) { delete defaultHeaders[h]; });
178
+
179
+ function request(ropts) {
180
+ return new Promise(function (resolve, reject) {
181
+ var r = ropts || {};
182
+ if (typeof r.path !== "string" || r.path.length === 0 || r.path.charAt(0) !== "/") {
183
+ reject(new LocalHttpError("local-http/bad-path", "request: opts.path must be a string beginning with '/'"));
184
+ return;
185
+ }
186
+ // Reject a raw space / control char in the path up front, as a typed error
187
+ // — http.request would otherwise throw a synchronous ERR_UNESCAPED_CHARACTERS
188
+ // inside the executor. Callers percent-encode such characters.
189
+ var _pathBad = false;
190
+ for (var _pi = 0; _pi < r.path.length; _pi += 1) {
191
+ var _pc = r.path.charCodeAt(_pi);
192
+ if (_pc <= 0x20 || _pc === 0x7f) { _pathBad = true; break; }
193
+ }
194
+ if (_pathBad) {
195
+ reject(new LocalHttpError("local-http/bad-path", "request: opts.path contains an unescaped space or control character"));
196
+ return;
197
+ }
198
+ var method = typeof r.method === "string" ? r.method.toUpperCase() : "GET";
199
+ // Merge headers: defaults, then per-request, then FORCE Host + strip
200
+ // Origin/Referer + apply the bearer token. setHost:false keeps Node from
201
+ // synthesising a Host we don't control.
202
+ var headers = Object.assign({}, defaultHeaders, _lowerHeaderKeys(r.headers));
203
+ FORBIDDEN_HEADERS.forEach(function (h) { delete headers[h]; });
204
+ headers.host = hostHeader;
205
+ if (opts.bearerToken !== undefined && headers.authorization === undefined) {
206
+ headers.authorization = "Bearer " + opts.bearerToken;
207
+ }
208
+ var bodyBuf = null;
209
+ if (r.body !== undefined && r.body !== null) {
210
+ bodyBuf = Buffer.isBuffer(r.body) ? r.body
211
+ : typeof r.body === "string" ? Buffer.from(r.body, "utf8")
212
+ : null;
213
+ if (!bodyBuf) { reject(new LocalHttpError("local-http/bad-body", "request: opts.body must be a Buffer or string")); return; }
214
+ headers["content-length"] = String(bodyBuf.length);
215
+ }
216
+ var reqOpts = {
217
+ method: method,
218
+ path: r.path,
219
+ headers: headers,
220
+ setHost: false, // we own the Host header
221
+ };
222
+ if (socketPath !== null) reqOpts.socketPath = socketPath;
223
+ else { reqOpts.host = host; reqOpts.port = port; }
224
+
225
+ var settled = false;
226
+ var deadline = null;
227
+ function fail(err) {
228
+ /* c8 ignore next -- re-entry guard: fail() settles once; a second failure source racing the first (a destroy-induced socket error vs. a size/timeout abort) is not deterministically forceable */
229
+ if (!settled) {
230
+ settled = true;
231
+ clearTimeout(deadline);
232
+ /* c8 ignore next -- defensive: req.destroy() on an already-closed socket does not throw in practice */
233
+ try { req.destroy(); } catch (_d) { /* already gone */ }
234
+ reject(err);
235
+ }
236
+ }
237
+
238
+ // This is the LOCAL-transport client — a socketPath (unix socket / named
239
+ // pipe) or a loopback TCP peer, never a network host. It deliberately does
240
+ // NOT route through b.httpClient (DNS + TCP/TLS + the SSRF gate): that is the
241
+ // exact surface it exists to bypass safely (a socket-path request can't be
242
+ // steered at an IP).
243
+ var req = http.request(reqOpts, function (res) { // allow:raw-outbound-http-framework-internal — local socket / loopback transport, never a network host
244
+ var collector = safeBuffer.boundedChunkCollector({
245
+ maxBytes: maxResponseBytes,
246
+ errorClass: LocalHttpError,
247
+ sizeCode: "local-http/response-too-large",
248
+ sizeMessage: "response body exceeded maxResponseBytes (" + maxResponseBytes + ")",
249
+ });
250
+ res.on("data", function (chunk) {
251
+ /* c8 ignore next -- data-after-settled race guard: once fail()/end has settled + destroyed the socket, a late buffered 'data' is not deterministically forceable */
252
+ if (settled) return;
253
+ try { collector.push(chunk); } catch (e) { fail(e); }
254
+ });
255
+ res.on("end", function () {
256
+ if (settled) return;
257
+ settled = true;
258
+ clearTimeout(deadline);
259
+ var body = collector.result();
260
+ resolve({
261
+ statusCode: res.statusCode,
262
+ headers: res.headers,
263
+ body: body,
264
+ text: function () { return body.toString("utf8"); },
265
+ json: function () { return safeJson.parse(body.toString("utf8"), { maxBytes: maxResponseBytes }); },
266
+ });
267
+ });
268
+ /* c8 ignore next -- response-stream 'error' after headers (mid-body transport fault) is not deterministically forceable; the connect/transport path is covered by req.on('error') below */
269
+ res.on("error", function (e) { fail(new LocalHttpError("local-http/response-error", "request: response stream error: " + ((e && e.message) || String(e)))); });
270
+ });
271
+ req.on("error", function (e) {
272
+ fail(new LocalHttpError("local-http/request-error",
273
+ /* c8 ignore next -- String(e) fallback: a transport error always carries a message */
274
+ "request: transport error: " + ((e && e.message) || String(e))));
275
+ });
276
+ // End-to-end deadline: a single timer measured from request start bounds
277
+ // the TOTAL duration. A socket-idle timeout (http's req.setTimeout) would
278
+ // reset on every byte, so a slow-drip response — a byte every few ms, never
279
+ // finishing — would never trip it; the fixed deadline always fires first.
280
+ deadline = setTimeout(function () {
281
+ fail(new LocalHttpError("local-http/timeout", "request: exceeded the " + timeoutMs + "ms deadline"));
282
+ }, timeoutMs);
283
+ if (bodyBuf) req.write(bodyBuf);
284
+ req.end();
285
+ });
286
+ }
287
+
288
+ function get(path, gopts) {
289
+ return request(Object.assign({}, gopts, { method: "GET", path: path }));
290
+ }
291
+ // async so a non-serializable obj (circular / BigInt) surfaces as a rejected
292
+ // promise the caller can .catch(), not a synchronous throw from JSON.stringify.
293
+ async function postJson(path, obj, popts) {
294
+ var headers = Object.assign({}, (popts && popts.headers) || {}, { "content-type": "application/json" });
295
+ return request(Object.assign({}, popts, { method: "POST", path: path, headers: headers, body: JSON.stringify(obj) }));
296
+ }
297
+
298
+ return { request: request, get: get, postJson: postJson };
299
+ }
300
+
301
+ /**
302
+ * @primitive b.localHttp.request
303
+ * @signature b.localHttp.request(opts)
304
+ * @since 0.18.8
305
+ * @status stable
306
+ * @related b.localHttp.create
307
+ *
308
+ * One-shot convenience: build a client from the transport fields and issue a
309
+ * single request. `opts` carries both the `create` transport fields
310
+ * (socketPath / host+port / hostHeader / bearerToken / timeoutMs /
311
+ * maxResponseBytes) and the per-request fields (method / path / headers /
312
+ * body). Resolves the same typed response as the client's `request`.
313
+ *
314
+ * @opts
315
+ * socketPath: string, // Unix socket path OR Windows named pipe (exclusive with host/port)
316
+ * host: string, // loopback IP LITERAL for the TCP+token mode (127.0.0.0/8 or ::1 — a hostname like "localhost" is refused)
317
+ * port: number, // TCP port (with host)
318
+ * hostHeader: string, // the Host header to send (default: "localhost")
319
+ * bearerToken: string, // Authorization: Bearer <token> on the request
320
+ * defaultHeaders: object, // headers merged into the request (Origin/Referer stripped)
321
+ * timeoutMs: number, // request timeout (default: 10s)
322
+ * maxResponseBytes: number, // response body cap; over-cap aborts (default: 8 MiB)
323
+ * method: string, // HTTP method (default: "GET")
324
+ * path: string, // request path (must start with "/")
325
+ * headers: object, // per-request headers (merged over defaultHeaders)
326
+ * body: Buffer | string, // request body
327
+ *
328
+ * @example
329
+ * var r = await b.localHttp.request({
330
+ * socketPath: "/var/run/docker.sock", hostHeader: "localhost",
331
+ * path: "/v1.44/containers/json",
332
+ * });
333
+ */
334
+ var TRANSPORT_FIELDS = ["socketPath", "host", "port", "hostHeader", "bearerToken", "defaultHeaders", "timeoutMs", "maxResponseBytes"];
335
+
336
+ function request(opts) {
337
+ opts = validateOpts.requireObject(opts, "localHttp.request", LocalHttpError, "local-http/bad-opts");
338
+ var createOpts = {};
339
+ TRANSPORT_FIELDS.forEach(function (k) { if (opts[k] !== undefined) createOpts[k] = opts[k]; });
340
+ return create(createOpts).request({ method: opts.method, path: opts.path, headers: opts.headers, body: opts.body });
341
+ }
342
+
343
+ module.exports = {
344
+ create: create,
345
+ request: request,
346
+ LocalHttpError: LocalHttpError,
347
+ };
@@ -517,6 +517,13 @@ function create(opts) {
517
517
  idleTimeoutMs: idleTimeoutMs,
518
518
  clearFields: ["pendingLiteral", "pendingAuth"],
519
519
  drain: _drainBuffer,
520
+ // Post-STARTTLS idle teardown MUST be TLS-aware — upgradeSocket strips the
521
+ // plain-socket timeout handler, so without this the upgraded session would
522
+ // never idle out (RFC 5804 BYE) and could stay open indefinitely.
523
+ onTimeout: function (tlsSocket) {
524
+ _writeBye(tlsSocket, "Idle timeout");
525
+ _close(tlsSocket);
526
+ },
520
527
  onSecure: function (tlsSocket) {
521
528
  _emit("mail.server.managesieve.starttls_upgraded",
522
529
  { connectionId: state.id });
@@ -414,6 +414,15 @@ function upgradeSocket(opts) {
414
414
  // post-TLS dispatcher and execute as if they had been sent over
415
415
  // the authenticated channel.
416
416
  plainSocket.removeAllListeners("data");
417
+ // The pre-upgrade plain-socket idle timer's "timeout" listener wrote a PLAINTEXT
418
+ // idle reply and closed the connection (every line-protocol server arms
419
+ // socket.setTimeout + a "timeout" handler at connect). Left attached, it fires on
420
+ // the wrapped socket after the handshake and injects cleartext into the now-
421
+ // encrypted stream — the peer sees a TLS decode error / reset instead of the TLS-
422
+ // aware onTimeout replying encrypted. Strip the plaintext listener; the TLSSocket
423
+ // arms its own idle timer + a TLS-aware onTimeout IMMEDIATELY below (before the
424
+ // handshake), so the reply is encrypted AND the handshake window stays bounded.
425
+ plainSocket.removeAllListeners("timeout");
417
426
  // Pause so the kernel TCP buffer doesn't drain into the old
418
427
  // handler in the window before TLSSocket attaches its own.
419
428
  if (typeof plainSocket.pause === "function") {
@@ -425,14 +434,21 @@ function upgradeSocket(opts) {
425
434
  secureContext: opts.secureContext,
426
435
  });
427
436
 
437
+ // Arm the TLS-aware idle timeout NOW, before the handshake completes — a peer
438
+ // that sends STARTTLS then withholds / trickles the ClientHello must not hold the
439
+ // connection (and its tracked rate-limit slot) open indefinitely. The TLSSocket
440
+ // owns the timer for the whole post-upgrade lifetime; onTimeout closes it (its
441
+ // reply serializes encrypted once secure). Arming only on "secure" left the
442
+ // handshake interval unbounded.
443
+ if (idleTimeoutMs !== undefined && typeof tlsSocket.setTimeout === "function") {
444
+ try { tlsSocket.setTimeout(idleTimeoutMs); }
445
+ catch (_e) { /* tolerate */ }
446
+ }
447
+ if (typeof opts.onTimeout === "function") {
448
+ tlsSocket.on("timeout", function () { opts.onTimeout(tlsSocket); });
449
+ }
450
+
428
451
  tlsSocket.on("secure", function () {
429
- if (idleTimeoutMs !== undefined && typeof tlsSocket.setTimeout === "function") {
430
- try { tlsSocket.setTimeout(idleTimeoutMs); }
431
- catch (_e) { /* tolerate */ }
432
- }
433
- if (typeof opts.onTimeout === "function") {
434
- tlsSocket.on("timeout", function () { opts.onTimeout(tlsSocket); });
435
- }
436
452
  try { opts.onSecure(tlsSocket); }
437
453
  catch (e) { try { opts.onError(e); } catch (_e) { /* drop-silent */ } }
438
454
  });