@blamejs/core 0.8.13 → 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/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);
@@ -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.13",
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.0.0"
57
+ "node": ">=24.4.0"
58
58
  },
59
59
  "files": [
60
60
  "index.js",