@blamejs/core 0.15.14 → 0.15.16
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/index.js +2 -0
- package/lib/atomic-file.js +34 -0
- package/lib/auth/ciba.js +32 -8
- package/lib/auth/dpop.js +9 -0
- package/lib/auth/fido-mds3.js +35 -12
- package/lib/auth/jwt.js +19 -3
- package/lib/auth/oauth.js +8 -2
- package/lib/auth/password.js +1 -0
- package/lib/auth/saml.js +30 -12
- package/lib/crypto-field.js +19 -1
- package/lib/csp.js +9 -0
- package/lib/daemon.js +4 -1
- package/lib/db-query.js +33 -2
- 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 +93 -15
- package/lib/mail-bimi.js +6 -0
- package/lib/mail-crypto-smime.js +10 -0
- package/lib/mail-dkim.js +86 -20
- package/lib/mail.js +39 -0
- package/lib/middleware/api-encrypt.js +6 -2
- package/lib/middleware/compose-pipeline.js +39 -5
- 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/pipl-cn.js +11 -8
- package/lib/queue-sqs.js +1 -0
- package/lib/request-helpers.js +165 -13
- package/lib/safe-json.js +26 -0
- package/lib/session-device-binding.js +46 -24
- package/lib/session.js +120 -145
- package/lib/sql.js +22 -0
- package/lib/ws-client.js +26 -0
- package/lib/x509-chain.js +71 -24
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/mail-dkim.js
CHANGED
|
@@ -167,13 +167,24 @@ function _parseHeaders(rawHeaders) {
|
|
|
167
167
|
|
|
168
168
|
// ---- Hashing + signing ----
|
|
169
169
|
|
|
170
|
-
function _bodyHashB64(body, algorithm, canonBody) {
|
|
170
|
+
function _bodyHashB64(body, algorithm, canonBody, lcap) {
|
|
171
171
|
var canonicalized = canonBody === "simple"
|
|
172
172
|
? _canonBodySimple(body)
|
|
173
173
|
: _canonBodyRelaxed(body);
|
|
174
174
|
var hashName = "sha256"; // both rsa-sha256 and ed25519-sha256 hash with sha256
|
|
175
|
-
|
|
176
|
-
|
|
175
|
+
var hash = nodeCrypto.createHash(hashName);
|
|
176
|
+
// RFC 6376 §3.4.5 — the l= body-length count is octets of the body AFTER
|
|
177
|
+
// canonicalization. Canonicalize first, then truncate the canonicalized
|
|
178
|
+
// octet stream to lcap (slicing the raw body before canonicalizing diverges
|
|
179
|
+
// whenever relaxed canon changes the byte count within the first lcap
|
|
180
|
+
// octets — WSP-run collapse, trailing-WSP strip, CRLF normalization).
|
|
181
|
+
if (typeof lcap === "number" && isFinite(lcap) && lcap >= 0) {
|
|
182
|
+
var buf = Buffer.from(canonicalized, "utf8");
|
|
183
|
+
hash.update(lcap < buf.length ? buf.subarray(0, lcap) : buf);
|
|
184
|
+
} else {
|
|
185
|
+
hash.update(canonicalized);
|
|
186
|
+
}
|
|
187
|
+
return hash.digest("base64");
|
|
177
188
|
}
|
|
178
189
|
|
|
179
190
|
function _signString(strToSign, privateKey, algorithm) {
|
|
@@ -471,6 +482,36 @@ function _parseDkimTagList(value) {
|
|
|
471
482
|
return tags;
|
|
472
483
|
}
|
|
473
484
|
|
|
485
|
+
// RFC 6376 §3.5 — x= / t= / l= are unsigned decimal integers ("1*DIGIT").
|
|
486
|
+
// parseInt() is too lenient here ("12abc" → 12, "0x10" → 0, " 12" → 12),
|
|
487
|
+
// so a present-but-malformed value must not be coerced into a finite
|
|
488
|
+
// number that then passes the downstream check. Returns the parsed integer,
|
|
489
|
+
// or null when the value is not a well-formed unsigned integer — the caller
|
|
490
|
+
// fails CLOSED on null. `maxDigits` bounds the digit count (a ReDoS-free
|
|
491
|
+
// length cap; x=/t= are NumericDate, bounded to 12 digits).
|
|
492
|
+
function _parseDkimUnsignedInt(raw, maxDigits) {
|
|
493
|
+
if (typeof raw !== "string") return null;
|
|
494
|
+
var len = raw.length;
|
|
495
|
+
// "1*DIGIT": non-empty, ASCII 0-9 only, length-capped. A char-code scan
|
|
496
|
+
// (no regex) keeps this linear and avoids a dynamic RegExp / a shared
|
|
497
|
+
// digit-pattern literal — maxDigits bounds x=/t=/l= to their NumericDate
|
|
498
|
+
// / octet-count headroom.
|
|
499
|
+
if (len === 0 || (maxDigits && len > maxDigits)) return null;
|
|
500
|
+
for (var i = 0; i < len; i++) {
|
|
501
|
+
var c = raw.charCodeAt(i);
|
|
502
|
+
if (c < 0x30 || c > 0x39) return null; // not an ASCII digit
|
|
503
|
+
}
|
|
504
|
+
var n = parseInt(raw, 10);
|
|
505
|
+
if (!isFinite(n)) return null;
|
|
506
|
+
return n;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// x= / t= are NumericDate (seconds-since-epoch); 12 digits is ample headroom
|
|
510
|
+
// past any realistic epoch and keeps the parse bounded.
|
|
511
|
+
function _parseDkimNumericDate(raw) {
|
|
512
|
+
return _parseDkimUnsignedInt(raw, 12);
|
|
513
|
+
}
|
|
514
|
+
|
|
474
515
|
function _selectorTxtToKeyTags(txtRecords) {
|
|
475
516
|
// DKIM key record is a TXT record at <selector>._domainkey.<domain>.
|
|
476
517
|
// Format: "v=DKIM1; k=rsa; p=<base64>" (chunks may be split across
|
|
@@ -674,15 +715,25 @@ function _verifySingleSignature(rfc822, parsedHeaders, sigHeader, keyTags, sigTa
|
|
|
674
715
|
|
|
675
716
|
var split = _splitHeadersBody(rfc822);
|
|
676
717
|
var body = split.body;
|
|
718
|
+
var lcap;
|
|
677
719
|
if (sigTags.l !== undefined) {
|
|
678
720
|
// The framework refuses l= at SIGN-time per the M3AAWG / Gmail /
|
|
679
721
|
// Microsoft 365 guidance (v0.7.18). On VERIFY, an `l=` tag on an
|
|
680
722
|
// inbound signature signals append-after-signature exposure —
|
|
681
723
|
// operators decide acceptance. Honor the cap for the body hash so
|
|
682
724
|
// the signature still validates against legitimate senders that
|
|
683
|
-
// use l=, but flag in the result.
|
|
684
|
-
|
|
685
|
-
|
|
725
|
+
// use l=, but flag in the result. The cap is octets of the
|
|
726
|
+
// CANONICALIZED body (RFC 6376 §3.4.5), applied inside _bodyHashB64.
|
|
727
|
+
// l= is an unsigned decimal integer ("1*DIGIT" per §3.5); a
|
|
728
|
+
// present-but-unparseable value must FAIL CLOSED rather than fall
|
|
729
|
+
// through to lcap=undefined (hashing the whole body silently masks the
|
|
730
|
+
// malformed tag instead of refusing it).
|
|
731
|
+
var parsedL = _parseDkimUnsignedInt(sigTags.l, 18);
|
|
732
|
+
if (parsedL === null) {
|
|
733
|
+
return { result: "permerror",
|
|
734
|
+
errors: ["DKIM-Signature l= present but unparseable (RFC 6376 §3.5 — unsigned integer required)"] };
|
|
735
|
+
}
|
|
736
|
+
lcap = parsedL;
|
|
686
737
|
}
|
|
687
738
|
|
|
688
739
|
// 1. Body-hash check.
|
|
@@ -690,7 +741,7 @@ function _verifySingleSignature(rfc822, parsedHeaders, sigHeader, keyTags, sigTa
|
|
|
690
741
|
if (typeof expectedBh !== "string") {
|
|
691
742
|
return { result: "permerror", errors: ["DKIM-Signature missing bh="] };
|
|
692
743
|
}
|
|
693
|
-
var actualBh = _bodyHashB64(body, algorithm, canonBody);
|
|
744
|
+
var actualBh = _bodyHashB64(body, algorithm, canonBody, lcap);
|
|
694
745
|
if (actualBh !== expectedBh) {
|
|
695
746
|
return { result: "fail", errors: ["body hash mismatch"] };
|
|
696
747
|
}
|
|
@@ -909,34 +960,49 @@ async function verify(rfc822, opts) {
|
|
|
909
960
|
// attack). Both are in seconds-since-epoch per ABNF.
|
|
910
961
|
var nowSec = Math.floor(Date.now() / C.TIME.seconds(1));
|
|
911
962
|
var clockSkewSec = Math.floor(clockSkewMs / C.TIME.seconds(1));
|
|
963
|
+
// x= / t= are NumericDate (digits-only seconds-since-epoch per the
|
|
964
|
+
// §3.5 ABNF). A present-but-unparseable value must FAIL CLOSED — a NaN
|
|
965
|
+
// result silently skipping the expiry / future-date / ordering checks
|
|
966
|
+
// would let verification proceed to result:"pass" with NO expiry
|
|
967
|
+
// enforced (mirrors the SAML Conditions present-but-unparseable fix).
|
|
968
|
+
var xSec = null;
|
|
912
969
|
if (sigTags.x !== undefined) {
|
|
913
|
-
|
|
914
|
-
if (
|
|
970
|
+
xSec = _parseDkimNumericDate(sigTags.x);
|
|
971
|
+
if (xSec === null) {
|
|
972
|
+
results.push({ d: d || null, s: s || null, alg: alg || null,
|
|
973
|
+
result: "permerror",
|
|
974
|
+
errors: ["DKIM-Signature x= present but unparseable (RFC 6376 §3.5 — NumericDate required)"] });
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
977
|
+
if (xSec + clockSkewSec < nowSec) {
|
|
915
978
|
results.push({ d: d || null, s: s || null, alg: alg || null,
|
|
916
979
|
result: "permerror",
|
|
917
|
-
errors: ["DKIM-Signature x=" +
|
|
980
|
+
errors: ["DKIM-Signature x=" + xSec + " has expired (RFC 6376 §3.5)"] });
|
|
918
981
|
continue;
|
|
919
982
|
}
|
|
920
983
|
}
|
|
921
984
|
if (sigTags.t !== undefined) {
|
|
922
|
-
var tSec =
|
|
985
|
+
var tSec = _parseDkimNumericDate(sigTags.t);
|
|
986
|
+
if (tSec === null) {
|
|
987
|
+
results.push({ d: d || null, s: s || null, alg: alg || null,
|
|
988
|
+
result: "permerror",
|
|
989
|
+
errors: ["DKIM-Signature t= present but unparseable (RFC 6376 §3.5 — NumericDate required)"] });
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
923
992
|
// Allow up to 24h future-skew; beyond that, refuse — neither
|
|
924
993
|
// operator clock drift nor delivery latency explains a future-
|
|
925
994
|
// dated signing time of more than a day.
|
|
926
|
-
if (
|
|
995
|
+
if (tSec - (24 * 60 * 60) > nowSec) { // allow:raw-time-literal — 24h future-date sanity ceiling
|
|
927
996
|
results.push({ d: d || null, s: s || null, alg: alg || null,
|
|
928
997
|
result: "permerror",
|
|
929
998
|
errors: ["DKIM-Signature t=" + tSec + " is more than 24h in the future (RFC 6376 §3.5 sanity)"] });
|
|
930
999
|
continue;
|
|
931
1000
|
}
|
|
932
|
-
if (
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
errors: ["DKIM-Signature x= must be after t= (RFC 6376 §3.5)"] });
|
|
938
|
-
continue;
|
|
939
|
-
}
|
|
1001
|
+
if (xSec !== null && xSec < tSec) {
|
|
1002
|
+
results.push({ d: d || null, s: s || null, alg: alg || null,
|
|
1003
|
+
result: "permerror",
|
|
1004
|
+
errors: ["DKIM-Signature x= must be after t= (RFC 6376 §3.5)"] });
|
|
1005
|
+
continue;
|
|
940
1006
|
}
|
|
941
1007
|
}
|
|
942
1008
|
if (!d || !s) {
|
package/lib/mail.js
CHANGED
|
@@ -825,6 +825,16 @@ function smtpTransport(opts) {
|
|
|
825
825
|
_refuseCtlBytes("host", opts.host);
|
|
826
826
|
_refuseCtlBytes("servername", opts.servername);
|
|
827
827
|
var timeoutMs = opts.timeoutMs || C.TIME.seconds(15);
|
|
828
|
+
// Absolute transaction deadline — distinct from the per-socket IDLE
|
|
829
|
+
// timeout above. A slow-trickle MX that emits one byte just inside
|
|
830
|
+
// every idle window resets socket.setTimeout forever and never
|
|
831
|
+
// completes; this wall-clock bound fails the whole send regardless of
|
|
832
|
+
// trickle. Defaults well above timeoutMs so a normal multi-round-trip
|
|
833
|
+
// SMTP conversation (greeting → EHLO → STARTTLS → AUTH → MAIL/RCPT →
|
|
834
|
+
// DATA/BDAT) never trips it.
|
|
835
|
+
numericBounds.requirePositiveFiniteIntIfPresent(opts.maxTransactionMs,
|
|
836
|
+
"smtp transport: opts.maxTransactionMs", MailError, "mail/smtp-misconfigured");
|
|
837
|
+
var maxTransactionMs = opts.maxTransactionMs || C.TIME.minutes(5);
|
|
828
838
|
var tlsOpts = {
|
|
829
839
|
rejectUnauthorized: rejectUnauthorized,
|
|
830
840
|
minVersion: opts.minTlsVersion || "TLSv1.3",
|
|
@@ -875,6 +885,7 @@ function smtpTransport(opts) {
|
|
|
875
885
|
useImplicitTLS: useImplicitTLS,
|
|
876
886
|
ehloName: ehloName,
|
|
877
887
|
timeoutMs: timeoutMs,
|
|
888
|
+
maxTransactionMs: maxTransactionMs,
|
|
878
889
|
tlsOpts: tlsOpts,
|
|
879
890
|
servername: servername,
|
|
880
891
|
dkimSigner: opts.dkimSigner || null,
|
|
@@ -1043,6 +1054,7 @@ function _smtpSend(message, cfg) {
|
|
|
1043
1054
|
var dataWireBytes = null; // Buffer view of dataMessage for BDAT slicing
|
|
1044
1055
|
var useBdat = false; // Decided post-EHLO based on peerSupportsChunking + cfg.chunkingEnabled
|
|
1045
1056
|
var bodyMode = "7BIT"; // "7BIT" / "8BITMIME" / "BINARYMIME"
|
|
1057
|
+
var txTimer = null; // Absolute transaction-deadline timer (cfg.maxTransactionMs)
|
|
1046
1058
|
|
|
1047
1059
|
var fromAddr = _extractAddr(message.from);
|
|
1048
1060
|
var toList = _toArray(message.to).map(_extractAddr);
|
|
@@ -1081,9 +1093,13 @@ function _smtpSend(message, cfg) {
|
|
|
1081
1093
|
}
|
|
1082
1094
|
}
|
|
1083
1095
|
|
|
1096
|
+
function clearTxTimer() {
|
|
1097
|
+
if (txTimer) { try { clearTimeout(txTimer); } catch (_e) { /* best-effort */ } txTimer = null; }
|
|
1098
|
+
}
|
|
1084
1099
|
function fail(reason) {
|
|
1085
1100
|
if (settled) return;
|
|
1086
1101
|
settled = true;
|
|
1102
|
+
clearTxTimer();
|
|
1087
1103
|
try { if (socket) socket.destroy(); } catch (_e) { /* socket may already be torn down */ }
|
|
1088
1104
|
reject(new MailError("mail/smtp-failed",
|
|
1089
1105
|
"SMTP send failed: " + reason, false));
|
|
@@ -1091,6 +1107,7 @@ function _smtpSend(message, cfg) {
|
|
|
1091
1107
|
function done(ok, code) {
|
|
1092
1108
|
if (settled) return;
|
|
1093
1109
|
settled = true;
|
|
1110
|
+
clearTxTimer();
|
|
1094
1111
|
try { socket.end(); } catch (_e) { /* socket may already be torn down */ }
|
|
1095
1112
|
if (ok) resolve({ transport: "smtp", deliveredAt: Date.now(), code: code });
|
|
1096
1113
|
else reject(new MailError("mail/smtp-rejected",
|
|
@@ -1149,6 +1166,16 @@ function _smtpSend(message, cfg) {
|
|
|
1149
1166
|
|
|
1150
1167
|
function onData(data) {
|
|
1151
1168
|
buffer += data;
|
|
1169
|
+
// Bound the framing accumulator. A hostile / broken MX that
|
|
1170
|
+
// streams bytes without ever sending CRLF would otherwise grow
|
|
1171
|
+
// `buffer` without limit and OOM the process. SMTP responses are
|
|
1172
|
+
// tiny per spec; the 256 KiB cap is generous headroom. Measure
|
|
1173
|
+
// bytes (not UTF-16 code units) so multibyte trickle can't slip a
|
|
1174
|
+
// larger payload past a char-length check.
|
|
1175
|
+
if (safeBuffer.byteLengthOf(buffer) > MAIL_RESPONSE_MAX_BYTES) {
|
|
1176
|
+
fail("response-too-large");
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1152
1179
|
var lines = buffer.split("\r\n");
|
|
1153
1180
|
buffer = lines.pop();
|
|
1154
1181
|
for (var i = 0; i < lines.length; i++) {
|
|
@@ -1233,6 +1260,7 @@ function _smtpSend(message, cfg) {
|
|
|
1233
1260
|
// would corrupt NUL-bearing octets in transit.
|
|
1234
1261
|
if (requiresBinaryMime && !peerSupportsBinaryMime) {
|
|
1235
1262
|
settled = true;
|
|
1263
|
+
clearTxTimer();
|
|
1236
1264
|
try { socket.destroy(); } catch (_e) { /* socket may already be torn down */ }
|
|
1237
1265
|
reject(new MailError("mail/binarymime-not-advertised",
|
|
1238
1266
|
"message has 8-bit binary content but peer does not advertise BINARYMIME (RFC 3030 §3)",
|
|
@@ -1245,6 +1273,7 @@ function _smtpSend(message, cfg) {
|
|
|
1245
1273
|
// cap"; -1 means "SIZE not advertised" (no precheck).
|
|
1246
1274
|
if (cfg.respectPeerSize && peerSizeCap > 0 && messageWireSize > peerSizeCap) {
|
|
1247
1275
|
settled = true;
|
|
1276
|
+
clearTxTimer();
|
|
1248
1277
|
try { socket.destroy(); } catch (_e) { /* socket may already be torn down */ }
|
|
1249
1278
|
reject(new MailError("mail/peer-size-exceeded",
|
|
1250
1279
|
"message wire size " + messageWireSize + " bytes exceeds peer SIZE cap " +
|
|
@@ -1341,6 +1370,7 @@ function _smtpSend(message, cfg) {
|
|
|
1341
1370
|
// the chunked-body failure mode.
|
|
1342
1371
|
if (code !== 250) {
|
|
1343
1372
|
settled = true;
|
|
1373
|
+
clearTxTimer();
|
|
1344
1374
|
try { socket.destroy(); } catch (_e) { /* socket may already be torn down */ }
|
|
1345
1375
|
reject(new MailError("mail/bdat-chunk-rejected",
|
|
1346
1376
|
"BDAT chunk rejected (code " + code + ", offset " + bdatOffset + "/" +
|
|
@@ -1360,6 +1390,15 @@ function _smtpSend(message, cfg) {
|
|
|
1360
1390
|
}
|
|
1361
1391
|
}
|
|
1362
1392
|
|
|
1393
|
+
// Arm the absolute transaction deadline before opening the socket so
|
|
1394
|
+
// a peer that connects but then trickles (or never responds) is
|
|
1395
|
+
// bounded regardless of how it games the per-socket idle timer.
|
|
1396
|
+
// unref so a pending deadline never keeps the process alive on its own.
|
|
1397
|
+
txTimer = setTimeout(function () {
|
|
1398
|
+
fail("transaction-timeout");
|
|
1399
|
+
}, cfg.maxTransactionMs);
|
|
1400
|
+
if (txTimer && typeof txTimer.unref === "function") txTimer.unref();
|
|
1401
|
+
|
|
1363
1402
|
try { connect(); }
|
|
1364
1403
|
catch (e) { fail(e.message || String(e)); }
|
|
1365
1404
|
});
|
|
@@ -1086,8 +1086,12 @@ function httpClientEncrypted(opts) {
|
|
|
1086
1086
|
headers["Content-Type"] = "application/json";
|
|
1087
1087
|
|
|
1088
1088
|
var passThrough = {};
|
|
1089
|
-
|
|
1090
|
-
|
|
1089
|
+
// timeoutMs (overall wall-clock cap) and signal (caller cancellation) are
|
|
1090
|
+
// forwarded alongside idleTimeoutMs: without the wall-clock cap a peer that
|
|
1091
|
+
// trickles bytes within the idle window holds the encrypted call open
|
|
1092
|
+
// indefinitely (the plain request path already accepts both).
|
|
1093
|
+
var passable = ["allowedProtocols", "allowInternal", "timeoutMs", "idleTimeoutMs",
|
|
1094
|
+
"maxResponseBytes", "signal", "agent", "errorClass"];
|
|
1091
1095
|
for (var i = 0; i < passable.length; i++) {
|
|
1092
1096
|
if (reqOpts[passable[i]] !== undefined) passThrough[passable[i]] = reqOpts[passable[i]];
|
|
1093
1097
|
}
|
|
@@ -287,6 +287,28 @@ function composePipeline(entries, opts) {
|
|
|
287
287
|
catch (finalErr) { return reject(finalErr); }
|
|
288
288
|
resolve();
|
|
289
289
|
}
|
|
290
|
+
// A middleware (or error handler) that ENDS THE RESPONSE without calling
|
|
291
|
+
// next has halted the chain: it handled the request itself. Settle the
|
|
292
|
+
// outer promise so the awaiting router is released — a never-settled
|
|
293
|
+
// promise pins its req/res closure forever — but do NOT call finalNext:
|
|
294
|
+
// the router's next-flag stays false, so it won't run the route handler
|
|
295
|
+
// on top of an already-sent response.
|
|
296
|
+
function _resolveOnce() {
|
|
297
|
+
if (finished) return;
|
|
298
|
+
finished = true;
|
|
299
|
+
resolve();
|
|
300
|
+
}
|
|
301
|
+
// Settle when the response actually finishes. This is response-driven,
|
|
302
|
+
// not return-driven: a callback-style middleware that calls next() LATER
|
|
303
|
+
// (from a timer, stream, or legacy callback) returns before next() runs,
|
|
304
|
+
// so we must NOT treat a bare return as a halt — only an ended response.
|
|
305
|
+
// The synchronous _responseEnded() check below covers a middleware that
|
|
306
|
+
// ended the response inline (and a mock res without an event emitter);
|
|
307
|
+
// this listener covers one that ends it from a deferred callback.
|
|
308
|
+
if (res && typeof res.once === "function") {
|
|
309
|
+
res.once("finish", _resolveOnce);
|
|
310
|
+
res.once("close", _resolveOnce);
|
|
311
|
+
}
|
|
290
312
|
async function dispatch(err) {
|
|
291
313
|
if (finished) return;
|
|
292
314
|
if (idx >= resolved.length) return _finishOnce(err);
|
|
@@ -313,14 +335,19 @@ function composePipeline(entries, opts) {
|
|
|
313
335
|
}
|
|
314
336
|
try {
|
|
315
337
|
if (err) {
|
|
316
|
-
// Error handler: (err, req, res, next)
|
|
338
|
+
// Error handler: (err, req, res, next). Express convention — a
|
|
339
|
+
// 4-arg handler that returns without calling next has HANDLED the
|
|
340
|
+
// error, so the chain ends cleanly; settle (without finalNext).
|
|
317
341
|
await entry.mw(err, req, res, _next);
|
|
318
|
-
if (!advanced)
|
|
342
|
+
if (!advanced) _resolveOnce();
|
|
319
343
|
} else {
|
|
320
|
-
// Regular middleware: (req, res, next)
|
|
344
|
+
// Regular middleware: (req, res, next). Settle only if it ENDED the
|
|
345
|
+
// response (a halt). A bare return without next is NOT treated as a
|
|
346
|
+
// halt — it may be a callback-style middleware that calls next()
|
|
347
|
+
// later (timer/stream); the finish/close listener covers a deferred
|
|
348
|
+
// response end, and a deferred next() continues the chain.
|
|
321
349
|
await entry.mw(req, res, _next);
|
|
322
|
-
|
|
323
|
-
// The middleware presumably wrote the response itself.
|
|
350
|
+
if (!advanced && _responseEnded(res)) _resolveOnce();
|
|
324
351
|
}
|
|
325
352
|
} catch (syncErr) {
|
|
326
353
|
// Synchronous throw OR rejected promise — route through
|
|
@@ -334,6 +361,13 @@ function composePipeline(entries, opts) {
|
|
|
334
361
|
};
|
|
335
362
|
}
|
|
336
363
|
|
|
364
|
+
// True once the response has been committed/ended — the reliable "this
|
|
365
|
+
// middleware handled the request" signal (vs. the function merely returning,
|
|
366
|
+
// which a callback-style middleware does before its deferred next()).
|
|
367
|
+
function _responseEnded(res) {
|
|
368
|
+
return !!(res && (res.writableEnded || res.finished || res.headersSent));
|
|
369
|
+
}
|
|
370
|
+
|
|
337
371
|
composePipeline.CANONICAL_POSITIONS = CANONICAL_POSITIONS;
|
|
338
372
|
composePipeline.ComposePipelineError = ComposePipelineError;
|
|
339
373
|
|
|
@@ -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; });
|