@blamejs/core 0.15.15 → 0.15.17

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.
@@ -223,12 +223,39 @@ function _validSid(sid) {
223
223
  SID_RE.test(sid);
224
224
  }
225
225
 
226
- function _writeRejection(res, code, body) {
226
+ // _writeRejection emit a protocol-level rejection body.
227
+ //
228
+ // On an ESTABLISHED per-session encrypted channel the rejection MUST
229
+ // travel inside the session envelope, exactly like a successful
230
+ // response: a client on a keyed channel that sends a stale / replayed /
231
+ // malformed request would otherwise learn, in cleartext, which check
232
+ // failed over an otherwise-encrypted channel. The middleware stamps
233
+ // `req.apiEncryptRejectEncode` the moment a session is resolved with a
234
+ // valid session key; when present, the body is wrapped through the same
235
+ // response-encryption path successful responses use. Absent it (a
236
+ // pre-session handshake error, where no session context exists yet, or
237
+ // per-request mode) the body falls back to plaintext.
238
+ function _writeRejection(req, res, code, body, opts) {
227
239
  if (res.headersSent || res.writableEnded) return;
228
- if (typeof res.writeHead === "function") {
229
- res.writeHead(code, { "Content-Type": "application/json" });
230
- res.end(JSON.stringify(body));
240
+ if (typeof res.writeHead !== "function") return;
241
+ var out = body;
242
+ // opts.plaintext forces a cleartext body even on an established channel.
243
+ // Used for the generic "encrypted-payload-rejected" refusals that DO NOT
244
+ // delete the session (the monotonic-counter replay and the atomic-claim
245
+ // loss): riding those on the session envelope would emit a response _ctr
246
+ // the client tracks as consumed, but those paths return before the
247
+ // server persists responsesEmitted — so the next genuine response reuses
248
+ // that _ctr and the client refuses it as a replay, bricking the session.
249
+ // The body is already generic (no session-lifecycle reason leaks the way
250
+ // session-expired / rotation-required would), so plaintext here costs no
251
+ // meaningful confidentiality.
252
+ var encode = (!opts || !opts.plaintext) && req && req.apiEncryptRejectEncode;
253
+ if (typeof encode === "function") {
254
+ try { out = encode(body); }
255
+ catch (_e) { out = body; } // encryption failed → fall back to plaintext rather than hang
231
256
  }
257
+ res.writeHead(code, { "Content-Type": "application/json" });
258
+ res.end(JSON.stringify(out));
232
259
  }
233
260
 
234
261
  // ---- Server-side middleware ----
@@ -458,6 +485,26 @@ function create(opts) {
458
485
  return encrypted;
459
486
  }
460
487
 
488
+ // _installRejectEncoder — stamp req with a function that wraps a
489
+ // protocol-level rejection body in the SAME session envelope a
490
+ // successful response uses, so an error emitted on an established
491
+ // per-session encrypted channel does not leak (in cleartext) which
492
+ // check the request tripped. Called the moment a session is resolved
493
+ // with a valid session key, BEFORE the expiry / rotation / replay
494
+ // gates that fire on a keyed channel. The rejection rides a fresh
495
+ // response counter (sid bound, strictly above the session's last
496
+ // emitted response) so the client's monotonic _ctr check still holds.
497
+ // The session-deleting rejections (expired / rotation) and the
498
+ // post-claim tag-mismatch ride this encoder; the two generic surviving
499
+ // rejections (monotonic-counter replay, atomic-claim loss) opt out via
500
+ // _writeRejection({ plaintext: true }) because they return before the
501
+ // consumed counter is persisted — see _writeRejection.
502
+ function _installRejectEncoder(req, sessionKey, sid, responseCtr) {
503
+ req.apiEncryptRejectEncode = function (body) {
504
+ return _encodeEnvelope(body, sessionKey, { sid: sid, responseCtr: responseCtr });
505
+ };
506
+ }
507
+
461
508
  // _wrapResJson — install res.json that encrypts the response with the
462
509
  // session key. In per-request mode the response is `{ _ct }`; in
463
510
  // per-session mode it carries `{ _ct, _sid, _ctr }` so the client can
@@ -507,18 +554,18 @@ function create(opts) {
507
554
  var body = req.body;
508
555
  if (!body || typeof body !== "object") {
509
556
  _emitFailure(req, "shape");
510
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
557
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
511
558
  }
512
559
 
513
560
  var now = Date.now();
514
561
  var ct = body._ct, ts = body._ts;
515
562
  if (typeof ct !== "string" || typeof ts !== "number") {
516
563
  _emitFailure(req, "shape");
517
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
564
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
518
565
  }
519
566
  if (Math.abs(now - ts) > replayWindowMs) {
520
567
  _emitFailure(req, "stale");
521
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
568
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
522
569
  }
523
570
 
524
571
  // Per-request OR per-session bootstrap path: shape includes _ek + _nonce.
@@ -540,25 +587,25 @@ function create(opts) {
540
587
  try { freshNonce = await store.checkAndInsert(nonceHash, expireAt); }
541
588
  catch (_e) {
542
589
  _emitFailure(req, "nonce-store-error");
543
- return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "nonce-store-unavailable" });
590
+ return _writeRejection(req, res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "nonce-store-unavailable" });
544
591
  }
545
592
  if (!freshNonce) {
546
593
  _emitFailure(req, "replay");
547
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
594
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
548
595
  }
549
596
  sessionKey = _decryptEkToSessionKey(ek);
550
597
  if (!sessionKey) {
551
598
  _emitFailure(req, "tag");
552
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
599
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
553
600
  }
554
601
  if (keying === "per-session") {
555
602
  if (!_validSid(sid)) {
556
603
  _emitFailure(req, "shape");
557
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
604
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
558
605
  }
559
606
  if (!numericBounds.isNonNegativeFiniteInt(ctr)) {
560
607
  _emitFailure(req, "shape");
561
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
608
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
562
609
  }
563
610
  // Bootstrap a new session row keyed by sid. responsesEmitted is
564
611
  // set to 1 (this bootstrap emits one response) BEFORE the store
@@ -579,7 +626,7 @@ function create(opts) {
579
626
  try { await sessionStore.set(sid, session, { ttlMs: sessionTtlMs }); }
580
627
  catch (_e) {
581
628
  _emitFailure(req, "session-store-error");
582
- return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session-store-unavailable" });
629
+ return _writeRejection(req, res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session-store-unavailable" });
583
630
  }
584
631
  _emitObs("apiEncrypt.session.created", 1, { mode: "per-session" });
585
632
  _emitSessionAudit("apiEncrypt.session.created", {
@@ -588,28 +635,56 @@ function create(opts) {
588
635
  requestId: req.requestId || null,
589
636
  });
590
637
  sessionCtx = { sid: sid, responseCtr: 1 };
638
+ // Session now established — a post-bootstrap rejection (e.g. the
639
+ // final decrypt below) rides the session envelope under the same
640
+ // counter the success response would have used.
641
+ _installRejectEncoder(req, sessionKey, sid, 1);
591
642
  }
592
643
  } else if (keying === "per-session" &&
593
644
  typeof sid === "string" && typeof ctr === "number") {
594
645
  // ---- Per-session subsequent-request path ----
595
646
  if (!_validSid(sid)) {
596
647
  _emitFailure(req, "shape");
597
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
648
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
598
649
  }
599
650
  if (!numericBounds.isNonNegativeFiniteInt(ctr)) {
600
651
  _emitFailure(req, "shape");
601
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
652
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
602
653
  }
603
654
  try { session = await sessionStore.get(sid); }
604
655
  catch (_e) {
605
656
  _emitFailure(req, "session-store-error");
606
- return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session-store-unavailable" });
657
+ return _writeRejection(req, res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session-store-unavailable" });
607
658
  }
608
659
  if (!session) {
609
660
  _emitObs("apiEncrypt.session.unknown", 1, {});
610
661
  _emitFailure(req, "session-unknown");
611
- return _writeRejection(res, HTTP_STATUS.UNAUTHORIZED, { error: "session-unknown" });
662
+ return _writeRejection(req, res, HTTP_STATUS.UNAUTHORIZED, { error: "session-unknown" });
663
+ }
664
+ // Recover + coerce the session key BEFORE the expiry / rotation /
665
+ // replay gates so a rejection on this established channel can ride
666
+ // the session envelope (the channel is keyed the moment the sid
667
+ // resolves to a stored session, even if THIS request is then
668
+ // refused). An operator store may have JSON-serialised the buffer.
669
+ sessionKey = session.sessionKey;
670
+ if (Buffer.isBuffer(sessionKey) === false) {
671
+ if (typeof sessionKey === "string") {
672
+ sessionKey = Buffer.from(sessionKey, "base64");
673
+ } else if (sessionKey && sessionKey.type === "Buffer" && Array.isArray(sessionKey.data)) {
674
+ sessionKey = Buffer.from(sessionKey.data);
675
+ } else if (sessionKey instanceof Uint8Array) {
676
+ sessionKey = Buffer.from(sessionKey);
677
+ }
678
+ }
679
+ if (!Buffer.isBuffer(sessionKey) || sessionKey.length !== SESSION_KEY_BYTES) {
680
+ sessionKey = null;
681
+ _emitFailure(req, "session-store-error");
682
+ return _writeRejection(req, res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session-store-unavailable" });
612
683
  }
684
+ // From here the channel is established: error bodies encrypt. The
685
+ // rejection counter sits one above the session's last emitted
686
+ // response so the client's strictly-increasing _ctr check holds.
687
+ _installRejectEncoder(req, sessionKey, sid, session.responsesEmitted + 1);
613
688
  if (now > session.expiresAt) {
614
689
  try { await sessionStore.delete(sid); } catch (_e) { /* best-effort */ }
615
690
  _emitObs("apiEncrypt.session.expired", 1, {});
@@ -620,7 +695,7 @@ function create(opts) {
620
695
  requestId: req.requestId || null,
621
696
  });
622
697
  _emitFailure(req, "session-expired");
623
- return _writeRejection(res, HTTP_STATUS.UNAUTHORIZED, { error: "session-expired" });
698
+ return _writeRejection(req, res, HTTP_STATUS.UNAUTHORIZED, { error: "session-expired" });
624
699
  }
625
700
  if (session.responsesEmitted >= sessionMaxResponses) {
626
701
  try { await sessionStore.delete(sid); } catch (_e) { /* best-effort */ }
@@ -632,7 +707,7 @@ function create(opts) {
632
707
  requestId: req.requestId || null,
633
708
  });
634
709
  _emitFailure(req, "session-rotation-required");
635
- return _writeRejection(res, HTTP_STATUS.UNAUTHORIZED, { error: "session-rotation-required" });
710
+ return _writeRejection(req, res, HTTP_STATUS.UNAUTHORIZED, { error: "session-rotation-required" });
636
711
  }
637
712
  // Replay defense: counter MUST strictly increase.
638
713
  if (ctr <= session.lastReqCtr) {
@@ -644,7 +719,10 @@ function create(opts) {
644
719
  requestId: req.requestId || null,
645
720
  });
646
721
  _emitFailure(req, "counter-replay");
647
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
722
+ // Plaintext: this path does not persist a consumed response counter
723
+ // (see _writeRejection). Keeping it cleartext avoids desyncing the
724
+ // session's response-counter sequence.
725
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" }, { plaintext: true });
648
726
  }
649
727
  // Atomic replay gate (CWE-367). The monotonic counter check above is
650
728
  // an ordering fast-path only: on a clustered session store, get(sid)
@@ -674,7 +752,7 @@ function create(opts) {
674
752
  try { ctrFresh = await store.checkAndInsert(ctrKey, session.expiresAt); }
675
753
  catch (_e) {
676
754
  _emitFailure(req, "nonce-store-error");
677
- return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "nonce-store-unavailable" });
755
+ return _writeRejection(req, res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "nonce-store-unavailable" });
678
756
  }
679
757
  if (!ctrFresh) {
680
758
  _emitObs("apiEncrypt.session.replay_rejected", 1, { lane: "atomic" });
@@ -685,23 +763,10 @@ function create(opts) {
685
763
  requestId: req.requestId || null,
686
764
  });
687
765
  _emitFailure(req, "counter-replay");
688
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
689
- }
690
- sessionKey = session.sessionKey;
691
- if (Buffer.isBuffer(sessionKey) === false) {
692
- // Operator-supplied store may have JSON-serialised the buffer.
693
- // Accept hex / base64 / Uint8Array and coerce.
694
- if (typeof sessionKey === "string") {
695
- sessionKey = Buffer.from(sessionKey, "base64");
696
- } else if (sessionKey && sessionKey.type === "Buffer" && Array.isArray(sessionKey.data)) {
697
- sessionKey = Buffer.from(sessionKey.data);
698
- } else if (sessionKey instanceof Uint8Array) {
699
- sessionKey = Buffer.from(sessionKey);
700
- }
701
- }
702
- if (!Buffer.isBuffer(sessionKey) || sessionKey.length !== SESSION_KEY_BYTES) {
703
- _emitFailure(req, "session-store-error");
704
- return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session-store-unavailable" });
766
+ // Plaintext for the same reason as the monotonic-replay path above:
767
+ // the atomic-claim loser returns before responsesEmitted is persisted,
768
+ // so encrypting it would consume a response _ctr the server never records.
769
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" }, { plaintext: true });
705
770
  }
706
771
  session.lastReqCtr = ctr;
707
772
  session.lastUsedAt = now;
@@ -711,7 +776,7 @@ function create(opts) {
711
776
  sessionCtx = { sid: sid, responseCtr: session.responsesEmitted };
712
777
  } else {
713
778
  _emitFailure(req, "shape");
714
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
779
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
715
780
  }
716
781
 
717
782
  // Decrypt _ct → cleartext payload bytes → JSON object. The request
@@ -726,7 +791,7 @@ function create(opts) {
726
791
  clearObj = safeJson.parse(ptBuf.toString("utf8"), { maxBytes: maxDecryptedBytes });
727
792
  } catch (_e) {
728
793
  _emitFailure(req, "tag");
729
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
794
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
730
795
  }
731
796
 
732
797
  // Replace req.body with cleartext, stash session key for any
@@ -1086,8 +1151,12 @@ function httpClientEncrypted(opts) {
1086
1151
  headers["Content-Type"] = "application/json";
1087
1152
 
1088
1153
  var passThrough = {};
1089
- var passable = ["allowedProtocols", "allowInternal", "idleTimeoutMs", "maxResponseBytes",
1090
- "agent", "errorClass"];
1154
+ // timeoutMs (overall wall-clock cap) and signal (caller cancellation) are
1155
+ // forwarded alongside idleTimeoutMs: without the wall-clock cap a peer that
1156
+ // trickles bytes within the idle window holds the encrypted call open
1157
+ // indefinitely (the plain request path already accepts both).
1158
+ var passable = ["allowedProtocols", "allowInternal", "timeoutMs", "idleTimeoutMs",
1159
+ "maxResponseBytes", "signal", "agent", "errorClass"];
1091
1160
  for (var i = 0; i < passable.length; i++) {
1092
1161
  if (reqOpts[passable[i]] !== undefined) passThrough[passable[i]] = reqOpts[passable[i]];
1093
1162
  }
@@ -112,6 +112,7 @@ var networkDns = require("./network-dns");
112
112
  var safeDns = require("./safe-dns");
113
113
  var safeUrl = require("./safe-url");
114
114
  var safeBuffer = require("./safe-buffer");
115
+ var safeAsync = require("./safe-async");
115
116
  var lazyRequire = require("./lazy-require");
116
117
 
117
118
  var audit = lazyRequire(function () { return require("./audit"); });
@@ -126,6 +127,14 @@ var DEFAULT_MAX_TTL_MS = C.TIME.hours(24);
126
127
  var DEFAULT_MIN_TTL_MS = C.TIME.seconds(60);
127
128
  var DEFAULT_STALE_WINDOW = C.TIME.hours(6);
128
129
  var DEFAULT_PROFILE = "strict";
130
+ // CWE-400. Per-query wall-clock deadline on the upstream transport
131
+ // lookup. A non-responsive / slow / stalling DoH endpoint (or a
132
+ // transport that sends headers then never ends the body) must not hold
133
+ // the await pending forever — every query() and every followCnames hop
134
+ // is bounded by this, and the default _wireLookup additionally tears
135
+ // the socket down (req.setTimeout) so the fd is released, not just the
136
+ // promise rejected. 10s matches a generous DoH round-trip ceiling.
137
+ var DEFAULT_TIMEOUT_MS = C.TIME.seconds(10);
129
138
  // CWE-400/770. Bound the cache so a hostile peer
130
139
  // that can drive query-name selection (e.g. inbound SMTP forwarding
131
140
  // DKIM `s=` / `d=` tag-controlled lookups) cannot inflate the Map to
@@ -168,6 +177,7 @@ var QTYPE_BY_NAME = Object.freeze({
168
177
  * maxTtlMs: number, // cap any TTL from upstream; default 24h
169
178
  * minTtlMs: number, // floor short-TTL records; default 60s
170
179
  * serveStale: number | false, // ms to retain expired entries; default 6h
180
+ * timeoutMs: number, // per-query upstream deadline; default 10s
171
181
  * transport: { lookup(name, qtype) → Promise<Buffer> }, // operator override
172
182
  * audit: b.audit namespace,
173
183
  *
@@ -187,7 +197,12 @@ function create(opts) {
187
197
  var minTtlMs = typeof opts.minTtlMs === "number" ? opts.minTtlMs : DEFAULT_MIN_TTL_MS;
188
198
  var serveStale = opts.serveStale === false ? 0 :
189
199
  typeof opts.serveStale === "number" ? opts.serveStale : DEFAULT_STALE_WINDOW;
190
- var transport = opts.transport || _defaultTransport();
200
+ var timeoutMs = typeof opts.timeoutMs === "number" ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;
201
+ if (!isFinite(timeoutMs) || timeoutMs <= 0) {
202
+ throw new ResolverError("resolver/bad-input",
203
+ "create: timeoutMs must be a positive finite number");
204
+ }
205
+ var transport = opts.transport || _defaultTransport(timeoutMs);
191
206
  // Audit goes to the operator-supplied sink (opts.audit) when present, else the
192
207
  // framework chain — b.audit.namespaced no-ops if the sink has no safeEmit.
193
208
  var _baseAudit = audit().namespaced("network.dns.resolver", { sink: opts.audit });
@@ -276,10 +291,18 @@ function create(opts) {
276
291
  return _result(hit.parsed, hit.ttl, true, false, hit.validated);
277
292
  }
278
293
 
279
- // Cache miss / expired — fetch from upstream.
294
+ // Cache miss / expired — fetch from upstream, bounded by the
295
+ // per-query wall-clock deadline. withTimeout rejects the await so a
296
+ // non-responsive / stalling endpoint can't hold the request pending
297
+ // forever; the default _wireLookup also tears the socket down on its
298
+ // own req.setTimeout so the underlying fd is released.
280
299
  var wireResponse;
281
300
  try {
282
- wireResponse = await transport.lookup(name, qtype);
301
+ wireResponse = await safeAsync.withTimeout(
302
+ Promise.resolve(transport.lookup(name, qtype)),
303
+ timeoutMs,
304
+ { name: "dns-resolver:" + name + "/" + qtype }
305
+ );
283
306
  } catch (e) {
284
307
  // Upstream failure — serve stale if we have it within window.
285
308
  if (hit && serveStale > 0 && hit.staleUntil > now) {
@@ -425,13 +448,13 @@ function create(opts) {
425
448
  };
426
449
  }
427
450
 
428
- function _defaultTransport() {
451
+ function _defaultTransport(timeoutMs) {
429
452
  // Default transport — compose b.network.dns.useDnsOverHttps()'s
430
453
  // existing DoH path. We use the wire-format DoH endpoint directly
431
454
  // so the response arrives as raw bytes for safeDns parsing.
432
455
  return {
433
456
  lookup: function (name, qtype) {
434
- return _wireLookup(name, qtype);
457
+ return _wireLookup(name, qtype, timeoutMs);
435
458
  },
436
459
  };
437
460
  }
@@ -440,7 +463,9 @@ function _defaultTransport() {
440
463
  // existing DoH path. Returns the raw response bytes for safeDns to
441
464
  // parse. Distinct from the existing network-dns DoH path which returns
442
465
  // already-decoded address strings — we need the raw bytes here.
443
- async function _wireLookup(name, qtype) {
466
+ async function _wireLookup(name, qtype, timeoutMs) {
467
+ var ms = typeof timeoutMs === "number" && isFinite(timeoutMs) && timeoutMs > 0
468
+ ? timeoutMs : DEFAULT_TIMEOUT_MS;
444
469
  var url = networkDns._getDohUrlForTest ? networkDns._getDohUrlForTest() : "https://cloudflare-dns.com/dns-query";
445
470
  // Encode a wire-format query for the target qtype.
446
471
  var qbuf = _encodeWireQuery(name, qtype);
@@ -448,6 +473,18 @@ async function _wireLookup(name, qtype) {
448
473
  var getUrl = url + (url.indexOf("?") === -1 ? "?" : "&") + "dns=" + b64;
449
474
  var u = safeUrl.parse(getUrl, { allowedProtocols: safeUrl.ALLOW_HTTP_TLS });
450
475
  return new Promise(function (resolve, reject) {
476
+ var settled = false;
477
+ function _fail(err) {
478
+ if (settled) return;
479
+ settled = true;
480
+ try { req.destroy(); } catch (_e) { /* best-effort socket teardown */ }
481
+ reject(err);
482
+ }
483
+ function _done(buf) {
484
+ if (settled) return;
485
+ settled = true;
486
+ resolve(buf);
487
+ }
451
488
  // Raw DoH wire-format request — bypasses b.httpClient envelope
452
489
  // because we need the raw binary response bytes for safeDns to
453
490
  // parse (httpClient assumes JSON/text shapes).
@@ -470,18 +507,31 @@ async function _wireLookup(name, qtype) {
470
507
  res.on("data", function (c) { if (!pushFailed) { try { collector.push(c); } catch (e) { pushFailed = e; } } });
471
508
  res.on("end", function () {
472
509
  try {
473
- if (pushFailed) { reject(pushFailed); return; }
510
+ if (pushFailed) { _fail(pushFailed); return; }
474
511
  if (res.statusCode !== 200) { // HTTP 200 OK
475
- reject(new ResolverError("resolver/upstream-http",
512
+ _fail(new ResolverError("resolver/upstream-http",
476
513
  "DoH HTTP " + res.statusCode + " for " + name));
477
514
  return;
478
515
  }
479
- resolve(collector.result());
480
- } catch (e) { reject(e); }
516
+ _done(collector.result());
517
+ } catch (e) { _fail(e); }
481
518
  });
482
519
  });
520
+ // Wall-clock / idle deadline — a DoH endpoint that accepts the
521
+ // connection, sends 200 headers, then never ends the body would
522
+ // otherwise hold the socket (and its fd) open indefinitely. setTimeout
523
+ // arms on socket inactivity; on fire we tear the socket down AND reject
524
+ // so neither the promise nor the fd leaks (CWE-400 slowloris-style).
525
+ req.setTimeout(ms, function () {
526
+ _fail(new ResolverError("resolver/upstream-timeout",
527
+ "DoH request to " + u.hostname + " for " + name + " exceeded " + ms + "ms"));
528
+ });
529
+ req.on("timeout", function () {
530
+ _fail(new ResolverError("resolver/upstream-timeout",
531
+ "DoH request to " + u.hostname + " for " + name + " exceeded " + ms + "ms"));
532
+ });
483
533
  req.on("error", function (e) {
484
- reject(new ResolverError("resolver/upstream-failed",
534
+ _fail(new ResolverError("resolver/upstream-failed",
485
535
  "DoH request failed: " + e.message));
486
536
  });
487
537
  req.end();
@@ -28,11 +28,18 @@ var HEX_RADIX = C.BYTES.bytes(16); // parseInt / toString radix-16
28
28
  var observability = lazyRequire(function () { return require("./observability"); });
29
29
  var safeEnv = require("./parsers/safe-env");
30
30
 
31
+ // Default wall-clock deadline for every lookup (resolve / DoH / DoT /
32
+ // system). Without a non-zero default a header-then-stall or
33
+ // slow-trickle upstream hangs the request forever (CWE-400). 10s
34
+ // matches the resolver's default. Operators override via
35
+ // setLookupTimeoutMs(); 0 disables the deadline (operator opt-out).
36
+ var DEFAULT_LOOKUP_TIMEOUT_MS = C.TIME.seconds(10);
37
+
31
38
  var STATE = {
32
39
  servers: null,
33
40
  resultOrder: null,
34
41
  family: 0,
35
- lookupTimeoutMs: 0,
42
+ lookupTimeoutMs: DEFAULT_LOOKUP_TIMEOUT_MS,
36
43
  cacheTtlMs: 0,
37
44
  cacheNegativeTtlMs: 0,
38
45
  doh: null,
@@ -285,6 +292,19 @@ function _withTimeout(promise, ms, host) {
285
292
  });
286
293
  }
287
294
 
295
+ // Arm a wall-clock deadline on a raw https.request so a stalled upstream
296
+ // (headers-then-stall, slow-trickle body) tears the socket down instead
297
+ // of leaking the fd until the process exits. The promise-level
298
+ // _withTimeout rejects the caller, but only this destroys the underlying
299
+ // connection. No-op when the operator has disabled the deadline (ms<=0).
300
+ function _armRequestTimeout(req, ms, host, reject) {
301
+ if (ms <= 0) return;
302
+ req.setTimeout(ms, function () {
303
+ try { req.destroy(); } catch (_e) { /* best-effort socket teardown */ }
304
+ reject(new DnsError("dns/lookup-timeout", "dns lookup of '" + host + "' exceeded " + ms + "ms"));
305
+ });
306
+ }
307
+
288
308
  function _encodeDnsQuery(host, qtype) {
289
309
  var parts = host.split(".").filter(Boolean);
290
310
  var nameLen = 1;
@@ -450,6 +470,7 @@ async function _dohLookup(host, family) {
450
470
  });
451
471
  });
452
472
  req.on("error", function (e) { reject(new DnsError("dns/doh-failed", "DoH request failed: " + e.message)); });
473
+ _armRequestTimeout(req, STATE.lookupTimeoutMs, host, reject);
453
474
  if (usePost) req.write(enc.buf);
454
475
  req.end();
455
476
  });
@@ -511,6 +532,7 @@ async function _dohLookupSecure(host, family) {
511
532
  });
512
533
  });
513
534
  req.on("error", function (e) { reject(new DnsError("dns/doh-failed", "DoH request failed: " + e.message)); });
535
+ _armRequestTimeout(req, STATE.lookupTimeoutMs, host, reject);
514
536
  if (usePost) req.write(enc.buf);
515
537
  req.end();
516
538
  });
@@ -591,6 +613,17 @@ function _dotConnect() {
591
613
  // when idle — _dotLookup toggles this around its query. Calling
592
614
  // unref() unconditionally here let node exit during a normal lookup
593
615
  // when no other I/O kept the event loop alive.
616
+ //
617
+ // Wall-clock teardown: a stalled handshake or trickle-response upstream
618
+ // would otherwise leak the socket until the process exits (CWE-400).
619
+ // On inactivity past the deadline destroy the socket — the pool's
620
+ // error/close handlers evict it and the next lookup rebuilds. No-op
621
+ // when the operator has disabled the deadline (lookupTimeoutMs<=0).
622
+ if (STATE.lookupTimeoutMs > 0) {
623
+ sock.setTimeout(STATE.lookupTimeoutMs, function () {
624
+ try { sock.destroy(); } catch (_e) { /* best-effort socket teardown */ }
625
+ });
626
+ }
594
627
  return sock;
595
628
  }
596
629
 
@@ -865,6 +898,7 @@ async function _dohRawQuery(host, qtype) {
865
898
  });
866
899
  });
867
900
  req.on("error", function (e) { reject(new DnsError("dns/doh-failed", "DoH request failed: " + e.message)); });
901
+ _armRequestTimeout(req, STATE.lookupTimeoutMs, host, reject);
868
902
  if (usePost) req.write(enc.buf);
869
903
  req.end();
870
904
  });
@@ -979,6 +1013,17 @@ async function _systemRawQuery(host, qtype) {
979
1013
  try { sock.destroy(); } catch (_e) { /* best-effort socket teardown */ }
980
1014
  if (err) reject(err); else resolve(val);
981
1015
  }
1016
+ // Wall-clock teardown: a server that accepts the TCP connection but
1017
+ // never replies (or trickles the length-prefix) would otherwise hang
1018
+ // the query forever (CWE-400). On inactivity past the deadline
1019
+ // settle()'s destroy() tears the socket down. No-op when the operator
1020
+ // has disabled the deadline (lookupTimeoutMs<=0).
1021
+ if (STATE.lookupTimeoutMs > 0) {
1022
+ sock.setTimeout(STATE.lookupTimeoutMs, function () {
1023
+ settle(new DnsError("dns/lookup-timeout",
1024
+ "system DNS TCP query of '" + host + "' exceeded " + STATE.lookupTimeoutMs + "ms"));
1025
+ });
1026
+ }
982
1027
  sock.on("connect", function () {
983
1028
  var lenBuf = Buffer.alloc(2);
984
1029
  lenBuf.writeUInt16BE(enc.buf.length, 0);
@@ -1684,7 +1729,7 @@ function nodeLookup(host, options, callback) {
1684
1729
  function _stateForTest() { return STATE; }
1685
1730
  function _resetForTest() {
1686
1731
  STATE.servers = null; STATE.resultOrder = null; STATE.family = 0;
1687
- STATE.lookupTimeoutMs = 0; STATE.cacheTtlMs = 0; STATE.cacheNegativeTtlMs = 0;
1732
+ STATE.lookupTimeoutMs = DEFAULT_LOOKUP_TIMEOUT_MS; STATE.cacheTtlMs = 0; STATE.cacheNegativeTtlMs = 0;
1688
1733
  STATE.doh = null; STATE.dot = null; STATE.systemResolver = false;
1689
1734
  _designatedResolvers = null;
1690
1735
  _clearCache();
@@ -7,12 +7,20 @@ var nodeCrypto = require("node:crypto");
7
7
  var C = require("./constants");
8
8
  var { timingSafeEqual } = require("./crypto");
9
9
  var validateOpts = require("./validate-opts");
10
+ var safeBuffer = require("./safe-buffer");
10
11
  var { defineClass } = require("./framework-error");
11
12
 
12
13
  var NtsError = defineClass("NtsError", { alwaysPermanent: false });
13
14
 
14
15
  var NTS_KE_DEFAULT_PORT = 4460;
15
16
  var NTPV4_DEFAULT_PORT = 123;
17
+ // Upper bound on accumulated NTS-KE handshake bytes before a REC_END
18
+ // record terminates the exchange. A conformant NTS-KE response is a few
19
+ // hundred bytes (RFC 8915 §4); 64 KiB is generous. Without this ceiling
20
+ // a malicious or buggy server that streams non-END records fast enough
21
+ // OOMs the process before the wall-clock timer fires (it bounds time,
22
+ // not memory). Mirrors the ws-client handshake header cap.
23
+ var NTS_KE_HANDSHAKE_MAX_BYTES = C.BYTES.kib(64);
16
24
  // RFC 5905 §6 — seconds between 1900-01-01T00:00Z (NTP epoch) and
17
25
  // 1970-01-01T00:00Z (Unix epoch). Protocol-fixed (not a tunable).
18
26
  var NTP_TO_UNIX_OFFSET_SECONDS = 2208988800;
@@ -284,6 +292,14 @@ function performKeHandshake(opts) {
284
292
  var warnings = [];
285
293
  sock.on("data", function (chunk) {
286
294
  got = Buffer.concat([got, chunk]);
295
+ if (safeBuffer.byteLengthOf(got) > NTS_KE_HANDSHAKE_MAX_BYTES) {
296
+ clearTimeout(timer);
297
+ try { sock.destroy(); } catch (_e) { /* best-effort socket teardown */ }
298
+ done(new NtsError("nts/ke-too-large",
299
+ "NTS-KE handshake exceeded " + NTS_KE_HANDSHAKE_MAX_BYTES +
300
+ " bytes before a REC_END record"));
301
+ return;
302
+ }
287
303
  try {
288
304
  var records = _decodeRecords(got);
289
305
  var endRec = records.find(function (r) { return r.type === REC_END; });