@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/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
|
+
};
|
package/lib/vault/index.js
CHANGED
|
@@ -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);
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* vault/seal-pem-file — seal a PEM file at rest with file-watch auto-
|
|
4
|
+
* reseal.
|
|
5
|
+
*
|
|
6
|
+
* Operator workflow this primitive solves: ACME / Let's Encrypt
|
|
7
|
+
* renewals run on a 30-60 day cadence, write fresh certbot output to
|
|
8
|
+
* `/etc/letsencrypt/live/<domain>/privkey.pem`, and signal the
|
|
9
|
+
* application to reload. The fresh PEM lives unencrypted on disk
|
|
10
|
+
* between the renewal write and the next operator-driven re-seal.
|
|
11
|
+
* Auto-reseal closes that window: every renewal writes the plaintext
|
|
12
|
+
* PEM, the framework's watcher sees the mtime change, re-seals on the
|
|
13
|
+
* spot, and the in-process key material rotates without human
|
|
14
|
+
* intervention.
|
|
15
|
+
*
|
|
16
|
+
* Surface:
|
|
17
|
+
*
|
|
18
|
+
* var watcher = b.vault.sealPemFile({
|
|
19
|
+
* source: "/etc/letsencrypt/live/example.com/privkey.pem",
|
|
20
|
+
* destination: "/var/lib/blamejs/server.key.sealed",
|
|
21
|
+
* audit: true, // default
|
|
22
|
+
* pollInterval: b.constants.TIME.seconds(2), // fs.watchFile cadence
|
|
23
|
+
* onResealed: function (info) { ... }, // { srcPath, destPath, bytes,
|
|
24
|
+
* resealedAt, generation }
|
|
25
|
+
* onError: function (err) { ... }, // sealing failed
|
|
26
|
+
* });
|
|
27
|
+
* // watcher.stop()
|
|
28
|
+
* // watcher.generation — monotonically increases per reseal
|
|
29
|
+
* // watcher.lastResealedAt — Unix-ms of most recent successful reseal
|
|
30
|
+
* // watcher.lastError — most recent failure, or null
|
|
31
|
+
*
|
|
32
|
+
* Crash-safe write protocol:
|
|
33
|
+
*
|
|
34
|
+
* 1. Write `<destination>.tmp` with mode 0o600, fsync.
|
|
35
|
+
* 2. Create `<destination>.rewriting` marker (operator-visible).
|
|
36
|
+
* 3. Rename `<destination>.tmp` → `<destination>` (atomic on POSIX).
|
|
37
|
+
* 4. Remove `<destination>.rewriting` marker.
|
|
38
|
+
*
|
|
39
|
+
* If the framework crashes between steps 2 and 4, the marker remains
|
|
40
|
+
* on disk and the next sealPemFile() call detects it. Recovery: the
|
|
41
|
+
* sealedPath is either complete (rename happened) or still .tmp
|
|
42
|
+
* (rename did not happen). The recovery routine re-runs the seal from
|
|
43
|
+
* source — idempotent because the source PEM is the source of truth.
|
|
44
|
+
*
|
|
45
|
+
* fs.watchFile semantics:
|
|
46
|
+
*
|
|
47
|
+
* Node's fs.watchFile is a polling stat() loop with the configured
|
|
48
|
+
* pollInterval. It fires on mtime / size change. fs.watch (the
|
|
49
|
+
* inotify / kqueue backend) is more efficient but inconsistent across
|
|
50
|
+
* platforms — single rename events surface as multiple change events
|
|
51
|
+
* on Linux (events fire on the directory entry, the file, and the
|
|
52
|
+
* inode), and not at all on macOS for renamed-into files. Polling
|
|
53
|
+
* with watchFile is consistent everywhere and the latency cost (one
|
|
54
|
+
* pollInterval) is acceptable for renewal cadences measured in days.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
var fs = require("fs");
|
|
58
|
+
var path = require("path");
|
|
59
|
+
var atomicFile = require("../atomic-file");
|
|
60
|
+
var C = require("../constants");
|
|
61
|
+
var lazyRequire = require("../lazy-require");
|
|
62
|
+
var validateOpts = require("../validate-opts");
|
|
63
|
+
var { defineClass } = require("../framework-error");
|
|
64
|
+
var { boot } = require("../log");
|
|
65
|
+
|
|
66
|
+
var vault = lazyRequire(function () { return require("./index"); });
|
|
67
|
+
var audit = lazyRequire(function () { return require("../audit"); });
|
|
68
|
+
|
|
69
|
+
var log = boot("vault-seal-pem");
|
|
70
|
+
|
|
71
|
+
var SealPemFileError = defineClass("SealPemFileError", { alwaysPermanent: true });
|
|
72
|
+
|
|
73
|
+
// Default poll cadence balances latency against syscall pressure.
|
|
74
|
+
// At 2s, ACME renewals (which happen every ~60 days) experience a
|
|
75
|
+
// 2-second worst-case re-seal latency — negligible against the
|
|
76
|
+
// renewal cadence. Operators with sub-second-sensitive use cases
|
|
77
|
+
// override via opts.pollInterval.
|
|
78
|
+
var DEFAULT_POLL_MS = C.TIME.seconds(2);
|
|
79
|
+
|
|
80
|
+
function sealPemFile(opts) {
|
|
81
|
+
opts = opts || {};
|
|
82
|
+
validateOpts(opts, [
|
|
83
|
+
"source", "destination", "audit", "pollInterval",
|
|
84
|
+
"onResealed", "onError",
|
|
85
|
+
], "vault.sealPemFile");
|
|
86
|
+
|
|
87
|
+
validateOpts.requireNonEmptyString(opts.source,
|
|
88
|
+
"vault.sealPemFile: source must be a non-empty path",
|
|
89
|
+
SealPemFileError, "seal-pem-file/bad-source");
|
|
90
|
+
validateOpts.requireNonEmptyString(opts.destination,
|
|
91
|
+
"vault.sealPemFile: destination must be a non-empty path",
|
|
92
|
+
SealPemFileError, "seal-pem-file/bad-destination");
|
|
93
|
+
if (opts.source === opts.destination) {
|
|
94
|
+
throw new SealPemFileError("seal-pem-file/same-path",
|
|
95
|
+
"vault.sealPemFile: source and destination must differ — sealing in place would overwrite the plaintext");
|
|
96
|
+
}
|
|
97
|
+
validateOpts.optionalPositiveFinite(opts.pollInterval,
|
|
98
|
+
"vault.sealPemFile: pollInterval", SealPemFileError, "seal-pem-file/bad-poll-interval");
|
|
99
|
+
validateOpts.optionalFunction(opts.onResealed,
|
|
100
|
+
"vault.sealPemFile: onResealed", SealPemFileError, "seal-pem-file/bad-on-resealed");
|
|
101
|
+
validateOpts.optionalFunction(opts.onError,
|
|
102
|
+
"vault.sealPemFile: onError", SealPemFileError, "seal-pem-file/bad-on-error");
|
|
103
|
+
|
|
104
|
+
var source = opts.source;
|
|
105
|
+
var destination = opts.destination;
|
|
106
|
+
// optionalPositiveFinite above already threw on a bad-shaped opts.pollInterval;
|
|
107
|
+
// here only undefined / null / valid-positive-finite remain.
|
|
108
|
+
var pollInterval = opts.pollInterval || DEFAULT_POLL_MS;
|
|
109
|
+
var auditOn = opts.audit !== false;
|
|
110
|
+
var onResealed = typeof opts.onResealed === "function" ? opts.onResealed : null;
|
|
111
|
+
var onError = typeof opts.onError === "function" ? opts.onError : null;
|
|
112
|
+
|
|
113
|
+
var generation = 0;
|
|
114
|
+
var lastResealedAt = null;
|
|
115
|
+
var lastError = null;
|
|
116
|
+
var watching = false;
|
|
117
|
+
var listener = null;
|
|
118
|
+
var resealing = false;
|
|
119
|
+
var pendingMtime = null;
|
|
120
|
+
|
|
121
|
+
function _emitAudit(action, outcome, metadata) {
|
|
122
|
+
if (!auditOn) return;
|
|
123
|
+
try {
|
|
124
|
+
audit().safeEmit({
|
|
125
|
+
action: "vault.seal_pem_file." + action,
|
|
126
|
+
outcome: outcome,
|
|
127
|
+
metadata: metadata || {},
|
|
128
|
+
});
|
|
129
|
+
} catch (_e) { /* drop-silent */ }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function _writeSealed(plaintextBytes) {
|
|
133
|
+
// atomicFile.writeSync already does the .tmp + fsync + rename +
|
|
134
|
+
// fsyncDir sequence atomically. The marker is the framework's
|
|
135
|
+
// operator-visible crash-detection signal — created BEFORE the
|
|
136
|
+
// atomic rename, removed AFTER. If the framework crashes between
|
|
137
|
+
// marker create and marker remove, the marker remains on disk
|
|
138
|
+
// and _recoverIfNeeded() detects it on the next start().
|
|
139
|
+
var markerPath = destination + ".rewriting";
|
|
140
|
+
atomicFile.ensureDir(path.dirname(destination));
|
|
141
|
+
var sealed = vault().seal(plaintextBytes);
|
|
142
|
+
fs.writeFileSync(markerPath, String(Date.now()), { mode: 0o600 }); // allow:raw-byte-literal — POSIX file mode
|
|
143
|
+
try {
|
|
144
|
+
atomicFile.writeSync(destination, sealed, { fileMode: 0o600 }); // allow:raw-byte-literal — POSIX file mode
|
|
145
|
+
} catch (e) {
|
|
146
|
+
try { fs.unlinkSync(markerPath); } catch (_e) { /* best-effort */ }
|
|
147
|
+
throw e;
|
|
148
|
+
}
|
|
149
|
+
try { fs.unlinkSync(markerPath); } catch (_e) { /* marker cleanup best-effort */ }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function _resealNow() {
|
|
153
|
+
if (resealing) return;
|
|
154
|
+
resealing = true;
|
|
155
|
+
try {
|
|
156
|
+
var plaintext;
|
|
157
|
+
try { plaintext = fs.readFileSync(source); }
|
|
158
|
+
catch (e) {
|
|
159
|
+
var err = new SealPemFileError("seal-pem-file/source-read-failed",
|
|
160
|
+
"vault.sealPemFile: failed to read source '" + source + "': " + e.message);
|
|
161
|
+
lastError = err;
|
|
162
|
+
_emitAudit("read_failed", "failure", { source: source, error: e.message });
|
|
163
|
+
if (onError) { try { onError(err); } catch (_e) { /* drop-silent */ } }
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
_writeSealed(plaintext);
|
|
168
|
+
} catch (e2) {
|
|
169
|
+
var err2 = new SealPemFileError("seal-pem-file/seal-failed",
|
|
170
|
+
"vault.sealPemFile: failed to seal '" + source + "' to '" + destination + "': " + e2.message);
|
|
171
|
+
lastError = err2;
|
|
172
|
+
_emitAudit("seal_failed", "failure", {
|
|
173
|
+
source: source, destination: destination, error: e2.message,
|
|
174
|
+
});
|
|
175
|
+
if (onError) { try { onError(err2); } catch (_e) { /* drop-silent */ } }
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
generation += 1;
|
|
179
|
+
lastResealedAt = Date.now();
|
|
180
|
+
lastError = null;
|
|
181
|
+
_emitAudit("resealed", "success", {
|
|
182
|
+
source: source,
|
|
183
|
+
destination: destination,
|
|
184
|
+
bytes: plaintext.length,
|
|
185
|
+
generation: generation,
|
|
186
|
+
});
|
|
187
|
+
if (onResealed) {
|
|
188
|
+
try {
|
|
189
|
+
onResealed({
|
|
190
|
+
srcPath: source,
|
|
191
|
+
destPath: destination,
|
|
192
|
+
bytes: plaintext.length,
|
|
193
|
+
resealedAt: lastResealedAt,
|
|
194
|
+
generation: generation,
|
|
195
|
+
});
|
|
196
|
+
} catch (_e) { /* drop-silent */ }
|
|
197
|
+
}
|
|
198
|
+
} finally {
|
|
199
|
+
resealing = false;
|
|
200
|
+
if (pendingMtime) {
|
|
201
|
+
// A change event arrived while we were resealing — reseal again
|
|
202
|
+
// so the latest source bytes land. Single-flight: only one
|
|
203
|
+
// pending reseal is queued.
|
|
204
|
+
pendingMtime = null;
|
|
205
|
+
setImmediate(_resealNow);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Recover from a prior crash: if the marker is present, the previous
|
|
211
|
+
// reseal was interrupted. Re-seal from source idempotently.
|
|
212
|
+
function _recoverIfNeeded() {
|
|
213
|
+
var markerPath = destination + ".rewriting";
|
|
214
|
+
if (fs.existsSync(markerPath)) {
|
|
215
|
+
log.info("vault.sealPemFile: recovery — marker '" + markerPath +
|
|
216
|
+
"' present from prior crashed reseal; re-sealing from source");
|
|
217
|
+
_emitAudit("recovery_started", "success", {
|
|
218
|
+
source: source, destination: destination,
|
|
219
|
+
});
|
|
220
|
+
// Don't unlink the marker yet — _writeSealed will rewrite it
|
|
221
|
+
// and remove it as part of the normal sequence.
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function start() {
|
|
226
|
+
if (watching) return;
|
|
227
|
+
_recoverIfNeeded();
|
|
228
|
+
// Initial seal — operator gets the destination populated on
|
|
229
|
+
// start() even if the source's mtime never changes.
|
|
230
|
+
_resealNow();
|
|
231
|
+
listener = function (curr, prev) {
|
|
232
|
+
// mtime change OR the source appearing for the first time.
|
|
233
|
+
if (curr.mtimeMs !== prev.mtimeMs || curr.size !== prev.size) {
|
|
234
|
+
if (resealing) { pendingMtime = curr.mtimeMs; return; }
|
|
235
|
+
_resealNow();
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
fs.watchFile(source, { persistent: false, interval: pollInterval }, listener);
|
|
239
|
+
watching = true;
|
|
240
|
+
_emitAudit("watch_started", "success", {
|
|
241
|
+
source: source,
|
|
242
|
+
destination: destination,
|
|
243
|
+
pollInterval: pollInterval,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function stop() {
|
|
248
|
+
if (!watching) return;
|
|
249
|
+
fs.unwatchFile(source, listener);
|
|
250
|
+
listener = null;
|
|
251
|
+
watching = false;
|
|
252
|
+
_emitAudit("watch_stopped", "success", {
|
|
253
|
+
source: source,
|
|
254
|
+
destination: destination,
|
|
255
|
+
generation: generation,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Auto-start so the operator's `var watcher = sealPemFile(...)` call
|
|
260
|
+
// produces a populated destination immediately. Operators wiring it
|
|
261
|
+
// into a deferred lifecycle override by passing autoStart: false —
|
|
262
|
+
// not yet a frequent enough use case to surface, opens cleanly when
|
|
263
|
+
// the first operator surfaces it.
|
|
264
|
+
start();
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
stop: stop,
|
|
268
|
+
get generation() { return generation; },
|
|
269
|
+
get lastResealedAt() { return lastResealedAt; },
|
|
270
|
+
get lastError() { return lastError; },
|
|
271
|
+
get watching() { return watching; },
|
|
272
|
+
// Force a reseal — useful for tests and operator-triggered rotations
|
|
273
|
+
// (e.g. after a manual ACME renewal). Idempotent: produces an
|
|
274
|
+
// updated destination from the current source bytes.
|
|
275
|
+
forceReseal: _resealNow,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
module.exports = {
|
|
280
|
+
sealPemFile: sealPemFile,
|
|
281
|
+
SealPemFileError: SealPemFileError,
|
|
282
|
+
DEFAULT_POLL_MS: DEFAULT_POLL_MS,
|
|
283
|
+
};
|
package/lib/websocket.js
CHANGED
|
@@ -725,6 +725,21 @@ class WebSocketConnection extends EventEmitter {
|
|
|
725
725
|
return this._abort(CLOSE_PROTOCOL_ERROR, "RSV1 on continuation frame (must be on start)");
|
|
726
726
|
}
|
|
727
727
|
|
|
728
|
+
// RFC 6455 §5.5 — control frames (opcodes >= 0x8: CLOSE/PING/PONG)
|
|
729
|
+
// MUST have payload length ≤ 125 and MUST NOT be fragmented.
|
|
730
|
+
// Without the cap an attacker can send a 1 MiB PING and we echo it
|
|
731
|
+
// verbatim as PONG — a 2× outbound-bandwidth amplification DoS.
|
|
732
|
+
if (frame.opcode >= 0x8) {
|
|
733
|
+
if (frame.payload.length > 125) {
|
|
734
|
+
return this._abort(CLOSE_PROTOCOL_ERROR,
|
|
735
|
+
"control frame payload exceeds 125 bytes (RFC 6455 §5.5)");
|
|
736
|
+
}
|
|
737
|
+
if (!frame.fin) {
|
|
738
|
+
return this._abort(CLOSE_PROTOCOL_ERROR,
|
|
739
|
+
"control frame must not be fragmented (RFC 6455 §5.5)");
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
728
743
|
if (frame.opcode === OPCODE_CONTINUATION) {
|
|
729
744
|
if (this._fragOpcode === null) {
|
|
730
745
|
return this._abort(CLOSE_PROTOCOL_ERROR, "continuation without start");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blamejs/core",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.15",
|
|
4
4
|
"description": "The Node framework that owns its stack.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "blamejs contributors",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"owns-its-stack"
|
|
55
55
|
],
|
|
56
56
|
"engines": {
|
|
57
|
-
"node": ">=24.
|
|
57
|
+
"node": ">=24.4.0"
|
|
58
58
|
},
|
|
59
59
|
"files": [
|
|
60
60
|
"index.js",
|