@blamejs/core 0.8.12 → 0.8.15
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 +230 -33
- package/lib/mcp.js +301 -0
- package/lib/middleware/sse.js +18 -20
- 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/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) {
|
package/lib/request-helpers.js
CHANGED
|
@@ -325,6 +325,38 @@ function parseQualityList(headerValue, opts) {
|
|
|
325
325
|
return out;
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
+
// safeHeadersDistinct(req) — defensive accessor for req.headersDistinct.
|
|
329
|
+
//
|
|
330
|
+
// Node CVE-2026-21710: req.headersDistinct is a getter; reading
|
|
331
|
+
// __proto__ on the underlying header bag throws synchronously inside
|
|
332
|
+
// the getter, so a request bearing a __proto__: header escapes any
|
|
333
|
+
// handler-level try/catch (the throw happens at property-access time,
|
|
334
|
+
// not later). This helper computes the same shape (lowercased header-
|
|
335
|
+
// name → array of values) directly from req.rawHeaders, bypassing the
|
|
336
|
+
// faulty getter entirely.
|
|
337
|
+
//
|
|
338
|
+
// Returns a null-prototype object so framework code can iterate its
|
|
339
|
+
// keys without inheriting Object.prototype properties — the same shape
|
|
340
|
+
// Node's headersDistinct produces, minus the throwing getter.
|
|
341
|
+
function safeHeadersDistinct(req) {
|
|
342
|
+
var out = Object.create(null);
|
|
343
|
+
if (!req || !Array.isArray(req.rawHeaders)) return out;
|
|
344
|
+
var raw = req.rawHeaders;
|
|
345
|
+
for (var i = 0; i + 1 < raw.length; i += 2) {
|
|
346
|
+
var name = raw[i];
|
|
347
|
+
var value = raw[i + 1];
|
|
348
|
+
if (typeof name !== "string" || typeof value !== "string") continue;
|
|
349
|
+
var lower = name.toLowerCase();
|
|
350
|
+
// skip __proto__ / constructor / prototype as keys — they are the
|
|
351
|
+
// exact strings that triggered the upstream getter throw, and we
|
|
352
|
+
// refuse to surface them as accessible header names.
|
|
353
|
+
if (lower === "__proto__" || lower === "constructor" || lower === "prototype") continue;
|
|
354
|
+
if (out[lower]) out[lower].push(value);
|
|
355
|
+
else out[lower] = [value];
|
|
356
|
+
}
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
|
|
328
360
|
module.exports = {
|
|
329
361
|
resolveRoute: resolveRoute,
|
|
330
362
|
captureResponseStatus: captureResponseStatus,
|
|
@@ -336,5 +368,7 @@ module.exports = {
|
|
|
336
368
|
clientIp: clientIp,
|
|
337
369
|
requestProtocol: requestProtocol,
|
|
338
370
|
appendVary: appendVary,
|
|
371
|
+
// CVE-2026-21710 wrap — safe alternative to req.headersDistinct
|
|
372
|
+
safeHeadersDistinct: safeHeadersDistinct,
|
|
339
373
|
HTTP_STATUS: HTTP_STATUS,
|
|
340
374
|
};
|
package/lib/router.js
CHANGED
|
@@ -620,6 +620,34 @@ class Router {
|
|
|
620
620
|
};
|
|
621
621
|
var server;
|
|
622
622
|
if (tlsOptions) {
|
|
623
|
+
// CVE-2026-21637 — Node propagates synchronous throws from a
|
|
624
|
+
// user-supplied SNICallback up through the TLS handshake
|
|
625
|
+
// listener; an unhandled throw on an unexpected servername
|
|
626
|
+
// crashes the listener. Wrap the operator's SNICallback so any
|
|
627
|
+
// synchronous error becomes a clean async (err, null) callback.
|
|
628
|
+
// RFC 6066 §3 expects the server to abort the handshake on a
|
|
629
|
+
// failed callback, not crash the process.
|
|
630
|
+
if (tlsOptions.SNICallback && typeof tlsOptions.SNICallback === "function") {
|
|
631
|
+
var operatorSniCallback = tlsOptions.SNICallback;
|
|
632
|
+
tlsOptions = Object.assign({}, tlsOptions, {
|
|
633
|
+
SNICallback: function (servername, cb) {
|
|
634
|
+
try {
|
|
635
|
+
operatorSniCallback(servername, cb);
|
|
636
|
+
} catch (err) {
|
|
637
|
+
log.error("SNICallback threw for servername=" +
|
|
638
|
+
JSON.stringify(servername) + ": " + (err && err.message));
|
|
639
|
+
try { cb(err, null); } catch (_e) { /* cb already invoked */ }
|
|
640
|
+
}
|
|
641
|
+
},
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
// TLS 1.3 minimum — operator can override but the framework's
|
|
645
|
+
// default refuses pre-1.3 negotiation. Without this set the
|
|
646
|
+
// bare {key, cert} path inherits Node's TLSv1.2 default; the
|
|
647
|
+
// outbound httpClient already pins TLS 1.3.
|
|
648
|
+
if (!tlsOptions.minVersion) {
|
|
649
|
+
tlsOptions = Object.assign({ minVersion: "TLSv1.3" }, tlsOptions);
|
|
650
|
+
}
|
|
623
651
|
// h2-capable server with h1 fallback via ALPN. ["h2", "http/1.1"]
|
|
624
652
|
// means modern clients negotiate h2 (preferred); legacy clients
|
|
625
653
|
// fall back to h1. allowHTTP1: true is what makes the same server
|