@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.
@@ -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
package/lib/sse.js ADDED
@@ -0,0 +1,349 @@
1
+ "use strict";
2
+ /**
3
+ * Server-Sent Events primitive — text/event-stream transport with
4
+ * newline-injection refusal in event:/id:/data: fields.
5
+ *
6
+ * The SSE wire format is line-oriented (W3C HTML Living Standard
7
+ * §server-sent-events-spec): each field is a line of the form
8
+ * "<name>: <value>" terminated by a single LF, and an empty line
9
+ * separates events. Any LF/CR/NUL inside a value silently splits the
10
+ * field, letting an attacker forge subsequent events, the event id
11
+ * (which the client echoes back as Last-Event-ID on reconnect), or
12
+ * the message data. Three CVEs in one quarter — CVE-2026-33128 (h3),
13
+ * CVE-2026-29085 (Hono), CVE-2026-44217 (sse-channel) — published in
14
+ * the same vulnerability class.
15
+ *
16
+ * Public API:
17
+ *
18
+ * sse.create(req, res, opts) → channel
19
+ * Wires the response stream as text/event-stream, sets the
20
+ * SSE-required headers, and returns a channel object. opts:
21
+ * heartbeatMs — interval for `:keepalive` comment frames
22
+ * (default 15 s; pass 0 to disable)
23
+ * retryMs — initial reconnection-time advisory sent on
24
+ * stream open (sets the `retry:` field once;
25
+ * omitted when null/undefined)
26
+ * errorClass — FrameworkError subclass to throw on bad
27
+ * input (default SseError)
28
+ * audit — bool, default true. Emit SSE lifecycle audit
29
+ * events.
30
+ *
31
+ * channel.send({ event, id, data, retry })
32
+ * Writes a single SSE event. Each field is validated; LF/CR/NUL
33
+ * anywhere in event/id is refused via `errorClass`. data is
34
+ * allowed to contain LF — the framework splits it into multiple
35
+ * `data:` lines per the spec — but CR and NUL are refused. retry
36
+ * must be a non-negative finite integer.
37
+ *
38
+ * channel.comment(text)
39
+ * Writes a `:<text>` comment line (used for keepalive). LF/CR/NUL
40
+ * in `text` are refused.
41
+ *
42
+ * channel.close()
43
+ * Ends the underlying response stream and stops the heartbeat
44
+ * timer. Idempotent.
45
+ *
46
+ * channel.lastEventId
47
+ * The Last-Event-ID header value from the initial request, or
48
+ * null. Sanitized — any LF/CR/NUL renders the header null
49
+ * (refuse-on-bad-input rather than passing through to handlers).
50
+ *
51
+ * sse.serializeEvent({ event, id, data, retry })
52
+ * Returns the SSE-encoded string for a single event. Same
53
+ * validation rules as channel.send. Exposed for operators that
54
+ * buffer events through their own queue before writing.
55
+ *
56
+ * Error discipline:
57
+ * channel.send and serializeEvent THROW errorClass on bad input.
58
+ * SSE is not a drop-silent surface — a refused event is a
59
+ * programming bug, and silently dropping would mask the injection
60
+ * attempt the refusal exists to flag. close() is idempotent and
61
+ * never throws.
62
+ *
63
+ * Composition:
64
+ * - Composes with router via raw req/res — no router-specific
65
+ * coupling. Works under h1 and h2 (h2 keeps the stream open
66
+ * identically; the response is just chunked-transfer at h1 and
67
+ * a long-running DATA-frame stream at h2).
68
+ * - Audit emissions go through audit.safeEmit so SSE doesn't
69
+ * escape audit-bus failures back to the caller.
70
+ */
71
+
72
+ var C = require("./constants");
73
+ var audit = require("./audit");
74
+ var { SseError } = require("./framework-error");
75
+
76
+ // Per W3C SSE — the wire format uses LF as terminator. A single LF
77
+ // inside any field splits the value at the parser. CR is canonicalized
78
+ // to LF by the parser (CR-only and CRLF terminators are also valid),
79
+ // so CR is equally injection-shaped. NUL is refused universally — it
80
+ // has no place in an event-stream wire-form and any presence is
81
+ // suspicious.
82
+ // eslint-disable-next-line no-control-regex
83
+ var INJECTION_RE = /[\r\n\u0000]/;
84
+
85
+ // retry: must be a non-negative finite integer. Browsers floor /
86
+ // reject non-integer or negative values; refuse them at the source so
87
+ // downstream behavior is uniform.
88
+ function _validateRetry(retry, errorClass) {
89
+ if (retry === undefined || retry === null) return null;
90
+ if (typeof retry !== "number" || !isFinite(retry) || retry < 0 ||
91
+ Math.floor(retry) !== retry) {
92
+ throw errorClass.factory("BAD_RETRY",
93
+ "sse.send: retry must be a non-negative finite integer (got " +
94
+ JSON.stringify(retry) + ")");
95
+ }
96
+ return retry;
97
+ }
98
+
99
+ function _refuseInjection(field, value, errorClass) {
100
+ if (typeof value !== "string") {
101
+ throw errorClass.factory("BAD_FIELD",
102
+ "sse.send: " + field + " must be a string");
103
+ }
104
+ // Length-bound BEFORE the regex test — _capField applies a tighter
105
+ // cap further along, but the regex itself runs against the full
106
+ // value so we bound here too.
107
+ if (value.length > MAX_DATA_BYTES) {
108
+ throw errorClass.factory("FIELD_TOO_LARGE",
109
+ "sse.send: " + field + " too large for injection scan");
110
+ }
111
+ if (INJECTION_RE.test(value)) { // allow:regex-no-length-cap — value length capped above
112
+ audit.safeEmit({
113
+ action: "sse.injection_refused",
114
+ outcome: "denied",
115
+ metadata: { field: field, length: value.length },
116
+ });
117
+ throw errorClass.factory("INJECTION",
118
+ "sse.send: " + field + " contains LF/CR/NUL — refused " +
119
+ "(CVE-2026-33128 / 29085 / 44217 class)");
120
+ }
121
+ }
122
+
123
+ // Field caps. Values aren't open-ended — a 100 MiB `id:` is an abuse
124
+ // shape. Operators who need larger bodies use the chunked binary
125
+ // transports (websocket / file-upload). SSE is for text events.
126
+ var MAX_EVENT_BYTES = C.BYTES.kib(8);
127
+ var MAX_ID_BYTES = C.BYTES.kib(8);
128
+ var MAX_DATA_BYTES = C.BYTES.mib(1);
129
+
130
+ function _capField(field, value, capBytes, errorClass) {
131
+ var len = Buffer.byteLength(value, "utf8");
132
+ if (len > capBytes) {
133
+ throw errorClass.factory("FIELD_TOO_LARGE",
134
+ "sse.send: " + field + " exceeds cap (" + len + " > " +
135
+ capBytes + " bytes)");
136
+ }
137
+ }
138
+
139
+ function serializeEvent(opts, errorClass) {
140
+ errorClass = errorClass || SseError;
141
+ if (!opts || typeof opts !== "object") {
142
+ throw errorClass.factory("BAD_OPTS", "sse.serializeEvent: opts required");
143
+ }
144
+ var out = "";
145
+ // Field order: id, event, retry, data — matches the framework's
146
+ // historical b.middleware.sse layout. The W3C SSE spec is order-
147
+ // agnostic, but consumers (incl. the existing wiki test fixtures)
148
+ // pin this order.
149
+ if (opts.id !== undefined && opts.id !== null) {
150
+ _refuseInjection("id", opts.id, errorClass);
151
+ _capField("id", opts.id, MAX_ID_BYTES, errorClass);
152
+ out += "id: " + opts.id + "\n";
153
+ }
154
+ if (opts.event !== undefined && opts.event !== null) {
155
+ _refuseInjection("event", opts.event, errorClass);
156
+ _capField("event", opts.event, MAX_EVENT_BYTES, errorClass);
157
+ out += "event: " + opts.event + "\n";
158
+ }
159
+ var retry = _validateRetry(opts.retry, errorClass);
160
+ if (retry !== null) {
161
+ out += "retry: " + retry + "\n";
162
+ }
163
+ if (opts.data !== undefined && opts.data !== null) {
164
+ if (typeof opts.data !== "string") {
165
+ throw errorClass.factory("BAD_FIELD",
166
+ "sse.send: data must be a string");
167
+ }
168
+ _capField("data", opts.data, MAX_DATA_BYTES, errorClass);
169
+ // CR / NUL refused; LF allowed (split into multiple data: lines).
170
+ // eslint-disable-next-line no-control-regex
171
+ if (/[\r\u0000]/.test(opts.data)) {
172
+ audit.safeEmit({
173
+ action: "sse.injection_refused",
174
+ outcome: "denied",
175
+ metadata: { field: "data", length: opts.data.length, char: "cr-or-nul" },
176
+ });
177
+ throw errorClass.factory("INJECTION",
178
+ "sse.send: data contains CR or NUL — refused");
179
+ }
180
+ var lines = opts.data.split("\n");
181
+ for (var i = 0; i < lines.length; i += 1) {
182
+ out += "data: " + lines[i] + "\n";
183
+ }
184
+ }
185
+ // Empty line separator.
186
+ out += "\n";
187
+ return out;
188
+ }
189
+
190
+ function _validateComment(text, errorClass) {
191
+ if (typeof text !== "string") {
192
+ throw errorClass.factory("BAD_FIELD",
193
+ "sse.comment: text must be a string");
194
+ }
195
+ if (text.length > MAX_DATA_BYTES) {
196
+ throw errorClass.factory("FIELD_TOO_LARGE",
197
+ "sse.comment: text too large for injection scan");
198
+ }
199
+ if (INJECTION_RE.test(text)) { // allow:regex-no-length-cap — text length capped above
200
+ audit.safeEmit({
201
+ action: "sse.injection_refused",
202
+ outcome: "denied",
203
+ metadata: { field: "comment", length: text.length },
204
+ });
205
+ throw errorClass.factory("INJECTION",
206
+ "sse.comment: text contains LF/CR/NUL — refused");
207
+ }
208
+ }
209
+
210
+ // Sanitize the Last-Event-ID header value the client echoed on
211
+ // reconnect. Per the spec the client SHOULD send the most recent id,
212
+ // but we receive raw header bytes — refuse the value entirely (return
213
+ // null) if it carries any injection-shaped char.
214
+ function _readLastEventId(req) {
215
+ if (!req || !req.headers) return null;
216
+ var raw = req.headers["last-event-id"];
217
+ if (typeof raw !== "string" || raw.length === 0) return null;
218
+ if (INJECTION_RE.test(raw)) return null;
219
+ if (Buffer.byteLength(raw, "utf8") > MAX_ID_BYTES) return null;
220
+ return raw;
221
+ }
222
+
223
+ function create(req, res, opts) {
224
+ opts = opts || {};
225
+ var errorClass = opts.errorClass || SseError;
226
+ if (!res || typeof res.write !== "function" || typeof res.end !== "function") {
227
+ throw errorClass.factory("BAD_RES",
228
+ "sse.create: res must be a writable response stream");
229
+ }
230
+ var heartbeatMs = opts.heartbeatMs;
231
+ if (heartbeatMs === undefined) heartbeatMs = C.TIME.seconds(15);
232
+ if (typeof heartbeatMs !== "number" || !isFinite(heartbeatMs) ||
233
+ heartbeatMs < 0 || Math.floor(heartbeatMs) !== heartbeatMs) {
234
+ throw errorClass.factory("BAD_OPTS",
235
+ "sse.create: heartbeatMs must be a non-negative integer ms (got " +
236
+ JSON.stringify(heartbeatMs) + ")");
237
+ }
238
+ var auditOn = opts.audit !== false;
239
+
240
+ var lastEventId = _readLastEventId(req);
241
+
242
+ // Headers. text/event-stream is the contract; Cache-Control: no-cache
243
+ // and Connection: keep-alive (h1) are the operationally required
244
+ // pair. X-Accel-Buffering: no defeats nginx-style proxy buffering;
245
+ // operators behind a proxy that doesn't honor this set proxyBuffer:
246
+ // false on their LB.
247
+ if (typeof res.setHeader === "function") {
248
+ res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
249
+ res.setHeader("Cache-Control", "no-cache, no-transform");
250
+ res.setHeader("X-Accel-Buffering", "no");
251
+ // Connection: keep-alive only meaningful on h1; h2 streams stay
252
+ // open until either side closes. node:http2 surfaces res.stream
253
+ // (h2 ServerHttp2Stream) where setHeader works the same.
254
+ if (req && req.httpVersionMajor !== 2) {
255
+ res.setHeader("Connection", "keep-alive");
256
+ }
257
+ }
258
+ if (typeof res.flushHeaders === "function") {
259
+ try { res.flushHeaders(); } catch (_e) { /* response may have flushed already */ }
260
+ }
261
+
262
+ var closed = false;
263
+ var heartbeatTimer = null;
264
+
265
+ function _writeRaw(s) {
266
+ if (closed) {
267
+ throw errorClass.factory("CLOSED",
268
+ "sse.send: channel closed");
269
+ }
270
+ res.write(s);
271
+ }
272
+
273
+ function send(eventOpts) {
274
+ var encoded = serializeEvent(eventOpts || {}, errorClass);
275
+ _writeRaw(encoded);
276
+ }
277
+
278
+ function comment(text) {
279
+ _validateComment(text, errorClass);
280
+ _writeRaw(":" + text + "\n\n");
281
+ }
282
+
283
+ function close() {
284
+ if (closed) return;
285
+ closed = true;
286
+ if (heartbeatTimer) {
287
+ clearInterval(heartbeatTimer);
288
+ heartbeatTimer = null;
289
+ }
290
+ try { res.end(); } catch (_e) { /* already destroyed */ }
291
+ if (auditOn) {
292
+ audit.safeEmit({
293
+ action: "sse.channel_closed",
294
+ outcome: "success",
295
+ metadata: { lastEventId: lastEventId },
296
+ });
297
+ }
298
+ }
299
+
300
+ // Stream-side close detection — when the client disconnects, free
301
+ // the heartbeat timer.
302
+ if (typeof res.on === "function") {
303
+ res.on("close", close);
304
+ res.on("error", function (_e) { close(); });
305
+ res.on("finish", function () { closed = true; if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } });
306
+ }
307
+
308
+ // Optional retry: advisory on open.
309
+ if (opts.retryMs !== undefined && opts.retryMs !== null) {
310
+ var validatedRetry = _validateRetry(opts.retryMs, errorClass);
311
+ _writeRaw("retry: " + validatedRetry + "\n\n");
312
+ }
313
+
314
+ // Heartbeat keeps intermediaries from idle-timing out the stream and
315
+ // gives the client a reliable progress signal. Timer is unref'd so a
316
+ // single live SSE channel doesn't pin the event loop on shutdown.
317
+ if (heartbeatMs > 0) {
318
+ heartbeatTimer = setInterval(function () {
319
+ if (closed) return;
320
+ try { _writeRaw(":keepalive\n\n"); }
321
+ catch (_e) { close(); }
322
+ }, heartbeatMs).unref();
323
+ }
324
+
325
+ if (auditOn) {
326
+ audit.safeEmit({
327
+ action: "sse.channel_opened",
328
+ outcome: "success",
329
+ metadata: { lastEventId: lastEventId, heartbeatMs: heartbeatMs },
330
+ });
331
+ }
332
+
333
+ return {
334
+ send: send,
335
+ comment: comment,
336
+ close: close,
337
+ get lastEventId() { return lastEventId; },
338
+ get closed() { return closed; },
339
+ };
340
+ }
341
+
342
+ module.exports = {
343
+ create: create,
344
+ serializeEvent: serializeEvent,
345
+ // Cap exposure for operators wiring their own framing.
346
+ MAX_EVENT_BYTES: MAX_EVENT_BYTES,
347
+ MAX_ID_BYTES: MAX_ID_BYTES,
348
+ MAX_DATA_BYTES: MAX_DATA_BYTES,
349
+ };
@@ -292,6 +292,8 @@ function getMode() {
292
292
 
293
293
  var vaultAad = require("../vault-aad");
294
294
 
295
+ var sealPemFileModule = require("./seal-pem-file");
296
+
295
297
  module.exports = {
296
298
  init: init,
297
299
  seal: seal,
@@ -301,6 +303,8 @@ module.exports = {
301
303
  getCurrentPassphrase: getCurrentPassphrase,
302
304
  getMode: getMode,
303
305
  VaultError: VaultError,
306
+ sealPemFile: sealPemFileModule.sealPemFile,
307
+ SealPemFileError: sealPemFileModule.SealPemFileError,
304
308
  // Testing helpers — not part of the public contract
305
309
  _resetForTest: function () {
306
310
  if (currentPassphrase) safeBuffer.secureZero(currentPassphrase);