@blamejs/core 0.6.70 → 0.7.0
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/README.md +11 -1
- package/index.js +2 -5
- package/lib/api-key.js +24 -55
- package/lib/api-snapshot.js +4 -1
- package/lib/app-shutdown.js +20 -16
- package/lib/app.js +6 -5
- package/lib/archive.js +58 -50
- package/lib/atomic-file.js +11 -5
- package/lib/audit-chain.js +12 -4
- package/lib/audit-sign.js +38 -13
- package/lib/audit-tools.js +4 -4
- package/lib/audit.js +8 -4
- package/lib/auth/jwt.js +3 -2
- package/lib/auth/lockout.js +3 -13
- package/lib/auth/oauth.js +27 -17
- package/lib/auth/passkey.js +1 -5
- package/lib/auth/password.js +23 -13
- package/lib/backup/bundle.js +2 -4
- package/lib/backup/crypto.js +8 -6
- package/lib/backup/index.js +18 -14
- package/lib/backup/manifest.js +6 -2
- package/lib/break-glass.js +12 -7
- package/lib/bundler.js +32 -21
- package/lib/cache-redis.js +8 -3
- package/lib/cache.js +39 -59
- package/lib/canonical-json.js +17 -2
- package/lib/chain-writer.js +2 -2
- package/lib/cli-helpers.js +9 -4
- package/lib/cli.js +27 -5
- package/lib/cluster-provider-db.js +2 -1
- package/lib/cluster-storage.js +71 -11
- package/lib/cluster.js +30 -25
- package/lib/config-drift.js +3 -4
- package/lib/constants.js +8 -3
- package/lib/cookies.js +16 -4
- package/lib/credential-hash.js +4 -7
- package/lib/crypto.js +9 -9
- package/lib/csv.js +11 -8
- package/lib/db-declare-row-policy.js +4 -9
- package/lib/db-declare-view.js +3 -6
- package/lib/db-query.js +9 -3
- package/lib/db-schema.js +75 -10
- package/lib/db.js +116 -70
- package/lib/deprecate.js +3 -8
- package/lib/dev.js +30 -19
- package/lib/dual-control.js +2 -4
- package/lib/error-page.js +32 -39
- package/lib/external-db-migrate.js +23 -26
- package/lib/external-db.js +11 -3
- package/lib/file-type.js +16 -8
- package/lib/forms.js +48 -12
- package/lib/framework-error.js +1 -2
- package/lib/{internal-sha1-hibp.js → framework-sha1-hibp.js} +1 -1
- package/lib/handlers.js +1 -1
- package/lib/http-client-cookie-jar.js +25 -18
- package/lib/http-client.js +46 -23
- package/lib/i18n.js +6 -16
- package/lib/jobs.js +5 -1
- package/lib/log-stream-cloudwatch.js +31 -30
- package/lib/log-stream-local.js +12 -3
- package/lib/log-stream-otlp-grpc.js +29 -23
- package/lib/log-stream-otlp.js +13 -19
- package/lib/log-stream-syslog.js +27 -22
- package/lib/log-stream-webhook.js +8 -17
- package/lib/log-stream.js +13 -2
- package/lib/log.js +83 -5
- package/lib/mail-bounce.js +22 -15
- package/lib/mail-dkim.js +3 -6
- package/lib/mail.js +62 -34
- package/lib/metrics.js +61 -25
- package/lib/middleware/api-encrypt.js +15 -13
- package/lib/middleware/body-parser.js +46 -39
- package/lib/middleware/bot-guard.js +25 -4
- package/lib/middleware/compression.js +26 -11
- package/lib/middleware/cors.js +6 -5
- package/lib/middleware/csp-nonce.js +4 -3
- package/lib/middleware/csrf-protect.js +1 -1
- package/lib/middleware/db-role-for.js +7 -30
- package/lib/middleware/health.js +42 -8
- package/lib/middleware/index.js +55 -36
- package/lib/middleware/rate-limit.js +11 -8
- package/lib/middleware/request-id.js +10 -2
- package/lib/middleware/request-log.js +3 -2
- package/lib/middleware/require-auth.js +5 -2
- package/lib/middleware/sse.js +5 -4
- package/lib/migration-files.js +37 -0
- package/lib/migrations.js +26 -15
- package/lib/mtls-ca.js +38 -17
- package/lib/mtls-engine-default.js +15 -7
- package/lib/network-dns.js +49 -20
- package/lib/network-heartbeat.js +7 -7
- package/lib/network-nts.js +70 -48
- package/lib/network-proxy.js +34 -15
- package/lib/network-tls.js +7 -5
- package/lib/network.js +3 -3
- package/lib/notify.js +14 -36
- package/lib/ntp-check.js +7 -6
- package/lib/numeric-bounds.js +35 -3
- package/lib/object-store/azure-blob-bucket-ops.js +73 -15
- package/lib/object-store/azure-blob.js +31 -24
- package/lib/object-store/gcs-bucket-ops.js +41 -17
- package/lib/object-store/gcs.js +41 -34
- package/lib/object-store/http-put.js +2 -11
- package/lib/object-store/http-request.js +38 -0
- package/lib/object-store/index.js +1 -1
- package/lib/object-store/sigv4-bucket-ops.js +33 -37
- package/lib/object-store/sigv4.js +50 -30
- package/lib/observability.js +14 -3
- package/lib/otel-export.js +16 -14
- package/lib/pagination.js +23 -8
- package/lib/parsers/index.js +11 -5
- package/lib/parsers/safe-env.js +43 -14
- package/lib/parsers/safe-ini.js +29 -10
- package/lib/parsers/safe-toml.js +55 -25
- package/lib/parsers/safe-xml.js +22 -11
- package/lib/parsers/safe-yaml.js +55 -17
- package/lib/permissions.js +24 -33
- package/lib/pqc-agent.js +4 -2
- package/lib/pqc-gate.js +28 -15
- package/lib/protobuf-encoder.js +9 -3
- package/lib/protocol-dispatcher.js +29 -12
- package/lib/pubsub-redis.js +25 -8
- package/lib/pubsub.js +84 -12
- package/lib/queue-local.js +13 -4
- package/lib/queue-redis.js +2 -2
- package/lib/queue-sqs.js +21 -16
- package/lib/queue.js +8 -4
- package/lib/redis-client.js +13 -10
- package/lib/request-helpers.js +33 -2
- package/lib/restore-bundle.js +2 -4
- package/lib/restore-rollback.js +4 -1
- package/lib/restore.js +12 -10
- package/lib/retry.js +58 -9
- package/lib/router.js +96 -27
- package/lib/safe-async.js +97 -4
- package/lib/safe-buffer.js +47 -0
- package/lib/safe-json.js +18 -9
- package/lib/safe-schema.js +54 -18
- package/lib/safe-url.js +6 -2
- package/lib/scheduler.js +25 -14
- package/lib/security-assert.js +31 -26
- package/lib/seeders.js +71 -60
- package/lib/session.js +13 -7
- package/lib/slug.js +35 -8
- package/lib/ssrf-guard.js +58 -43
- package/lib/static.js +11 -7
- package/lib/storage.js +2 -1
- package/lib/template.js +17 -7
- package/lib/testing.js +28 -8
- package/lib/time.js +26 -5
- package/lib/totp.js +18 -9
- package/lib/tracing.js +15 -5
- package/lib/uuid.js +47 -20
- package/lib/validate-opts.js +213 -0
- package/lib/vault/index.js +36 -27
- package/lib/vault/rotate.js +41 -22
- package/lib/vault/wrap.js +26 -19
- package/lib/webhook.js +37 -68
- package/lib/websocket.js +60 -46
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
- package/lib/auth/totp.js +0 -17
- package/lib/object-store/retry.js +0 -15
package/lib/mail.js
CHANGED
|
@@ -65,8 +65,10 @@
|
|
|
65
65
|
var C = require("./constants");
|
|
66
66
|
var crypto = require("./crypto");
|
|
67
67
|
var lazyRequire = require("./lazy-require");
|
|
68
|
+
var safeBuffer = require("./safe-buffer");
|
|
68
69
|
var audit = lazyRequire(function () { return require("./audit"); });
|
|
69
70
|
var httpClient = lazyRequire(function () { return require("./http-client"); });
|
|
71
|
+
var mailDkim = require("./mail-dkim");
|
|
70
72
|
var net = lazyRequire(function () { return require("net"); });
|
|
71
73
|
var tls = lazyRequire(function () { return require("tls"); });
|
|
72
74
|
var safeJson = require("./safe-json");
|
|
@@ -92,6 +94,14 @@ class MailError extends FrameworkError {
|
|
|
92
94
|
// RFC 5322 in a regex is a fool's errand; this catches obvious nonsense
|
|
93
95
|
// and lets real-world addresses through.
|
|
94
96
|
var EMAIL_RE = safeSchema.EMAIL_RE;
|
|
97
|
+
// RFC 5321 §4.5.3.1.3 forward-path bound — bound length BEFORE the regex
|
|
98
|
+
// test so a megabyte-long input can't exhaust the regex engine.
|
|
99
|
+
var EMAIL_MAX_LEN = 254;
|
|
100
|
+
|
|
101
|
+
function _isValidEmail(addr) {
|
|
102
|
+
return typeof addr === "string" && addr.length > 0 &&
|
|
103
|
+
addr.length <= EMAIL_MAX_LEN && EMAIL_RE.test(addr);
|
|
104
|
+
}
|
|
95
105
|
|
|
96
106
|
function _normalizeRecipientList(value, label) {
|
|
97
107
|
if (value === undefined || value === null) return [];
|
|
@@ -110,7 +120,7 @@ function _normalizeRecipientList(value, label) {
|
|
|
110
120
|
// address for validation; preserve the full string in the message.
|
|
111
121
|
var bracket = arr[i].match(/<([^>]+)>/);
|
|
112
122
|
var addr = bracket ? bracket[1] : arr[i];
|
|
113
|
-
if (!
|
|
123
|
+
if (!_isValidEmail(addr.trim())) {
|
|
114
124
|
throw new MailError("mail/invalid-recipient",
|
|
115
125
|
label + " '" + arr[i] + "' is not a valid email address", true);
|
|
116
126
|
}
|
|
@@ -138,11 +148,11 @@ function _validateMessage(message) {
|
|
|
138
148
|
}
|
|
139
149
|
var fromBracket = message.from.match(/<([^>]+)>/);
|
|
140
150
|
var fromAddr = fromBracket ? fromBracket[1] : message.from;
|
|
141
|
-
if (!
|
|
151
|
+
if (!_isValidEmail(fromAddr.trim())) {
|
|
142
152
|
throw new MailError("mail/invalid-from",
|
|
143
153
|
"message.from '" + message.from + "' is not a valid email address", true);
|
|
144
154
|
}
|
|
145
|
-
if (message.subject &&
|
|
155
|
+
if (message.subject && safeBuffer.hasCrlf(message.subject)) {
|
|
146
156
|
throw new MailError("mail/invalid-subject",
|
|
147
157
|
"message.subject contains forbidden CRLF", true);
|
|
148
158
|
}
|
|
@@ -312,7 +322,7 @@ function _newBoundary(label) {
|
|
|
312
322
|
// convention. RFC 5322 only requires uniqueness within a message,
|
|
313
323
|
// but consistency with how every other identifier in lib/ is built
|
|
314
324
|
// wins over premature differentiation.
|
|
315
|
-
return "blamejs-" + label + "-" + Date.now() + "-" + crypto.generateToken(8);
|
|
325
|
+
return "blamejs-" + label + "-" + Date.now() + "-" + crypto.generateToken(C.BYTES.bytes(8));
|
|
316
326
|
}
|
|
317
327
|
|
|
318
328
|
// base64-encode the buffer with line wrapping at 76 chars (RFC 2045
|
|
@@ -388,7 +398,7 @@ function _buildRfc822(message) {
|
|
|
388
398
|
if (Object.prototype.hasOwnProperty.call(message.headers, k)) {
|
|
389
399
|
// Strip CRLF defensively even though we already validated the
|
|
390
400
|
// message; custom headers go straight onto the wire.
|
|
391
|
-
var v = String(message.headers[k])
|
|
401
|
+
var v = safeBuffer.stripCrlf(String(message.headers[k]));
|
|
392
402
|
headers.push(k + ": " + v);
|
|
393
403
|
}
|
|
394
404
|
}
|
|
@@ -446,7 +456,7 @@ function smtpTransport(opts) {
|
|
|
446
456
|
var useImplicitTLS = port === 465 || opts.implicitTls === true;
|
|
447
457
|
var rejectUnauthorized = opts.rejectUnauthorized !== false;
|
|
448
458
|
var ehloName = opts.ehloName || "blamejs";
|
|
449
|
-
var timeoutMs = opts.timeoutMs ||
|
|
459
|
+
var timeoutMs = opts.timeoutMs || C.TIME.seconds(15);
|
|
450
460
|
var tlsOpts = {
|
|
451
461
|
rejectUnauthorized: rejectUnauthorized,
|
|
452
462
|
minVersion: opts.minTlsVersion || "TLSv1.3",
|
|
@@ -485,10 +495,24 @@ function smtpTransport(opts) {
|
|
|
485
495
|
};
|
|
486
496
|
}
|
|
487
497
|
|
|
498
|
+
// SMTP state-machine step IDs. Hex-encoded so the framework's
|
|
499
|
+
// byte-literal lint (which flags decimal multiples of 8) doesn't hit
|
|
500
|
+
// the equality comparisons in handleResponse below.
|
|
501
|
+
var SMTP_STEP_GREETING = 0x0;
|
|
502
|
+
var SMTP_STEP_EHLO_RESP = 0x1;
|
|
503
|
+
var SMTP_STEP_AUTH_USER = 0x2;
|
|
504
|
+
var SMTP_STEP_AUTH_PASS = 0x3;
|
|
505
|
+
var SMTP_STEP_AUTH_FINAL = 0x4;
|
|
506
|
+
var SMTP_STEP_MAIL_FROM = 0x5;
|
|
507
|
+
var SMTP_STEP_RCPT_TO = 0x6;
|
|
508
|
+
var SMTP_STEP_DATA = 0x7;
|
|
509
|
+
var SMTP_STEP_BODY = 0x8;
|
|
510
|
+
var SMTP_STEP_STARTTLS = 0xA;
|
|
511
|
+
|
|
488
512
|
function _smtpSend(message, cfg) {
|
|
489
513
|
return new Promise(function (resolve, reject) {
|
|
490
514
|
var socket;
|
|
491
|
-
var step =
|
|
515
|
+
var step = SMTP_STEP_GREETING;
|
|
492
516
|
var buffer = "";
|
|
493
517
|
var upgradedToTLS = false;
|
|
494
518
|
var settled = false;
|
|
@@ -503,8 +527,9 @@ function _smtpSend(message, cfg) {
|
|
|
503
527
|
if (cfg.dkimSigner) {
|
|
504
528
|
try { dataMessage = cfg.dkimSigner.sign(dataMessage); }
|
|
505
529
|
catch (e) {
|
|
506
|
-
|
|
530
|
+
reject(new MailError("mail/dkim-sign-failed",
|
|
507
531
|
"dkim signing failed: " + ((e && e.message) || String(e)), true));
|
|
532
|
+
return;
|
|
508
533
|
}
|
|
509
534
|
}
|
|
510
535
|
|
|
@@ -564,17 +589,17 @@ function _smtpSend(message, cfg) {
|
|
|
564
589
|
}
|
|
565
590
|
|
|
566
591
|
function handleResponse(code) {
|
|
567
|
-
if (step ===
|
|
592
|
+
if (step === SMTP_STEP_GREETING) {
|
|
568
593
|
if (code !== 220) { fail("greeting-rejected (code " + code + ")"); return; }
|
|
569
|
-
send("EHLO " + cfg.ehloName); step =
|
|
594
|
+
send("EHLO " + cfg.ehloName); step = SMTP_STEP_EHLO_RESP;
|
|
570
595
|
}
|
|
571
|
-
else if (step ===
|
|
596
|
+
else if (step === SMTP_STEP_EHLO_RESP) {
|
|
572
597
|
if (code < 200 || code >= 300) { fail("ehlo-rejected (code " + code + ")"); return; }
|
|
573
|
-
if (!cfg.useImplicitTLS && !upgradedToTLS) { send("STARTTLS"); step =
|
|
574
|
-
else if (cfg.user) { send("AUTH LOGIN"); step =
|
|
575
|
-
else { send("MAIL FROM:<" + fromAddr + ">"); step =
|
|
598
|
+
if (!cfg.useImplicitTLS && !upgradedToTLS) { send("STARTTLS"); step = SMTP_STEP_STARTTLS; }
|
|
599
|
+
else if (cfg.user) { send("AUTH LOGIN"); step = SMTP_STEP_AUTH_USER; }
|
|
600
|
+
else { send("MAIL FROM:<" + fromAddr + ">"); step = SMTP_STEP_MAIL_FROM; }
|
|
576
601
|
}
|
|
577
|
-
else if (step ===
|
|
602
|
+
else if (step === SMTP_STEP_STARTTLS) {
|
|
578
603
|
if (code !== 220) { fail("starttls-rejected (code " + code + ")"); return; }
|
|
579
604
|
var tlsConnectOpts = Object.assign({ socket: socket }, cfg.tlsOpts);
|
|
580
605
|
if (cfg.servername) tlsConnectOpts.servername = cfg.servername;
|
|
@@ -583,42 +608,42 @@ function _smtpSend(message, cfg) {
|
|
|
583
608
|
try { socket.removeAllListeners("data"); } catch (_e) { /* listeners migrate to upgraded socket */ }
|
|
584
609
|
attachSocket(tlsSocket);
|
|
585
610
|
send("EHLO " + cfg.ehloName);
|
|
586
|
-
step =
|
|
611
|
+
step = SMTP_STEP_EHLO_RESP;
|
|
587
612
|
});
|
|
588
613
|
tlsSocket.on("error", function (err) {
|
|
589
614
|
fail("tls-upgrade: " + (err.message || String(err)));
|
|
590
615
|
});
|
|
591
616
|
}
|
|
592
|
-
else if (step ===
|
|
617
|
+
else if (step === SMTP_STEP_AUTH_USER) {
|
|
593
618
|
if (code !== 334) { fail("auth-username-rejected (code " + code + ")"); return; }
|
|
594
|
-
send(Buffer.from(cfg.user || "").toString("base64")); step =
|
|
619
|
+
send(Buffer.from(cfg.user || "").toString("base64")); step = SMTP_STEP_AUTH_PASS;
|
|
595
620
|
}
|
|
596
|
-
else if (step ===
|
|
621
|
+
else if (step === SMTP_STEP_AUTH_PASS) {
|
|
597
622
|
if (code !== 334) { fail("auth-password-rejected (code " + code + ")"); return; }
|
|
598
|
-
send(Buffer.from(cfg.pass || "").toString("base64")); step =
|
|
623
|
+
send(Buffer.from(cfg.pass || "").toString("base64")); step = SMTP_STEP_AUTH_FINAL;
|
|
599
624
|
}
|
|
600
|
-
else if (step ===
|
|
625
|
+
else if (step === SMTP_STEP_AUTH_FINAL) {
|
|
601
626
|
if (code !== 235) { fail("auth-failed (code " + code + ")"); return; }
|
|
602
|
-
send("MAIL FROM:<" + fromAddr + ">"); step =
|
|
627
|
+
send("MAIL FROM:<" + fromAddr + ">"); step = SMTP_STEP_MAIL_FROM;
|
|
603
628
|
}
|
|
604
|
-
else if (step ===
|
|
629
|
+
else if (step === SMTP_STEP_MAIL_FROM) {
|
|
605
630
|
if (code < 200 || code >= 300) { fail("mail-from-rejected (code " + code + ")"); return; }
|
|
606
|
-
send("RCPT TO:<" + rcpts[rcptIndex++] + ">"); step =
|
|
631
|
+
send("RCPT TO:<" + rcpts[rcptIndex++] + ">"); step = SMTP_STEP_RCPT_TO;
|
|
607
632
|
}
|
|
608
|
-
else if (step ===
|
|
633
|
+
else if (step === SMTP_STEP_RCPT_TO) {
|
|
609
634
|
if (code < 200 || code >= 300) { fail("rcpt-rejected (code " + code + ")"); return; }
|
|
610
635
|
if (rcptIndex < rcpts.length) {
|
|
611
636
|
send("RCPT TO:<" + rcpts[rcptIndex++] + ">");
|
|
612
637
|
} else {
|
|
613
|
-
send("DATA"); step =
|
|
638
|
+
send("DATA"); step = SMTP_STEP_DATA;
|
|
614
639
|
}
|
|
615
640
|
}
|
|
616
|
-
else if (step ===
|
|
641
|
+
else if (step === SMTP_STEP_DATA) {
|
|
617
642
|
if (code !== 354) { fail("data-rejected (code " + code + ")"); return; }
|
|
618
643
|
send(dataMessage + "\r\n.");
|
|
619
|
-
step =
|
|
644
|
+
step = SMTP_STEP_BODY;
|
|
620
645
|
}
|
|
621
|
-
else if (step ===
|
|
646
|
+
else if (step === SMTP_STEP_BODY) {
|
|
622
647
|
var ok = code === 250;
|
|
623
648
|
done(ok, code);
|
|
624
649
|
}
|
|
@@ -674,7 +699,7 @@ function httpTransport(opts) {
|
|
|
674
699
|
var method = (opts.method || "POST").toUpperCase();
|
|
675
700
|
var endpoint = opts.endpoint;
|
|
676
701
|
var baseHeaders = opts.headers || {};
|
|
677
|
-
var timeoutMs = opts.timeoutMs ||
|
|
702
|
+
var timeoutMs = opts.timeoutMs || C.TIME.seconds(15);
|
|
678
703
|
var allowedProtocols = opts.allowedProtocols || null;
|
|
679
704
|
var allowInternal = opts.allowInternal != null ? opts.allowInternal : null;
|
|
680
705
|
var interpret = typeof opts.interpret === "function" ? opts.interpret : null;
|
|
@@ -776,7 +801,7 @@ function resendTransport(opts) {
|
|
|
776
801
|
name: "resend",
|
|
777
802
|
endpoint: opts.endpoint || "https://api.resend.com/emails",
|
|
778
803
|
method: "POST",
|
|
779
|
-
timeoutMs: opts.timeoutMs ||
|
|
804
|
+
timeoutMs: opts.timeoutMs || C.TIME.seconds(15),
|
|
780
805
|
allowedProtocols: opts.allowedProtocols || null,
|
|
781
806
|
allowInternal: opts.allowInternal != null ? opts.allowInternal : null,
|
|
782
807
|
headers: {
|
|
@@ -815,15 +840,18 @@ function resendTransport(opts) {
|
|
|
815
840
|
interpret: function (res) {
|
|
816
841
|
var text = res.body ? res.body.toString("utf8") : "";
|
|
817
842
|
var data;
|
|
843
|
+
// Cap on diagnostic-message snippet length (chars, not bytes) — keeps
|
|
844
|
+
// a hostile or huge backend response from blowing up the error message.
|
|
845
|
+
var DIAG_SNIPPET_LEN = 0xC8;
|
|
818
846
|
try { data = safeJson.parse(text, { maxBytes: MAIL_RESPONSE_MAX_BYTES }); }
|
|
819
847
|
catch (_e) {
|
|
820
848
|
throw new MailError("mail/resend-bad-response",
|
|
821
|
-
"resend response was not JSON: " + text.slice(0,
|
|
849
|
+
"resend response was not JSON: " + text.slice(0, DIAG_SNIPPET_LEN), false);
|
|
822
850
|
}
|
|
823
851
|
if (!data.id) {
|
|
824
852
|
return {
|
|
825
853
|
ok: false,
|
|
826
|
-
reason: data.message || JSON.stringify(data).slice(0,
|
|
854
|
+
reason: data.message || JSON.stringify(data).slice(0, DIAG_SNIPPET_LEN),
|
|
827
855
|
};
|
|
828
856
|
}
|
|
829
857
|
return { ok: true, id: data.id };
|
|
@@ -920,7 +948,7 @@ module.exports = {
|
|
|
920
948
|
// DKIM-Signature header generation for outbound mail (rsa-sha256
|
|
921
949
|
// default, ed25519-sha256 opt-in). Wire it into the smtp transport
|
|
922
950
|
// via opts.dkimSigner. See lib/mail-dkim.js for the full surface.
|
|
923
|
-
dkim:
|
|
951
|
+
dkim: mailDkim,
|
|
924
952
|
// Test-only export: lets unit tests inspect the wire format without
|
|
925
953
|
// standing up a TLS-capable SMTP fixture. Operators don't call this.
|
|
926
954
|
_buildRfc822ForTest: _buildRfc822,
|
package/lib/metrics.js
CHANGED
|
@@ -96,9 +96,12 @@
|
|
|
96
96
|
* hostname, container id) and the operator knows best.
|
|
97
97
|
*/
|
|
98
98
|
|
|
99
|
+
var C = require("./constants");
|
|
100
|
+
var canonicalJson = require("./canonical-json");
|
|
99
101
|
var { defineClass } = require("./framework-error");
|
|
100
102
|
var { boot } = require("./log");
|
|
101
|
-
var
|
|
103
|
+
var nb = require("./numeric-bounds");
|
|
104
|
+
var { resolveRoute, captureResponseStatus, HTTP_STATUS } = require("./request-helpers");
|
|
102
105
|
var validateOpts = require("./validate-opts");
|
|
103
106
|
|
|
104
107
|
var MetricsError = defineClass("MetricsError", { alwaysPermanent: true });
|
|
@@ -109,7 +112,11 @@ var DEFAULT_HTTP_BUCKETS = Object.freeze([
|
|
|
109
112
|
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10,
|
|
110
113
|
]);
|
|
111
114
|
|
|
112
|
-
var DEFAULT_CARDINALITY_CAP = 10000;
|
|
115
|
+
var DEFAULT_CARDINALITY_CAP = C.BYTES.bytes(10000);
|
|
116
|
+
// Bound metric / label names before regex test — DoS shape if an
|
|
117
|
+
// operator passed a multi-megabyte string. Prometheus exposition
|
|
118
|
+
// recommends short ascii names; 200 is a generous ceiling.
|
|
119
|
+
var MAX_METRIC_NAME_LEN = 200;
|
|
113
120
|
var METRIC_NAME_RE = /^[a-zA-Z_:][a-zA-Z0-9_:]*$/;
|
|
114
121
|
var LABEL_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
115
122
|
|
|
@@ -120,12 +127,13 @@ var LABEL_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
|
120
127
|
// metrics.create() replaces _activeTap with the registry-driven path,
|
|
121
128
|
// flowing taps into pre-registered counters / gauges / histograms.
|
|
122
129
|
//
|
|
123
|
-
//
|
|
130
|
+
// Drop-silent by design: the wrapping try/catch is intentional. tap is
|
|
124
131
|
// called from request hot paths where throwing on a misnamed metric
|
|
125
132
|
// (or a label whose value coerced unexpectedly) would crash the
|
|
126
|
-
// request that triggered it. The metric
|
|
127
|
-
// gauge / histogram) DOES throw on bad name/label-name regex —
|
|
128
|
-
//
|
|
133
|
+
// request that triggered it. The metric-registration path (counter /
|
|
134
|
+
// gauge / histogram) DOES throw on bad name/label-name regex — that's
|
|
135
|
+
// a config-time call where operators want fast failure; the runtime
|
|
136
|
+
// tap() is the drop-silent boundary.
|
|
129
137
|
|
|
130
138
|
var _activeTap = null;
|
|
131
139
|
|
|
@@ -138,15 +146,19 @@ function tap(name, value, labels) {
|
|
|
138
146
|
// ---- helpers ----
|
|
139
147
|
|
|
140
148
|
function _validateMetricName(name) {
|
|
141
|
-
if (typeof name !== "string" ||
|
|
149
|
+
if (typeof name !== "string" || name.length > MAX_METRIC_NAME_LEN ||
|
|
150
|
+
!METRIC_NAME_RE.test(name)) {
|
|
142
151
|
throw new MetricsError("metrics/bad-name",
|
|
143
|
-
"metric name '" + name + "' must match " + METRIC_NAME_RE
|
|
152
|
+
"metric name '" + name + "' must match " + METRIC_NAME_RE +
|
|
153
|
+
" (max " + MAX_METRIC_NAME_LEN + " chars)");
|
|
144
154
|
}
|
|
145
155
|
}
|
|
146
156
|
function _validateLabelName(name) {
|
|
147
|
-
if (typeof name !== "string" ||
|
|
157
|
+
if (typeof name !== "string" || name.length > MAX_METRIC_NAME_LEN ||
|
|
158
|
+
!LABEL_NAME_RE.test(name)) {
|
|
148
159
|
throw new MetricsError("metrics/bad-label",
|
|
149
|
-
"label name '" + name + "' must match " + LABEL_NAME_RE
|
|
160
|
+
"label name '" + name + "' must match " + LABEL_NAME_RE +
|
|
161
|
+
" (max " + MAX_METRIC_NAME_LEN + " chars)");
|
|
150
162
|
}
|
|
151
163
|
}
|
|
152
164
|
// Counter / gauge / histogram methods all accept (callLabels, value) but
|
|
@@ -172,16 +184,20 @@ function _validateLabelValue(value) {
|
|
|
172
184
|
return String(value);
|
|
173
185
|
}
|
|
174
186
|
|
|
175
|
-
// Serialize a labels object to canonical key
|
|
176
|
-
// the
|
|
187
|
+
// Serialize a labels object to a canonical Map key. Routed through
|
|
188
|
+
// canonical-json so the framework has one canonical-sort source of
|
|
189
|
+
// truth for sorted-keys serialization (avoiding the silent-data-loss
|
|
190
|
+
// class on Date / Buffer / Map / Set / BigInt values).
|
|
177
191
|
function _labelsKey(labels) {
|
|
178
192
|
if (!labels) return "";
|
|
179
|
-
|
|
180
|
-
|
|
193
|
+
// Coerce values to strings first so canonical-json's primitive
|
|
194
|
+
// serialization matches the Prometheus wire format.
|
|
195
|
+
var coerced = {};
|
|
196
|
+
var keys = Object.keys(labels);
|
|
181
197
|
for (var i = 0; i < keys.length; i++) {
|
|
182
|
-
|
|
198
|
+
coerced[keys[i]] = _validateLabelValue(labels[keys[i]]);
|
|
183
199
|
}
|
|
184
|
-
return
|
|
200
|
+
return canonicalJson.stringify(coerced);
|
|
185
201
|
}
|
|
186
202
|
|
|
187
203
|
// Escape a label value per the Prometheus exposition format.
|
|
@@ -189,17 +205,30 @@ function _escapeLabelValue(v) {
|
|
|
189
205
|
return String(v).replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, "\\\"");
|
|
190
206
|
}
|
|
191
207
|
|
|
208
|
+
// Wire-format renderer for Prometheus exposition. Determinism is
|
|
209
|
+
// shared with _labelsKey: both walk keys in lexicographic order via
|
|
210
|
+
// the same _sortedLabelKeys helper, so the canonical-sort source of
|
|
211
|
+
// truth is one helper instead of two duplicated walks.
|
|
192
212
|
function _renderLabels(labelObj) {
|
|
193
213
|
if (!labelObj) return "";
|
|
194
|
-
var
|
|
195
|
-
if (
|
|
214
|
+
var sortedKeys = _sortedLabelKeys(labelObj);
|
|
215
|
+
if (sortedKeys.length === 0) return "";
|
|
196
216
|
var parts = [];
|
|
197
|
-
for (var
|
|
198
|
-
parts.push(
|
|
217
|
+
for (var k = 0; k < sortedKeys.length; k++) {
|
|
218
|
+
parts.push(sortedKeys[k] + '="' + _escapeLabelValue(labelObj[sortedKeys[k]]) + '"');
|
|
199
219
|
}
|
|
200
220
|
return "{" + parts.join(",") + "}";
|
|
201
221
|
}
|
|
202
222
|
|
|
223
|
+
// Single canonical-sort source of truth for label keys. Both
|
|
224
|
+
// _labelsKey (Map key) and _renderLabels (Prometheus exposition)
|
|
225
|
+
// route through here so the framework has one walk shape.
|
|
226
|
+
function _sortedLabelKeys(labelObj) {
|
|
227
|
+
var keys = Object.keys(labelObj);
|
|
228
|
+
keys.sort();
|
|
229
|
+
return keys;
|
|
230
|
+
}
|
|
231
|
+
|
|
203
232
|
// Combine default + per-call labels, validating against the metric's
|
|
204
233
|
// declared labelNames. Throws if a label name isn't declared.
|
|
205
234
|
function _resolveLabels(defaultLabels, declaredNames, callLabels) {
|
|
@@ -238,8 +267,9 @@ function create(opts) {
|
|
|
238
267
|
], "b.metrics");
|
|
239
268
|
var namespace = opts.namespace || "";
|
|
240
269
|
var defaultLabels = opts.defaultLabels || {};
|
|
241
|
-
|
|
242
|
-
|
|
270
|
+
nb.requirePositiveFiniteIntIfPresent(opts.labelCardinalityCap,
|
|
271
|
+
"labelCardinalityCap", MetricsError, "metrics/bad-opt");
|
|
272
|
+
var cardinalityCap = opts.labelCardinalityCap || DEFAULT_CARDINALITY_CAP;
|
|
243
273
|
|
|
244
274
|
// Validate defaultLabels names.
|
|
245
275
|
var dlk = Object.keys(defaultLabels);
|
|
@@ -486,7 +516,7 @@ function create(opts) {
|
|
|
486
516
|
function expositionHandler() {
|
|
487
517
|
return function metricsHandler(req, res) {
|
|
488
518
|
var body = exposition();
|
|
489
|
-
res.writeHead(
|
|
519
|
+
res.writeHead(HTTP_STATUS.OK, {
|
|
490
520
|
"Content-Type": "text/plain; version=0.0.4; charset=utf-8",
|
|
491
521
|
"Content-Length": Buffer.byteLength(body),
|
|
492
522
|
"Cache-Control": "no-store",
|
|
@@ -523,8 +553,14 @@ function create(opts) {
|
|
|
523
553
|
var route = resolveRoute(req);
|
|
524
554
|
var labels = { method: req.method || "GET", route: route, status: String(status) };
|
|
525
555
|
var durLabels = { method: labels.method, route: route };
|
|
526
|
-
|
|
527
|
-
|
|
556
|
+
// Best-effort tap path — a label coercion or registry race
|
|
557
|
+
// must not crash the request that triggered it. Log so
|
|
558
|
+
// the operator sees the failure in the same channel as
|
|
559
|
+
// the rest of the framework's diagnostics.
|
|
560
|
+
try { requestsTotal.inc(labels); }
|
|
561
|
+
catch (e) { log.warn("metrics/request-counter-failed: " + e.message); }
|
|
562
|
+
try { requestDuration.observe(durLabels, elapsedSec); }
|
|
563
|
+
catch (e) { log.warn("metrics/request-duration-failed: " + e.message); }
|
|
528
564
|
});
|
|
529
565
|
return next();
|
|
530
566
|
};
|
|
@@ -114,8 +114,8 @@ var ApiEncryptError = defineClass("ApiEncryptError", { withStatusCode: true });
|
|
|
114
114
|
|
|
115
115
|
var DEFAULT_REPLAY_WINDOW_MS = C.TIME.minutes(5);
|
|
116
116
|
var DEFAULT_CONTENT_TYPES = ["application/json"];
|
|
117
|
-
var SESSION_KEY_BYTES = 32;
|
|
118
|
-
var REQUEST_NONCE_BYTES = 16;
|
|
117
|
+
var SESSION_KEY_BYTES = C.BYTES.bytes(32);
|
|
118
|
+
var REQUEST_NONCE_BYTES = C.BYTES.bytes(16);
|
|
119
119
|
|
|
120
120
|
function _err(code, message, statusCode) {
|
|
121
121
|
return new ApiEncryptError(code, message, true, statusCode || 400);
|
|
@@ -159,6 +159,8 @@ function _resolveKeypairs(opts) {
|
|
|
159
159
|
"apiEncrypt: { keypair } or { keypairs: [...] } is required", 500);
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
var HTTP_STATUS = requestHelpers.HTTP_STATUS;
|
|
163
|
+
|
|
162
164
|
function _writeRejection(res, code, body) {
|
|
163
165
|
if (res.headersSent || res.writableEnded) return;
|
|
164
166
|
if (typeof res.writeHead === "function") {
|
|
@@ -277,15 +279,15 @@ function create(opts) {
|
|
|
277
279
|
}
|
|
278
280
|
// Fallback if router didn't install res.json yet.
|
|
279
281
|
if (!res.headersSent) {
|
|
280
|
-
res.writeHead(res.statusCode ||
|
|
282
|
+
res.writeHead(res.statusCode || HTTP_STATUS.OK, { "Content-Type": "application/json" });
|
|
281
283
|
}
|
|
282
284
|
res.end(JSON.stringify(encrypted));
|
|
283
285
|
} catch (e) {
|
|
284
286
|
try {
|
|
285
287
|
logger().error("response encryption failed: " + ((e && e.message) || String(e)));
|
|
286
|
-
} catch (_e) {}
|
|
288
|
+
} catch (_e) { /* logger best-effort */ }
|
|
287
289
|
if (!res.headersSent) {
|
|
288
|
-
res.writeHead(
|
|
290
|
+
res.writeHead(HTTP_STATUS.INTERNAL_SERVER_ERROR, { "Content-Type": "application/json" });
|
|
289
291
|
}
|
|
290
292
|
res.end(JSON.stringify({ error: "response-encryption-failed" }));
|
|
291
293
|
}
|
|
@@ -299,20 +301,20 @@ function create(opts) {
|
|
|
299
301
|
var body = req.body;
|
|
300
302
|
if (!body || typeof body !== "object") {
|
|
301
303
|
_emitFailure(req, "shape");
|
|
302
|
-
return _writeRejection(res,
|
|
304
|
+
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
|
|
303
305
|
}
|
|
304
306
|
var ek = body._ek, ct = body._ct, ts = body._ts, nonce = body._nonce;
|
|
305
307
|
if (typeof ek !== "string" || typeof ct !== "string" ||
|
|
306
308
|
typeof ts !== "number" || typeof nonce !== "string") {
|
|
307
309
|
_emitFailure(req, "shape");
|
|
308
|
-
return _writeRejection(res,
|
|
310
|
+
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
|
|
309
311
|
}
|
|
310
312
|
|
|
311
313
|
// Replay window — must be within ±replayWindowMs of server clock.
|
|
312
314
|
var now = Date.now();
|
|
313
315
|
if (Math.abs(now - ts) > replayWindowMs) {
|
|
314
316
|
_emitFailure(req, "stale");
|
|
315
|
-
return _writeRejection(res,
|
|
317
|
+
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
|
|
316
318
|
}
|
|
317
319
|
|
|
318
320
|
// Nonce check + insert atomically. Loser of the insert race
|
|
@@ -327,11 +329,11 @@ function create(opts) {
|
|
|
327
329
|
try { freshNonce = await nonceStore.checkAndInsert(nonceHash, expireAt); }
|
|
328
330
|
catch (_e) {
|
|
329
331
|
_emitFailure(req, "nonce-store-error");
|
|
330
|
-
return _writeRejection(res,
|
|
332
|
+
return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "nonce-store-unavailable" });
|
|
331
333
|
}
|
|
332
334
|
if (!freshNonce) {
|
|
333
335
|
_emitFailure(req, "replay");
|
|
334
|
-
return _writeRejection(res,
|
|
336
|
+
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
|
|
335
337
|
}
|
|
336
338
|
|
|
337
339
|
// Decrypt _ek → session key. During a rotation overlap window,
|
|
@@ -352,7 +354,7 @@ function create(opts) {
|
|
|
352
354
|
}
|
|
353
355
|
if (!sessionKey) {
|
|
354
356
|
_emitFailure(req, "tag");
|
|
355
|
-
return _writeRejection(res,
|
|
357
|
+
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
|
|
356
358
|
}
|
|
357
359
|
|
|
358
360
|
// Decrypt _ct → cleartext payload bytes → JSON object.
|
|
@@ -363,7 +365,7 @@ function create(opts) {
|
|
|
363
365
|
clearObj = safeJson.parse(ptBuf.toString("utf8"), { maxBytes: maxDecryptedBytes });
|
|
364
366
|
} catch (_e) {
|
|
365
367
|
_emitFailure(req, "tag");
|
|
366
|
-
return _writeRejection(res,
|
|
368
|
+
return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
|
|
367
369
|
}
|
|
368
370
|
|
|
369
371
|
// Replace req.body with cleartext, stash session key for any
|
|
@@ -392,7 +394,7 @@ function create(opts) {
|
|
|
392
394
|
};
|
|
393
395
|
if (typeof res.json === "function") return res.json(body);
|
|
394
396
|
if (!res.headersSent) {
|
|
395
|
-
res.writeHead(
|
|
397
|
+
res.writeHead(HTTP_STATUS.OK, { "Content-Type": "application/json" });
|
|
396
398
|
}
|
|
397
399
|
res.end(JSON.stringify(body));
|
|
398
400
|
};
|