@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.
- package/CHANGELOG.md +4 -0
- package/lib/atomic-file.js +66 -9
- package/lib/auth/fido-mds3.js +10 -0
- package/lib/auth/password.js +1 -0
- package/lib/auth/saml.js +11 -3
- package/lib/daemon.js +4 -1
- package/lib/external-db.js +131 -0
- package/lib/graphql-federation.js +25 -15
- package/lib/log-stream-cloudwatch.js +1 -0
- package/lib/log-stream-local.js +14 -1
- package/lib/log-stream-otlp.js +1 -0
- package/lib/log-stream-webhook.js +1 -0
- package/lib/mail-auth.js +69 -14
- package/lib/mail-bimi.js +6 -0
- package/lib/mail-crypto-smime.js +10 -0
- package/lib/mail-dkim.js +68 -15
- package/lib/mail.js +39 -0
- package/lib/middleware/api-encrypt.js +111 -42
- package/lib/network-dns-resolver.js +61 -11
- package/lib/network-dns.js +47 -2
- package/lib/network-nts.js +16 -0
- package/lib/network-proxy.js +55 -2
- package/lib/object-store/azure-blob-bucket-ops.js +1 -0
- package/lib/object-store/gcs-bucket-ops.js +1 -0
- package/lib/object-store/http-request.js +4 -0
- package/lib/outbox.js +29 -0
- package/lib/queue-local.js +123 -46
- package/lib/queue-sqs.js +1 -0
- package/lib/queue.js +13 -9
- package/lib/request-helpers.js +25 -6
- package/lib/self-update-standalone-verifier.js +69 -7
- package/lib/self-update.js +11 -2
- package/lib/session-device-binding.js +46 -24
- package/lib/session.js +85 -28
- package/lib/vendor/MANIFEST.json +11 -11
- package/lib/vendor/public-suffix-list.dat +6 -2
- package/lib/vendor/public-suffix-list.data.js +689 -688
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -223,12 +223,39 @@ function _validSid(sid) {
|
|
|
223
223
|
SID_RE.test(sid);
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
-
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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
|
-
|
|
1090
|
-
|
|
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
|
|
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
|
|
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) {
|
|
510
|
+
if (pushFailed) { _fail(pushFailed); return; }
|
|
474
511
|
if (res.statusCode !== 200) { // HTTP 200 OK
|
|
475
|
-
|
|
512
|
+
_fail(new ResolverError("resolver/upstream-http",
|
|
476
513
|
"DoH HTTP " + res.statusCode + " for " + name));
|
|
477
514
|
return;
|
|
478
515
|
}
|
|
479
|
-
|
|
480
|
-
} catch (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
|
-
|
|
534
|
+
_fail(new ResolverError("resolver/upstream-failed",
|
|
485
535
|
"DoH request failed: " + e.message));
|
|
486
536
|
});
|
|
487
537
|
req.end();
|
package/lib/network-dns.js
CHANGED
|
@@ -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:
|
|
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 =
|
|
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();
|
package/lib/network-nts.js
CHANGED
|
@@ -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; });
|