@blamejs/core 0.8.13 → 0.8.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 +6 -0
- package/README.md +3 -1
- package/index.js +12 -1
- package/lib/a2a.js +272 -0
- package/lib/ai-input.js +151 -0
- package/lib/audit.js +6 -0
- package/lib/dark-patterns.js +357 -0
- package/lib/framework-error.js +34 -0
- package/lib/graphql-federation.js +176 -0
- package/lib/http-client.js +16 -0
- package/lib/mail-auth.js +33 -10
- package/lib/mail-dkim.js +44 -2
- package/lib/mcp.js +301 -0
- package/lib/middleware/sse.js +18 -20
- package/lib/network-smtp-policy.js +57 -5
- package/lib/network-tls.js +33 -0
- package/lib/request-helpers.js +34 -0
- package/lib/router.js +28 -0
- package/lib/sse.js +349 -0
- package/lib/vault/index.js +4 -0
- package/lib/vault/seal-pem-file.js +283 -0
- package/lib/websocket.js +15 -0
- package/package.json +2 -2
- package/sbom.cyclonedx.json +6 -6
package/lib/mail-auth.js
CHANGED
|
@@ -109,8 +109,12 @@ function _parseSpfRecord(text) {
|
|
|
109
109
|
return mechanisms;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
// Fetch the SPF TXT record for a domain. Returns
|
|
113
|
-
// text
|
|
112
|
+
// Fetch the SPF TXT record for a domain. Returns:
|
|
113
|
+
// { kind: "found", record: "<text>" } — exactly one v=spf1 record
|
|
114
|
+
// { kind: "none" } — zero v=spf1 records
|
|
115
|
+
// { kind: "permerror", reason: "<msg>" } — multiple v=spf1 records
|
|
116
|
+
// (RFC 7208 §4.5 — domain
|
|
117
|
+
// MUST publish at most one)
|
|
114
118
|
async function _fetchSpfRecord(domain, dnsLookup) {
|
|
115
119
|
var records;
|
|
116
120
|
try {
|
|
@@ -118,17 +122,24 @@ async function _fetchSpfRecord(domain, dnsLookup) {
|
|
|
118
122
|
? await dnsLookup(domain, "TXT")
|
|
119
123
|
: await dnsPromises.resolveTxt(domain);
|
|
120
124
|
} catch (e) {
|
|
121
|
-
if (e && (e.code === "ENOTFOUND" || e.code === "ENODATA")) return
|
|
125
|
+
if (e && (e.code === "ENOTFOUND" || e.code === "ENODATA")) return { kind: "none" };
|
|
122
126
|
throw new MailAuthError("mail-auth/spf-lookup-failed",
|
|
123
127
|
"SPF TXT lookup for " + domain + " failed: " +
|
|
124
128
|
((e && e.message) || String(e)));
|
|
125
129
|
}
|
|
126
|
-
if (!Array.isArray(records)) return
|
|
130
|
+
if (!Array.isArray(records)) return { kind: "none" };
|
|
131
|
+
var matches = [];
|
|
127
132
|
for (var i = 0; i < records.length; i += 1) {
|
|
128
133
|
var rec = Array.isArray(records[i]) ? records[i].join("") : records[i];
|
|
129
|
-
if (typeof rec === "string" && rec.indexOf("v=spf1") === 0)
|
|
134
|
+
if (typeof rec === "string" && rec.indexOf("v=spf1") === 0) matches.push(rec);
|
|
130
135
|
}
|
|
131
|
-
return
|
|
136
|
+
if (matches.length === 0) return { kind: "none" };
|
|
137
|
+
if (matches.length > 1) {
|
|
138
|
+
return { kind: "permerror",
|
|
139
|
+
reason: "domain " + domain + " publishes " + matches.length +
|
|
140
|
+
" v=spf1 records; RFC 7208 §4.5 requires at most one" };
|
|
141
|
+
}
|
|
142
|
+
return { kind: "found", record: matches[0] };
|
|
132
143
|
}
|
|
133
144
|
|
|
134
145
|
// SPF verify — recursive include resolution + ip4/ip6/all/+a/+mx
|
|
@@ -166,17 +177,20 @@ async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups) {
|
|
|
166
177
|
}
|
|
167
178
|
lookups.count += 1;
|
|
168
179
|
|
|
169
|
-
var
|
|
170
|
-
try {
|
|
180
|
+
var fetched;
|
|
181
|
+
try { fetched = await _fetchSpfRecord(domain, dnsLookup); }
|
|
171
182
|
catch (e) {
|
|
172
183
|
return { verdict: "temperror", explanation: e.message };
|
|
173
184
|
}
|
|
174
|
-
if (
|
|
185
|
+
if (fetched.kind === "permerror") {
|
|
186
|
+
return { verdict: "permerror", explanation: fetched.reason };
|
|
187
|
+
}
|
|
188
|
+
if (fetched.kind === "none") {
|
|
175
189
|
return { verdict: "none", explanation: "no SPF record at " + domain };
|
|
176
190
|
}
|
|
177
191
|
|
|
178
192
|
var mechanisms;
|
|
179
|
-
try { mechanisms = _parseSpfRecord(record); }
|
|
193
|
+
try { mechanisms = _parseSpfRecord(fetched.record); }
|
|
180
194
|
catch (e) {
|
|
181
195
|
return { verdict: "permerror", explanation: e.message };
|
|
182
196
|
}
|
|
@@ -201,6 +215,15 @@ async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups) {
|
|
|
201
215
|
else if (inner.verdict === "permerror" || inner.verdict === "temperror") {
|
|
202
216
|
return inner;
|
|
203
217
|
}
|
|
218
|
+
// RFC 7208 §5.2 — when the included domain has no SPF record at
|
|
219
|
+
// all, the include itself MUST permerror (the included policy is
|
|
220
|
+
// missing, the operator's intent is unverifiable). Without this
|
|
221
|
+
// check `include:gone-domain.example` silently authorizes whatever
|
|
222
|
+
// mechanism follows, including `+all`.
|
|
223
|
+
else if (inner.verdict === "none") {
|
|
224
|
+
return { verdict: "permerror",
|
|
225
|
+
explanation: "include:" + m.arg + " has no SPF record (RFC 7208 §5.2)" };
|
|
226
|
+
}
|
|
204
227
|
} else if (m.mechanism === "a" || m.mechanism === "mx" ||
|
|
205
228
|
m.mechanism === "exists" || m.mechanism === "ptr" ||
|
|
206
229
|
m.mechanism === "redirect") {
|
package/lib/mail-dkim.js
CHANGED
|
@@ -216,9 +216,15 @@ function create(opts) {
|
|
|
216
216
|
throw new DkimError("dkim/bad-domain",
|
|
217
217
|
"domain must be a valid DNS name (e.g. 'example.com')");
|
|
218
218
|
}
|
|
219
|
-
|
|
219
|
+
// RFC 6376 §3.1 ABNF: selector = sub-domain *("." sub-domain). Multi-
|
|
220
|
+
// label selectors like "2024.s1" are valid (and common for time-rotated
|
|
221
|
+
// keys). Each label is the LDH set; refuse leading/trailing dots and
|
|
222
|
+
// empty labels.
|
|
223
|
+
if (typeof opts.selector !== "string" ||
|
|
224
|
+
opts.selector.length === 0 || opts.selector.length > 253 || // allow:raw-byte-literal — DNS label length cap (RFC 1035)
|
|
225
|
+
!/^[a-z0-9_-]+(?:\.[a-z0-9_-]+)*$/i.test(opts.selector)) {
|
|
220
226
|
throw new DkimError("dkim/bad-selector",
|
|
221
|
-
"selector must be a non-empty token
|
|
227
|
+
"selector must be a non-empty LDH token, optionally dot-separated (e.g. 's1', '2024.s1') (RFC 6376 §3.1)");
|
|
222
228
|
}
|
|
223
229
|
if (!opts.privateKey || (typeof opts.privateKey !== "string" &&
|
|
224
230
|
typeof opts.privateKey !== "object")) {
|
|
@@ -566,6 +572,14 @@ function _verifySingleSignature(rfc822, parsedHeaders, sigHeader, keyTags, sigTa
|
|
|
566
572
|
var headerNames = (sigTags.h || "").split(":").map(function (s) {
|
|
567
573
|
return s.trim().toLowerCase();
|
|
568
574
|
});
|
|
575
|
+
// RFC 6376 §3.5 — "from" MUST be in h=. Without From-coverage the
|
|
576
|
+
// signature does not bind to the visible sender, and the receiver's
|
|
577
|
+
// "this domain signed for that From" claim is meaningless. Cornerstone
|
|
578
|
+
// bypass class — refuse the signature outright.
|
|
579
|
+
if (headerNames.indexOf("from") === -1) {
|
|
580
|
+
return { result: "permerror",
|
|
581
|
+
errors: ["DKIM-Signature h= tag does not include 'from' (RFC 6376 §3.5)"] };
|
|
582
|
+
}
|
|
569
583
|
var lcNames = parsedHeaders.map(function (h) { return h.name.toLowerCase(); });
|
|
570
584
|
var canonicalizedHeaders = "";
|
|
571
585
|
for (var j = 0; j < headerNames.length; j += 1) {
|
|
@@ -659,6 +673,13 @@ async function verify(rfc822, opts) {
|
|
|
659
673
|
var d = sigTags.d;
|
|
660
674
|
var s = sigTags.s;
|
|
661
675
|
var alg = sigTags.a;
|
|
676
|
+
// RFC 6376 §3.5 — v= tag is REQUIRED and MUST be "1". Unrecognized
|
|
677
|
+
// version → permerror per spec; refuse rather than guess at intent.
|
|
678
|
+
if (sigTags.v !== undefined && sigTags.v !== "1") {
|
|
679
|
+
results.push({ d: d || null, s: s || null, alg: alg || null,
|
|
680
|
+
result: "permerror", errors: ["DKIM-Signature v=" + sigTags.v + " unsupported (RFC 6376 §3.5 — only v=1)"] });
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
662
683
|
if (!d || !s) {
|
|
663
684
|
results.push({ d: d || null, s: s || null, alg: alg || null,
|
|
664
685
|
result: "permerror", errors: ["DKIM-Signature missing d= or s="] });
|
|
@@ -671,11 +692,32 @@ async function verify(rfc822, opts) {
|
|
|
671
692
|
results.push({ d: d, s: s, alg: alg, result: verdict, errors: [e.message] });
|
|
672
693
|
continue;
|
|
673
694
|
}
|
|
695
|
+
if (keyTags.p === "") {
|
|
696
|
+
// RFC 6376 §3.6.1 — empty p= explicitly revokes the key. Verdict
|
|
697
|
+
// is "fail" (not "permerror") — the signature is well-formed but
|
|
698
|
+
// the key authority intentionally withdrew it.
|
|
699
|
+
results.push({ d: d, s: s, alg: alg, result: "fail",
|
|
700
|
+
errors: ["DKIM key revoked (empty p= per RFC 6376 §3.6.1)"] });
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
674
703
|
if (!keyTags.p) {
|
|
675
704
|
results.push({ d: d, s: s, alg: alg, result: "permerror",
|
|
676
705
|
errors: ["DKIM key record missing p="] });
|
|
677
706
|
continue;
|
|
678
707
|
}
|
|
708
|
+
// RFC 6376 §3.6.1 — k= tag declares the key's algorithm family.
|
|
709
|
+
// Default is "rsa" when absent. If the key's k= disagrees with the
|
|
710
|
+
// signature's a= family, the operator who published the key intends
|
|
711
|
+
// a different algorithm; refuse rather than guess.
|
|
712
|
+
if (keyTags.k !== undefined) {
|
|
713
|
+
var kFamily = String(keyTags.k).toLowerCase();
|
|
714
|
+
var sigFamily = String(alg || "").toLowerCase().split("-")[0];
|
|
715
|
+
if (kFamily !== sigFamily) {
|
|
716
|
+
results.push({ d: d, s: s, alg: alg, result: "permerror",
|
|
717
|
+
errors: ["DKIM key k=" + kFamily + " does not match signature a=" + alg + " (RFC 6376 §3.6.1)"] });
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
679
721
|
var rv = _verifySingleSignature(rfc822, parsedHeaders, sigHeaders[i], keyTags, sigTags);
|
|
680
722
|
results.push(Object.assign({ d: d, s: s, alg: alg }, rv));
|
|
681
723
|
}
|
package/lib/mcp.js
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Model Context Protocol server-guard primitive — hardens an HTTP
|
|
4
|
+
* endpoint that speaks MCP against the three CVE classes published in
|
|
5
|
+
* 2025-2026:
|
|
6
|
+
*
|
|
7
|
+
* - CVE-2026-33032 (CVSS 9.8, nginx-ui) — auth-bypass class:
|
|
8
|
+
* unauthenticated tool/resource invocations.
|
|
9
|
+
* - CVE-2025-6514 (CVSS 9.6, mcp-remote) — OAuth RCE class:
|
|
10
|
+
* consent-redirect with attacker-controlled redirect_uri.
|
|
11
|
+
* - Confused-deputy class — static client IDs combined with
|
|
12
|
+
* dynamic-client-registration AND opaque consent cookies.
|
|
13
|
+
*
|
|
14
|
+
* Public API:
|
|
15
|
+
*
|
|
16
|
+
* mcp.serverGuard(opts) -> middleware(req, res, next)
|
|
17
|
+
* opts:
|
|
18
|
+
* requireBearer — bool, default true.
|
|
19
|
+
* verifyBearer — async (token, req) -> claims | null.
|
|
20
|
+
* redirectUriAllowlist — Array<string> exact-match URIs.
|
|
21
|
+
* allowDynamicRegister — bool, default false.
|
|
22
|
+
* registerClientAllowlist — function(body) -> bool.
|
|
23
|
+
* toolAllowlist — Array<string> | null.
|
|
24
|
+
* resourceAllowlist — Array<string> | null.
|
|
25
|
+
* maxBodyBytes — default 1 MiB.
|
|
26
|
+
* errorClass — McpError by default.
|
|
27
|
+
* audit — bool, default true.
|
|
28
|
+
*
|
|
29
|
+
* mcp.parseRequest(body, opts) — JSON-RPC 2.0 envelope validator.
|
|
30
|
+
* mcp.refuse(res, code, message, id) — JSON-RPC error responder.
|
|
31
|
+
*
|
|
32
|
+
* The guard is the secure-by-default front door. Every default
|
|
33
|
+
* refuses; operators opt into capabilities deliberately.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
var C = require("./constants");
|
|
37
|
+
var nb = require("./numeric-bounds");
|
|
38
|
+
var safeUrl = require("./safe-url");
|
|
39
|
+
var safeJson = require("./safe-json");
|
|
40
|
+
var safeBuffer = require("./safe-buffer");
|
|
41
|
+
var requestHelpers = require("./request-helpers");
|
|
42
|
+
var audit = require("./audit");
|
|
43
|
+
var { McpError } = require("./framework-error");
|
|
44
|
+
|
|
45
|
+
var TOOL_NAME_MAX = 64; // allow:raw-byte-literal — string-length cap, not bytes
|
|
46
|
+
var RESOURCE_NAME_MAX = 256; // allow:raw-byte-literal — string-length cap, not bytes
|
|
47
|
+
var METHOD_NAME_MAX = 256; // allow:raw-byte-literal — string-length cap, not bytes
|
|
48
|
+
// JSON-RPC 2.0 error codes (https://www.jsonrpc.org/specification#error_object).
|
|
49
|
+
// Negative numerics by spec; mapped to HTTP status for the framework's
|
|
50
|
+
// HTTP-shaped reply envelope.
|
|
51
|
+
var JSONRPC_PARSE_ERROR = -32700; // allow:raw-byte-literal — JSON-RPC 2.0 fixed error code / allow:raw-time-literal — not seconds
|
|
52
|
+
var JSONRPC_INVALID_REQUEST = -32600; // allow:raw-byte-literal — JSON-RPC 2.0 fixed error code / allow:raw-time-literal — not seconds
|
|
53
|
+
var JSONRPC_METHOD_NOT_FOUND= -32601; // allow:raw-byte-literal — JSON-RPC 2.0 fixed error code / allow:raw-time-literal — not seconds
|
|
54
|
+
var JSONRPC_INVALID_PARAMS = -32602; // allow:raw-byte-literal — JSON-RPC 2.0 fixed error code / allow:raw-time-literal — not seconds
|
|
55
|
+
var JSONRPC_INTERNAL_ERROR = -32603; // allow:raw-byte-literal — JSON-RPC 2.0 fixed error code / allow:raw-time-literal — not seconds
|
|
56
|
+
var JSONRPC_AUTH_REQUIRED = -32001; // allow:raw-byte-literal — JSON-RPC server-error reserved range / allow:raw-time-literal — not seconds
|
|
57
|
+
var TOOL_NAME_RE = /^[a-zA-Z][a-zA-Z0-9._-]{0,63}$/;
|
|
58
|
+
var RESOURCE_NAME_RE = /^[a-zA-Z][a-zA-Z0-9._/-]{0,255}$/;
|
|
59
|
+
|
|
60
|
+
function parseRequest(body, opts) {
|
|
61
|
+
opts = opts || {};
|
|
62
|
+
var errorClass = opts.errorClass || McpError;
|
|
63
|
+
var parsed;
|
|
64
|
+
try {
|
|
65
|
+
parsed = typeof body === "string" ? safeJson.parse(body, { maxBytes: C.BYTES.mib(1) }) : body; // allow:JSON.parse — routed via safeJson.parse
|
|
66
|
+
} catch (_e) {
|
|
67
|
+
throw errorClass.factory("BAD_JSON",
|
|
68
|
+
"mcp.parseRequest: body is not valid JSON");
|
|
69
|
+
}
|
|
70
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
71
|
+
throw errorClass.factory("BAD_ENVELOPE",
|
|
72
|
+
"mcp.parseRequest: request must be a JSON-RPC object");
|
|
73
|
+
}
|
|
74
|
+
if (parsed.jsonrpc !== "2.0") {
|
|
75
|
+
throw errorClass.factory("BAD_VERSION",
|
|
76
|
+
"mcp.parseRequest: jsonrpc must be \"2.0\"");
|
|
77
|
+
}
|
|
78
|
+
if (typeof parsed.method !== "string" || parsed.method.length === 0 ||
|
|
79
|
+
parsed.method.length > METHOD_NAME_MAX) {
|
|
80
|
+
throw errorClass.factory("BAD_METHOD",
|
|
81
|
+
"mcp.parseRequest: method must be a non-empty string under 256 bytes");
|
|
82
|
+
}
|
|
83
|
+
if (parsed.id !== undefined && parsed.id !== null &&
|
|
84
|
+
typeof parsed.id !== "string" && typeof parsed.id !== "number") {
|
|
85
|
+
throw errorClass.factory("BAD_ID",
|
|
86
|
+
"mcp.parseRequest: id must be string, number, or null");
|
|
87
|
+
}
|
|
88
|
+
if (parsed.params !== undefined && parsed.params !== null &&
|
|
89
|
+
typeof parsed.params !== "object") {
|
|
90
|
+
throw errorClass.factory("BAD_PARAMS",
|
|
91
|
+
"mcp.parseRequest: params must be object or array");
|
|
92
|
+
}
|
|
93
|
+
return parsed;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function refuse(res, code, message, id) {
|
|
97
|
+
var body = JSON.stringify({
|
|
98
|
+
jsonrpc: "2.0",
|
|
99
|
+
error: { code: code, message: message },
|
|
100
|
+
id: id === undefined ? null : id,
|
|
101
|
+
});
|
|
102
|
+
if (typeof res.setHeader === "function") {
|
|
103
|
+
res.setHeader("Content-Type", "application/json");
|
|
104
|
+
}
|
|
105
|
+
// HTTP status mapping for the JSON-RPC error code we reply with.
|
|
106
|
+
res.statusCode = code === JSONRPC_PARSE_ERROR || code === JSONRPC_INVALID_REQUEST ? 400 : // allow:raw-byte-literal — HTTP status code (RFC 9110)
|
|
107
|
+
code === JSONRPC_METHOD_NOT_FOUND ? 404 : // allow:raw-byte-literal — HTTP status code (RFC 9110)
|
|
108
|
+
code === JSONRPC_INTERNAL_ERROR ? 500 : 400; // allow:raw-byte-literal — HTTP status code (RFC 9110)
|
|
109
|
+
res.end(body);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function _readBearer(req) {
|
|
113
|
+
var h = req.headers && req.headers.authorization;
|
|
114
|
+
if (typeof h !== "string") return null;
|
|
115
|
+
if (h.length > C.BYTES.kib(8)) return null;
|
|
116
|
+
var m = /^Bearer\s+([A-Za-z0-9._~+/=-]+)$/.exec(h.trim());
|
|
117
|
+
return m ? m[1] : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function _readBodyBuffered(req, maxBytes, errorClass) {
|
|
121
|
+
if (req.body !== undefined && req.body !== null) {
|
|
122
|
+
return Promise.resolve(req.body);
|
|
123
|
+
}
|
|
124
|
+
return new Promise(function (resolve, reject) {
|
|
125
|
+
var collector = safeBuffer.boundedChunkCollector({ maxBytes: maxBytes });
|
|
126
|
+
req.on("data", function (chunk) {
|
|
127
|
+
try { collector.push(chunk); }
|
|
128
|
+
catch (_e) {
|
|
129
|
+
req.destroy();
|
|
130
|
+
reject(errorClass.factory("BODY_TOO_LARGE",
|
|
131
|
+
"mcp: request body exceeds " + maxBytes + " bytes"));
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
req.on("end", function () { resolve(collector.result().toString("utf8")); });
|
|
135
|
+
req.on("error", reject);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function _checkRedirectUri(uri, allowlist, errorClass) {
|
|
140
|
+
if (typeof uri !== "string") {
|
|
141
|
+
throw errorClass.factory("BAD_REDIRECT_URI",
|
|
142
|
+
"mcp: redirect_uri must be a string");
|
|
143
|
+
}
|
|
144
|
+
if (!Array.isArray(allowlist) || allowlist.indexOf(uri) === -1) {
|
|
145
|
+
throw errorClass.factory("REDIRECT_URI_REFUSED",
|
|
146
|
+
"mcp: redirect_uri not in allowlist (OAuth 2.1 / RFC 9700 sec 4.1.1)");
|
|
147
|
+
}
|
|
148
|
+
var parsed;
|
|
149
|
+
try { parsed = safeUrl.parse(uri); }
|
|
150
|
+
catch (_e) {
|
|
151
|
+
throw errorClass.factory("BAD_REDIRECT_URI",
|
|
152
|
+
"mcp: redirect_uri did not parse");
|
|
153
|
+
}
|
|
154
|
+
var isHttps = parsed.protocol === "https:";
|
|
155
|
+
var isLocal = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" ||
|
|
156
|
+
parsed.hostname === "::1";
|
|
157
|
+
if (!isHttps && !isLocal) {
|
|
158
|
+
throw errorClass.factory("INSECURE_REDIRECT_URI",
|
|
159
|
+
"mcp: redirect_uri must be HTTPS (or localhost; RFC 9700 sec 4.1.1)");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function serverGuard(opts) {
|
|
164
|
+
opts = opts || {};
|
|
165
|
+
var errorClass = opts.errorClass || McpError;
|
|
166
|
+
var requireBearer = opts.requireBearer !== false;
|
|
167
|
+
var verifyBearer = opts.verifyBearer || null;
|
|
168
|
+
if (requireBearer && typeof verifyBearer !== "function") {
|
|
169
|
+
throw errorClass.factory("BAD_OPTS",
|
|
170
|
+
"mcp.serverGuard: verifyBearer required when requireBearer=true");
|
|
171
|
+
}
|
|
172
|
+
var redirectUriAllowlist = Array.isArray(opts.redirectUriAllowlist)
|
|
173
|
+
? opts.redirectUriAllowlist.slice() : [];
|
|
174
|
+
var allowDynamicRegister = opts.allowDynamicRegister === true;
|
|
175
|
+
var registerClientAllowlist = typeof opts.registerClientAllowlist === "function"
|
|
176
|
+
? opts.registerClientAllowlist : null;
|
|
177
|
+
if (allowDynamicRegister && !registerClientAllowlist) {
|
|
178
|
+
throw errorClass.factory("BAD_OPTS",
|
|
179
|
+
"mcp.serverGuard: allowDynamicRegister=true requires registerClientAllowlist function");
|
|
180
|
+
}
|
|
181
|
+
var toolAllowlist = Array.isArray(opts.toolAllowlist) ? opts.toolAllowlist : null;
|
|
182
|
+
var resourceAllowlist = Array.isArray(opts.resourceAllowlist) ? opts.resourceAllowlist : null;
|
|
183
|
+
nb.requirePositiveFiniteIntIfPresent(opts.maxBodyBytes, "mcp.serverGuard: opts.maxBodyBytes", errorClass, "BAD_MAX_BYTES");
|
|
184
|
+
var maxBodyBytes = opts.maxBodyBytes || C.BYTES.mib(1);
|
|
185
|
+
var auditOn = opts.audit !== false;
|
|
186
|
+
|
|
187
|
+
function _emitDenied(req, action, reason, metadata) {
|
|
188
|
+
if (!auditOn) return;
|
|
189
|
+
audit.safeEmit({
|
|
190
|
+
action: action,
|
|
191
|
+
outcome: "denied",
|
|
192
|
+
reason: reason,
|
|
193
|
+
metadata: Object.assign({
|
|
194
|
+
ip: requestHelpers.clientIp(req),
|
|
195
|
+
path: req && req.url,
|
|
196
|
+
}, metadata || {}),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return function mcpGuard(req, res, next) {
|
|
201
|
+
Promise.resolve().then(function () {
|
|
202
|
+
var token = _readBearer(req);
|
|
203
|
+
if (requireBearer) {
|
|
204
|
+
if (!token) {
|
|
205
|
+
_emitDenied(req, "mcp.auth.missing-bearer", "no bearer", {});
|
|
206
|
+
if (typeof res.setHeader === "function") {
|
|
207
|
+
res.setHeader("WWW-Authenticate",
|
|
208
|
+
"Bearer realm=\"mcp\", error=\"invalid_request\"");
|
|
209
|
+
}
|
|
210
|
+
return refuse(res, JSONRPC_AUTH_REQUIRED, "authentication required");
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
var claimsPromise = token && verifyBearer
|
|
214
|
+
? Promise.resolve(verifyBearer(token, req))
|
|
215
|
+
: Promise.resolve(null);
|
|
216
|
+
|
|
217
|
+
return claimsPromise.then(function (claims) {
|
|
218
|
+
if (requireBearer && !claims) {
|
|
219
|
+
_emitDenied(req, "mcp.auth.invalid-bearer", "bearer rejected", {});
|
|
220
|
+
if (typeof res.setHeader === "function") {
|
|
221
|
+
res.setHeader("WWW-Authenticate",
|
|
222
|
+
"Bearer realm=\"mcp\", error=\"invalid_token\"");
|
|
223
|
+
}
|
|
224
|
+
return refuse(res, JSONRPC_AUTH_REQUIRED, "authentication failed");
|
|
225
|
+
}
|
|
226
|
+
req.mcpClaims = claims || null;
|
|
227
|
+
|
|
228
|
+
var path = String(req.url || "").split("?")[0];
|
|
229
|
+
if (path === "/register" || path.endsWith("/register")) {
|
|
230
|
+
if (!allowDynamicRegister) {
|
|
231
|
+
_emitDenied(req, "mcp.register.refused-static", "dynamic registration disabled", { path: path });
|
|
232
|
+
return refuse(res, JSONRPC_METHOD_NOT_FOUND, "dynamic client registration is not permitted");
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return _readBodyBuffered(req, maxBodyBytes, errorClass).then(function (rawBody) {
|
|
237
|
+
var parsed;
|
|
238
|
+
try { parsed = parseRequest(rawBody, { errorClass: errorClass }); }
|
|
239
|
+
catch (e) {
|
|
240
|
+
_emitDenied(req, "mcp.envelope.refused", e.message, {});
|
|
241
|
+
return refuse(res, JSONRPC_PARSE_ERROR, e.message);
|
|
242
|
+
}
|
|
243
|
+
var method = parsed.method;
|
|
244
|
+
var params = parsed.params || {};
|
|
245
|
+
|
|
246
|
+
if (params && typeof params === "object" && params.redirect_uri !== undefined) {
|
|
247
|
+
try { _checkRedirectUri(params.redirect_uri, redirectUriAllowlist, errorClass); }
|
|
248
|
+
catch (e) {
|
|
249
|
+
_emitDenied(req, "mcp.redirect-uri.refused", e.message,
|
|
250
|
+
{ redirectUri: params.redirect_uri });
|
|
251
|
+
return refuse(res, JSONRPC_INVALID_PARAMS, e.message, parsed.id);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (method === "tools/call") {
|
|
256
|
+
var toolName = params && typeof params === "object" ? params.name : null;
|
|
257
|
+
if (typeof toolName !== "string" || toolName.length > TOOL_NAME_MAX || !TOOL_NAME_RE.test(toolName)) {
|
|
258
|
+
_emitDenied(req, "mcp.tool.bad-name", "tool name shape", { toolName: toolName });
|
|
259
|
+
return refuse(res, JSONRPC_INVALID_PARAMS, "tool name malformed", parsed.id);
|
|
260
|
+
}
|
|
261
|
+
if (toolAllowlist && toolAllowlist.indexOf(toolName) === -1) {
|
|
262
|
+
_emitDenied(req, "mcp.tool.refused", "not in allowlist", { toolName: toolName });
|
|
263
|
+
return refuse(res, JSONRPC_METHOD_NOT_FOUND, "tool not permitted", parsed.id);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (method === "resources/read") {
|
|
267
|
+
var resourceUri = params && typeof params === "object" ? params.uri : null;
|
|
268
|
+
if (typeof resourceUri !== "string" || resourceUri.length > RESOURCE_NAME_MAX || !RESOURCE_NAME_RE.test(resourceUri)) {
|
|
269
|
+
_emitDenied(req, "mcp.resource.bad-uri", "resource uri shape", { resourceUri: resourceUri });
|
|
270
|
+
return refuse(res, JSONRPC_INVALID_PARAMS, "resource uri malformed", parsed.id);
|
|
271
|
+
}
|
|
272
|
+
if (resourceAllowlist && resourceAllowlist.indexOf(resourceUri) === -1) {
|
|
273
|
+
_emitDenied(req, "mcp.resource.refused", "not in allowlist", { resourceUri: resourceUri });
|
|
274
|
+
return refuse(res, JSONRPC_METHOD_NOT_FOUND, "resource not permitted", parsed.id);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
req.mcpRequest = parsed;
|
|
279
|
+
if (auditOn) {
|
|
280
|
+
audit.safeEmit({
|
|
281
|
+
action: "mcp.request",
|
|
282
|
+
outcome: "success",
|
|
283
|
+
metadata: { method: method, hasClaims: !!claims },
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
if (typeof next === "function") next();
|
|
287
|
+
else if (!res.writableEnded) refuse(res, JSONRPC_METHOD_NOT_FOUND, "handler not wired");
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
}).catch(function (err) {
|
|
291
|
+
_emitDenied(req, "mcp.guard.error", err.message || "guard error", {});
|
|
292
|
+
if (!res.writableEnded) refuse(res, JSONRPC_INTERNAL_ERROR, "internal guard error");
|
|
293
|
+
});
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
module.exports = {
|
|
298
|
+
serverGuard: serverGuard,
|
|
299
|
+
parseRequest: parseRequest,
|
|
300
|
+
refuse: refuse,
|
|
301
|
+
};
|
package/lib/middleware/sse.js
CHANGED
|
@@ -38,32 +38,30 @@
|
|
|
38
38
|
var C = require("../constants");
|
|
39
39
|
var requestHelpers = require("../request-helpers");
|
|
40
40
|
var safeBuffer = require("../safe-buffer");
|
|
41
|
+
var sse = require("../sse");
|
|
41
42
|
var validateOpts = require("../validate-opts");
|
|
42
43
|
|
|
43
44
|
var DEFAULT_HEARTBEAT_MS = C.TIME.seconds(15);
|
|
44
45
|
|
|
46
|
+
// _formatEvent — REFUSES on CRLF/NUL injection in event/id (CVE-2026-
|
|
47
|
+
// 33128 / 29085 / 44217 class). Pre-v0.8.15 this stripped CRLF
|
|
48
|
+
// silently; the strip-instead-of-refuse behavior was the
|
|
49
|
+
// vulnerability. The framework now refuses at the source, returning
|
|
50
|
+
// the operator a clear error code so the caller knows the event was
|
|
51
|
+
// rejected. Validation routes through b.sse.serializeEvent so the
|
|
52
|
+
// middleware surface and the low-level surface share one policy.
|
|
45
53
|
function _formatEvent(msg) {
|
|
46
|
-
|
|
47
|
-
// Lines: "id: <n>\n", "event: <name>\n", "data: <line>\n" (multi-line
|
|
48
|
-
// data is multiple "data: " lines), "retry: <ms>\n", blank line ends.
|
|
49
|
-
var out = "";
|
|
50
|
-
if (msg.id !== undefined && msg.id !== null) out += "id: " + safeBuffer.stripCrlf(String(msg.id)) + "\n";
|
|
51
|
-
if (msg.event) out += "event: " + safeBuffer.stripCrlf(String(msg.event)) + "\n";
|
|
52
|
-
if (msg.retry !== undefined && msg.retry !== null) {
|
|
53
|
-
if (typeof msg.retry !== "number" || !isFinite(msg.retry) || msg.retry < 0) {
|
|
54
|
-
throw new Error("sse: retry must be a non-negative finite number of milliseconds");
|
|
55
|
-
}
|
|
56
|
-
out += "retry: " + Math.floor(msg.retry) + "\n";
|
|
57
|
-
}
|
|
54
|
+
var coerced = msg || {};
|
|
58
55
|
var dataStr;
|
|
59
|
-
if (
|
|
60
|
-
else if (typeof
|
|
61
|
-
else
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
56
|
+
if (coerced.data === undefined || coerced.data === null) dataStr = "";
|
|
57
|
+
else if (typeof coerced.data === "string") dataStr = coerced.data;
|
|
58
|
+
else dataStr = JSON.stringify(coerced.data);
|
|
59
|
+
return sse.serializeEvent({
|
|
60
|
+
id: coerced.id !== undefined && coerced.id !== null ? String(coerced.id) : undefined,
|
|
61
|
+
event: coerced.event !== undefined && coerced.event !== null ? String(coerced.event) : undefined,
|
|
62
|
+
retry: coerced.retry,
|
|
63
|
+
data: dataStr,
|
|
64
|
+
});
|
|
67
65
|
}
|
|
68
66
|
|
|
69
67
|
function create(handler, opts) {
|
|
@@ -117,13 +117,51 @@ function _parseStsPolicy(text) {
|
|
|
117
117
|
return policy;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
// RFC 8461 §3.1 precondition. The TXT record at _mta-sts.<domain> is
|
|
121
|
+
// the rotation signal: receivers re-fetch the HTTPS policy when the
|
|
122
|
+
// `id=` value changes. Without it the fetcher would re-pull the same
|
|
123
|
+
// cached policy forever (defeating operator rotation), and would also
|
|
124
|
+
// fetch policies from domains that don't publish one.
|
|
125
|
+
async function _fetchStsTxt(domain, dnsLookup) {
|
|
126
|
+
var records;
|
|
127
|
+
try {
|
|
128
|
+
records = dnsLookup
|
|
129
|
+
? await dnsLookup("_mta-sts." + domain, "TXT")
|
|
130
|
+
: await dnsPromises.resolveTxt("_mta-sts." + domain);
|
|
131
|
+
} catch (e) {
|
|
132
|
+
if (e && (e.code === "ENOTFOUND" || e.code === "ENODATA")) return null;
|
|
133
|
+
throw new SmtpPolicyError("smtp/mta-sts-txt-lookup-failed",
|
|
134
|
+
"_mta-sts." + domain + " TXT lookup failed: " +
|
|
135
|
+
((e && e.message) || String(e)));
|
|
136
|
+
}
|
|
137
|
+
if (!Array.isArray(records)) return null;
|
|
138
|
+
for (var i = 0; i < records.length; i += 1) {
|
|
139
|
+
var rec = Array.isArray(records[i]) ? records[i].join("") : records[i];
|
|
140
|
+
if (typeof rec !== "string") continue;
|
|
141
|
+
if (rec.indexOf("v=STSv1") === -1) continue;
|
|
142
|
+
var idMatch = /\bid=([A-Za-z0-9]{1,32})/.exec(rec);
|
|
143
|
+
return { record: rec, id: idMatch ? idMatch[1] : null };
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function mtaStsFetch(domain, opts) {
|
|
121
149
|
if (typeof domain !== "string" || domain.length === 0) {
|
|
122
150
|
throw new SmtpPolicyError("smtp/bad-domain",
|
|
123
151
|
"mtaSts.fetch: domain must be a non-empty string");
|
|
124
152
|
}
|
|
153
|
+
opts = opts || {};
|
|
125
154
|
var lcDomain = domain.toLowerCase();
|
|
126
|
-
|
|
155
|
+
// RFC 8461 §3.1 — refuse to fetch the HTTPS policy if the
|
|
156
|
+
// _mta-sts TXT record is absent. Closes the silent-escalation
|
|
157
|
+
// class.
|
|
158
|
+
var txt = await _fetchStsTxt(lcDomain, opts.dnsLookup);
|
|
159
|
+
if (!txt) return null;
|
|
160
|
+
|
|
161
|
+
// Cache key includes the policy id so operator-side rotations (id
|
|
162
|
+
// changes) invalidate the cached policy without operator action.
|
|
163
|
+
var cacheKey = lcDomain + "|" + (txt.id || "noid");
|
|
164
|
+
return await _getStsCache().wrap(cacheKey, async function () {
|
|
127
165
|
var url = "https://mta-sts." + lcDomain + "/.well-known/mta-sts.txt";
|
|
128
166
|
safeUrl.parse(url, { allowedProtocols: safeUrl.ALLOW_HTTP_TLS });
|
|
129
167
|
var res;
|
|
@@ -135,8 +173,6 @@ async function mtaStsFetch(domain) {
|
|
|
135
173
|
timeoutMs: C.TIME.seconds(10),
|
|
136
174
|
});
|
|
137
175
|
} catch (_e) {
|
|
138
|
-
// Domain doesn't publish MTA-STS — return null (not an error;
|
|
139
|
-
// operators decide policy via their own gate).
|
|
140
176
|
return null;
|
|
141
177
|
}
|
|
142
178
|
if (res.statusCode === 404) return null; // allow:raw-byte-literal — HTTP 404
|
|
@@ -144,7 +180,23 @@ async function mtaStsFetch(domain) {
|
|
|
144
180
|
throw new SmtpPolicyError("smtp/mta-sts-fetch-failed",
|
|
145
181
|
"MTA-STS fetch returned " + res.statusCode + " for " + url);
|
|
146
182
|
}
|
|
147
|
-
|
|
183
|
+
var parsed = _parseStsPolicy(res.body.toString("utf8"));
|
|
184
|
+
parsed.id = txt.id || null;
|
|
185
|
+
parsed.fetchedAt = Date.now();
|
|
186
|
+
// RFC 8461 §3.2 — max_age caps the cache TTL. Bound between 1 hour
|
|
187
|
+
// (floor — operators using shorter values are below the spec
|
|
188
|
+
// recommended floor) and 31557600 seconds (RFC 8461 ceiling). When
|
|
189
|
+
// max_age is missing, fall back to the framework default.
|
|
190
|
+
var maxAgeSec = parsed.max_age;
|
|
191
|
+
if (typeof maxAgeSec === "number" && isFinite(maxAgeSec) && maxAgeSec > 0) {
|
|
192
|
+
var hourSec = C.TIME.hours(1) / C.TIME.seconds(1);
|
|
193
|
+
var ceilingSec = C.TIME.weeks(52) / C.TIME.seconds(1); // RFC 8461 §3.2 — ~1 year ceiling
|
|
194
|
+
var clamped = Math.max(hourSec, Math.min(ceilingSec, maxAgeSec));
|
|
195
|
+
parsed._cacheTtlMs = clamped * C.TIME.seconds(1);
|
|
196
|
+
} else {
|
|
197
|
+
parsed._cacheTtlMs = DEFAULT_POLICY_CACHE_MS;
|
|
198
|
+
}
|
|
199
|
+
return parsed;
|
|
148
200
|
});
|
|
149
201
|
}
|
|
150
202
|
|
package/lib/network-tls.js
CHANGED
|
@@ -882,6 +882,39 @@ function evaluateOcspResponse(ocspDer, opts) {
|
|
|
882
882
|
} else if (parsed.basic.nonce) {
|
|
883
883
|
nonceCheck = "present-not-checked";
|
|
884
884
|
}
|
|
885
|
+
// RFC 6960 §4.2.2.1 — time-window enforcement. A "good" response is
|
|
886
|
+
// valid only between thisUpdate and nextUpdate (with operator-tunable
|
|
887
|
+
// skew). Without this check a stapled response is replayable forever:
|
|
888
|
+
// an attacker captures a pre-revocation "good" reply, the cert later
|
|
889
|
+
// gets revoked, the attacker keeps presenting the cached "good" and
|
|
890
|
+
// the framework keeps accepting it. requireGood postures depend on
|
|
891
|
+
// freshness — reject expired or future-dated responses outright.
|
|
892
|
+
var clockSkewMs = typeof opts.clockSkewMs === "number" && opts.clockSkewMs >= 0 // allow:numeric-opt-Infinity — operator-supplied skew, default 5 min if absent or invalid
|
|
893
|
+
? opts.clockSkewMs : C.TIME.minutes(5);
|
|
894
|
+
var now = typeof opts.now === "number" ? opts.now : Date.now();
|
|
895
|
+
var thisUpdateMs = match.thisUpdate ? Date.parse(match.thisUpdate) : NaN;
|
|
896
|
+
var nextUpdateMs = match.nextUpdate ? Date.parse(match.nextUpdate) : NaN;
|
|
897
|
+
if (!isFinite(thisUpdateMs)) {
|
|
898
|
+
return { ok: false, status: parsed.status, signatureValid: true,
|
|
899
|
+
certStatus: match.certStatus,
|
|
900
|
+
thisUpdate: match.thisUpdate, nextUpdate: match.nextUpdate,
|
|
901
|
+
nonce: nonceCheck,
|
|
902
|
+
errors: ["OCSP response missing thisUpdate (RFC 6960 §4.2.2.1)"] };
|
|
903
|
+
}
|
|
904
|
+
if (thisUpdateMs - clockSkewMs > now) {
|
|
905
|
+
return { ok: false, status: parsed.status, signatureValid: true,
|
|
906
|
+
certStatus: match.certStatus,
|
|
907
|
+
thisUpdate: match.thisUpdate, nextUpdate: match.nextUpdate,
|
|
908
|
+
nonce: nonceCheck,
|
|
909
|
+
errors: ["OCSP thisUpdate is in the future (RFC 6960 §4.2.2.1 — possible clock skew or response replay)"] };
|
|
910
|
+
}
|
|
911
|
+
if (isFinite(nextUpdateMs) && nextUpdateMs + clockSkewMs < now) {
|
|
912
|
+
return { ok: false, status: parsed.status, signatureValid: true,
|
|
913
|
+
certStatus: match.certStatus,
|
|
914
|
+
thisUpdate: match.thisUpdate, nextUpdate: match.nextUpdate,
|
|
915
|
+
nonce: nonceCheck,
|
|
916
|
+
errors: ["OCSP response is past nextUpdate (RFC 6960 §4.2.2.1 — stale response, possible replay)"] };
|
|
917
|
+
}
|
|
885
918
|
return {
|
|
886
919
|
ok: match.certStatus === "good",
|
|
887
920
|
status: parsed.status,
|