@blamejs/core 0.14.26 → 0.14.27
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 +2 -0
- package/lib/agent-envelope-mac.js +104 -0
- package/lib/agent-event-bus.js +105 -4
- package/lib/agent-posture-chain.js +8 -42
- package/lib/atomic-file.js +33 -3
- package/lib/audit.js +31 -23
- package/lib/auth/openid-federation.js +108 -47
- package/lib/compliance.js +147 -4
- package/lib/crypto-field.js +87 -1
- package/lib/error-page.js +14 -1
- package/lib/file-upload.js +52 -7
- package/lib/framework-error.js +3 -1
- package/lib/gate-contract.js +53 -0
- package/lib/http-client.js +23 -9
- package/lib/mail-server-jmap.js +117 -12
- package/lib/middleware/body-parser.js +71 -25
- package/lib/middleware/csrf-protect.js +19 -8
- package/lib/object-store/azure-blob.js +28 -2
- package/lib/observability.js +87 -0
- package/lib/otel-export.js +25 -1
- package/lib/parsers/safe-xml.js +47 -7
- package/lib/redact.js +68 -11
- package/lib/redis-client.js +160 -31
- package/lib/router.js +212 -5
- package/lib/ssrf-guard.js +51 -4
- package/lib/static.js +132 -27
- package/lib/websocket.js +19 -5
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/redis-client.js
CHANGED
|
@@ -28,7 +28,6 @@ var net = require("node:net");
|
|
|
28
28
|
var nodeTls = require("node:tls");
|
|
29
29
|
var nodeUrl = require("node:url");
|
|
30
30
|
var C = require("./constants");
|
|
31
|
-
var safeAsync = require("./safe-async");
|
|
32
31
|
var validateOpts = require("./validate-opts");
|
|
33
32
|
var ipUtils = require("./ip-utils");
|
|
34
33
|
var { RedisError } = require("./framework-error");
|
|
@@ -215,11 +214,28 @@ function create(opts) {
|
|
|
215
214
|
var connected = false;
|
|
216
215
|
var connecting = false;
|
|
217
216
|
var closing = false;
|
|
217
|
+
// Shared in-flight connect. Every _connect() call returns the SAME
|
|
218
|
+
// promise while a connect is in progress, so concurrent callers all
|
|
219
|
+
// observe the same resolve/reject instead of polling a flag. It is
|
|
220
|
+
// ALWAYS settled (resolve on ready, reject on socket-error / connect-
|
|
221
|
+
// timeout / AUTH-or-SELECT failure) and cleared the moment it settles
|
|
222
|
+
// so the next caller starts a fresh attempt — a connect that fails
|
|
223
|
+
// can never leave a never-settling promise behind for the next
|
|
224
|
+
// awaiter to wedge on.
|
|
225
|
+
var connectPromise = null;
|
|
218
226
|
// Tracked + unref'd reconnect timer. Tracked so close() can cancel a
|
|
219
227
|
// pending backoff (otherwise a reconnect scheduled before close fires
|
|
220
228
|
// after it and opens a fresh socket); unref'd so a backoff window doesn't
|
|
221
229
|
// by itself keep the event loop alive (the process-won't-exit class).
|
|
230
|
+
// Single-flight: a non-null reconnectTimer means a backoff is already
|
|
231
|
+
// pending — socket-error AND socket-close firing for the same failure
|
|
232
|
+
// must not stack two timers (which would burn the reconnect budget at
|
|
233
|
+
// 2x and open redundant sockets).
|
|
222
234
|
var reconnectTimer = null;
|
|
235
|
+
// Set once the reconnect budget is exhausted. Makes the give-up path
|
|
236
|
+
// idempotent (drains pending+backlog exactly once) and stops a stray
|
|
237
|
+
// close/error after give-up from re-draining or racing a later success.
|
|
238
|
+
var gaveUp = false;
|
|
223
239
|
var rxBuffer = Buffer.alloc(0);
|
|
224
240
|
// FIFO of in-flight commands awaiting a response
|
|
225
241
|
var pending = [];
|
|
@@ -239,18 +255,31 @@ function create(opts) {
|
|
|
239
255
|
|
|
240
256
|
function _scheduleReconnect() {
|
|
241
257
|
if (closing) return;
|
|
258
|
+
// Single-flight: a socket failure surfaces as both an `error` and a
|
|
259
|
+
// `close` event. Without this guard each one schedules its own timer,
|
|
260
|
+
// stacking two reconnects for one failure — the budget burns at 2x
|
|
261
|
+
// and two fresh sockets open. A pending backoff already covers the
|
|
262
|
+
// failure, so a second call is a no-op.
|
|
263
|
+
if (reconnectTimer !== null) return;
|
|
242
264
|
if (maxReconnectAttempts >= 0 && reconnectAttempt >= maxReconnectAttempts) {
|
|
243
|
-
// Drain pending
|
|
265
|
+
// Reconnect budget exhausted. Drain pending + backlog exactly once;
|
|
266
|
+
// a later stray close/error must not re-drain or race a future
|
|
267
|
+
// success path.
|
|
268
|
+
if (gaveUp) return;
|
|
269
|
+
gaveUp = true;
|
|
244
270
|
var err = _err("RECONNECT_GAVE_UP",
|
|
245
271
|
"redis: gave up after " + reconnectAttempt + " reconnect attempts");
|
|
246
272
|
_drainPending(err);
|
|
247
273
|
return;
|
|
248
274
|
}
|
|
249
275
|
reconnectAttempt++;
|
|
276
|
+
// Exponential backoff capped at 30s. Base 100ms is the first-retry
|
|
277
|
+
// delay (not a duration unit), so it stays a literal; the cap routes
|
|
278
|
+
// through C.TIME.
|
|
250
279
|
var delay = Math.min(C.TIME.seconds(30), 100 * Math.pow(2, reconnectAttempt - 1));
|
|
251
280
|
reconnectTimer = setTimeout(function () {
|
|
252
281
|
reconnectTimer = null;
|
|
253
|
-
_connect().catch(function () { /*
|
|
282
|
+
_connect().catch(function () { /* failure reschedules via the teardown path */ });
|
|
254
283
|
}, delay);
|
|
255
284
|
if (typeof reconnectTimer.unref === "function") reconnectTimer.unref();
|
|
256
285
|
}
|
|
@@ -261,7 +290,10 @@ function create(opts) {
|
|
|
261
290
|
batch.forEach(function (p) { p.reject(err); });
|
|
262
291
|
var bl = backlog.slice();
|
|
263
292
|
backlog.length = 0;
|
|
264
|
-
bl.forEach(function (p) {
|
|
293
|
+
bl.forEach(function (p) {
|
|
294
|
+
if (p.timer) { clearTimeout(p.timer); p.timer = null; }
|
|
295
|
+
p.reject(err);
|
|
296
|
+
});
|
|
265
297
|
}
|
|
266
298
|
|
|
267
299
|
function _onData(chunk) {
|
|
@@ -312,39 +344,74 @@ function create(opts) {
|
|
|
312
344
|
}
|
|
313
345
|
}
|
|
314
346
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
347
|
+
// Single teardown path for a lost socket. A failure surfaces as an
|
|
348
|
+
// `error` event AND a `close` event (and `error` then destroys the
|
|
349
|
+
// socket, which fires `close` again) — three callbacks for ONE lost
|
|
350
|
+
// connection. Routing all of them here, guarded by a "are we still
|
|
351
|
+
// attached to this socket" check, means pending is drained once and
|
|
352
|
+
// exactly one reconnect is scheduled (the single-flight guard in
|
|
353
|
+
// _scheduleReconnect absorbs the rest). `err` is the diagnostic to
|
|
354
|
+
// reject in-flight commands with.
|
|
355
|
+
function _teardownSocket(err) {
|
|
356
|
+
// Already torn down for this socket (the sibling event already ran).
|
|
357
|
+
if (!connected && socket === null) {
|
|
358
|
+
// Still let a stray event re-arm a reconnect if one isn't pending
|
|
359
|
+
// and we haven't been closed — but never re-drain pending.
|
|
360
|
+
if (!closing) _scheduleReconnect();
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
318
363
|
connected = false;
|
|
319
|
-
|
|
364
|
+
var dead = socket;
|
|
320
365
|
socket = null;
|
|
366
|
+
if (dead) {
|
|
367
|
+
try {
|
|
368
|
+
dead.removeListener("error", _onSocketError);
|
|
369
|
+
dead.removeListener("close", _onSocketClose);
|
|
370
|
+
dead.removeListener("data", _onData);
|
|
371
|
+
dead.destroy();
|
|
372
|
+
} catch (_e) { /* best-effort socket teardown */ }
|
|
373
|
+
}
|
|
374
|
+
_drainPending(err);
|
|
321
375
|
if (!closing) _scheduleReconnect();
|
|
322
376
|
}
|
|
323
377
|
|
|
378
|
+
function _onSocketError(err) {
|
|
379
|
+
_teardownSocket(_err("SOCKET",
|
|
380
|
+
"redis socket error: " + ((err && err.message) || String(err))));
|
|
381
|
+
}
|
|
382
|
+
|
|
324
383
|
function _onSocketClose() {
|
|
325
|
-
|
|
326
|
-
if (!closing) {
|
|
327
|
-
var err = _err("SOCKET_CLOSED", "redis socket closed unexpectedly");
|
|
328
|
-
_drainPending(err);
|
|
329
|
-
socket = null;
|
|
330
|
-
_scheduleReconnect();
|
|
331
|
-
}
|
|
384
|
+
_teardownSocket(_err("SOCKET_CLOSED", "redis socket closed unexpectedly"));
|
|
332
385
|
}
|
|
333
386
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
387
|
+
// _connect() — public entry. Returns a promise that ALWAYS settles.
|
|
388
|
+
// Concurrent callers (and the reconnect timer) share the single
|
|
389
|
+
// in-flight connectPromise rather than each starting a parallel dial,
|
|
390
|
+
// and they all observe the same resolve/reject. A previous version
|
|
391
|
+
// polled a `connecting` flag in a `while (connecting) await sleep(20)`
|
|
392
|
+
// loop; if a failure path failed to clear that flag the waiter spun
|
|
393
|
+
// forever. The shared promise removes that wedge — the promise is
|
|
394
|
+
// cleared the instant it settles, so a failed connect can never leave
|
|
395
|
+
// a never-settling promise behind.
|
|
396
|
+
function _connect() {
|
|
397
|
+
if (closing) return Promise.resolve();
|
|
398
|
+
if (connected) return Promise.resolve();
|
|
399
|
+
if (connectPromise) return connectPromise;
|
|
400
|
+
connectPromise = _doConnect();
|
|
401
|
+
// Clear the shared promise once it settles (either way) so the next
|
|
402
|
+
// _connect() starts a fresh attempt instead of re-awaiting a stale
|
|
403
|
+
// settled promise.
|
|
404
|
+
var clear = function () { connectPromise = null; };
|
|
405
|
+
connectPromise.then(clear, clear);
|
|
406
|
+
return connectPromise;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function _doConnect() {
|
|
344
410
|
connecting = true;
|
|
345
411
|
rxBuffer = Buffer.alloc(0);
|
|
412
|
+
var newSocket = null;
|
|
346
413
|
try {
|
|
347
|
-
|
|
414
|
+
newSocket = await new Promise(function (resolve, reject) {
|
|
348
415
|
var sock;
|
|
349
416
|
var timer = setTimeout(function () {
|
|
350
417
|
try { if (sock) sock.destroy(); } catch (_e) { /* best-effort socket teardown */ }
|
|
@@ -371,16 +438,19 @@ function create(opts) {
|
|
|
371
438
|
}
|
|
372
439
|
sock.once("error", onErr);
|
|
373
440
|
});
|
|
441
|
+
socket = newSocket;
|
|
374
442
|
socket.setNoDelay(true);
|
|
375
443
|
socket.on("data", _onData);
|
|
376
444
|
socket.on("error", _onSocketError);
|
|
377
445
|
socket.on("close", _onSocketClose);
|
|
378
446
|
connected = true;
|
|
379
|
-
reconnectAttempt = 0;
|
|
380
447
|
|
|
381
448
|
// Auth + select db on (re)connect — without resetting the
|
|
382
449
|
// backlog of commands queued during disconnect. Send these
|
|
383
450
|
// BEFORE the backlog so the server is ready when backlog flushes.
|
|
451
|
+
// A failure here (wrong password, server SELECT rejection, socket
|
|
452
|
+
// dropped mid-AUTH) must not leave connected=true on a half-open
|
|
453
|
+
// socket — the catch below tears the socket down and rethrows.
|
|
384
454
|
if (password) {
|
|
385
455
|
var authArgs = username ? ["AUTH", username, password] : ["AUTH", password];
|
|
386
456
|
await _sendNoQueue(authArgs);
|
|
@@ -389,15 +459,47 @@ function create(opts) {
|
|
|
389
459
|
await _sendNoQueue(["SELECT", String(db)]);
|
|
390
460
|
}
|
|
391
461
|
|
|
392
|
-
//
|
|
462
|
+
// Connect fully succeeded — only now reset the backoff counter +
|
|
463
|
+
// the give-up latch so a future disconnect gets a fresh budget.
|
|
464
|
+
reconnectAttempt = 0;
|
|
465
|
+
gaveUp = false;
|
|
466
|
+
connecting = false;
|
|
467
|
+
|
|
468
|
+
// Flush backlog. Clear each queued entry's not-connected timeout
|
|
469
|
+
// before it goes on the wire — the in-flight command timeout in
|
|
470
|
+
// _writeAndAwait now owns its lifetime.
|
|
393
471
|
var bl = backlog.slice();
|
|
394
472
|
backlog.length = 0;
|
|
395
|
-
bl.forEach(function (entry) {
|
|
473
|
+
bl.forEach(function (entry) {
|
|
474
|
+
if (entry.timer) { clearTimeout(entry.timer); entry.timer = null; }
|
|
475
|
+
_writeAndAwait(entry.args, entry.resolve, entry.reject);
|
|
476
|
+
});
|
|
396
477
|
} catch (err) {
|
|
397
478
|
connecting = false;
|
|
479
|
+
connected = false;
|
|
480
|
+
// Tear down a half-open socket (came up, then AUTH/SELECT failed)
|
|
481
|
+
// so we never leave connected=false with a live socket whose data/
|
|
482
|
+
// error/close handlers would fire against stale state. If the
|
|
483
|
+
// socket-error handler already ran it set socket=null.
|
|
484
|
+
var dead = socket || newSocket;
|
|
485
|
+
socket = null;
|
|
486
|
+
if (dead) {
|
|
487
|
+
try {
|
|
488
|
+
dead.removeListener("error", _onSocketError);
|
|
489
|
+
dead.removeListener("close", _onSocketClose);
|
|
490
|
+
dead.removeListener("data", _onData);
|
|
491
|
+
dead.destroy();
|
|
492
|
+
} catch (_e) { /* best-effort socket teardown */ }
|
|
493
|
+
}
|
|
494
|
+
// A failed dial (reset before ready) or an AUTH/SELECT failure must keep
|
|
495
|
+
// the reconnect loop alive. The post-ready error/close handlers that
|
|
496
|
+
// normally drive reconnect are not attached yet during the dial, so
|
|
497
|
+
// without scheduling here a connection lost mid-dial rejects the connect
|
|
498
|
+
// promise and the client never reconnects. Single-flight + budget-guarded
|
|
499
|
+
// by _scheduleReconnect; the caller still observes this attempt's rejection.
|
|
500
|
+
if (!closing) _scheduleReconnect();
|
|
398
501
|
throw err;
|
|
399
502
|
}
|
|
400
|
-
connecting = false;
|
|
401
503
|
}
|
|
402
504
|
|
|
403
505
|
// Internal helper that bypasses the connect-pending backlog (used
|
|
@@ -448,7 +550,28 @@ function create(opts) {
|
|
|
448
550
|
return;
|
|
449
551
|
}
|
|
450
552
|
if (!connected) {
|
|
451
|
-
|
|
553
|
+
// Reconnect budget exhausted and no reconnect is in flight — a
|
|
554
|
+
// backlogged command here would never be flushed (nothing will
|
|
555
|
+
// reconnect to drain it) and would wedge the caller forever.
|
|
556
|
+
// Reject immediately instead.
|
|
557
|
+
if (gaveUp && reconnectTimer === null && !connecting) {
|
|
558
|
+
reject(_err("RECONNECT_GAVE_UP",
|
|
559
|
+
"redis: client disconnected and reconnect budget exhausted"));
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
// Queued until the next successful connect flushes the backlog.
|
|
563
|
+
// Bound it with a timeout so a connect that never completes (the
|
|
564
|
+
// backend is down for the whole window) settles the caller with
|
|
565
|
+
// a clear error instead of leaving the await pending forever.
|
|
566
|
+
var entry = { args: args, resolve: resolve, reject: reject, timer: null };
|
|
567
|
+
entry.timer = setTimeout(function () {
|
|
568
|
+
var idx = backlog.indexOf(entry);
|
|
569
|
+
if (idx !== -1) backlog.splice(idx, 1);
|
|
570
|
+
reject(_err("COMMAND_TIMEOUT",
|
|
571
|
+
"redis " + args[0] + " timed out while queued (client not connected)"));
|
|
572
|
+
}, commandTimeoutMs);
|
|
573
|
+
if (typeof entry.timer.unref === "function") entry.timer.unref();
|
|
574
|
+
backlog.push(entry);
|
|
452
575
|
return;
|
|
453
576
|
}
|
|
454
577
|
_writeAndAwait(args, resolve, reject);
|
|
@@ -469,6 +592,9 @@ function create(opts) {
|
|
|
469
592
|
async function close() {
|
|
470
593
|
closing = true;
|
|
471
594
|
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
595
|
+
// Drop the shared connect promise so a re-create after close (or a
|
|
596
|
+
// late awaiter) doesn't re-await a stale in-flight attempt.
|
|
597
|
+
connectPromise = null;
|
|
472
598
|
var err = _err("CLOSED", "redis client closed");
|
|
473
599
|
_drainPending(err);
|
|
474
600
|
if (socket) {
|
|
@@ -492,8 +618,11 @@ function create(opts) {
|
|
|
492
618
|
_state: function () {
|
|
493
619
|
return {
|
|
494
620
|
connected: connected, closing: closing,
|
|
621
|
+
connecting: connecting,
|
|
495
622
|
pending: pending.length, backlog: backlog.length,
|
|
496
623
|
reconnect: reconnectAttempt,
|
|
624
|
+
reconnectPending: reconnectTimer !== null,
|
|
625
|
+
gaveUp: gaveUp,
|
|
497
626
|
host: host, port: port, db: db, tls: useTls,
|
|
498
627
|
connectTimeoutMs: connectTimeoutMs,
|
|
499
628
|
commandTimeoutMs: commandTimeoutMs,
|
package/lib/router.js
CHANGED
|
@@ -42,6 +42,7 @@ var lazyRequire = require("./lazy-require");
|
|
|
42
42
|
var safeAsync = require("./safe-async");
|
|
43
43
|
var safeEnv = require("./parsers/safe-env");
|
|
44
44
|
var safeUrl = require("./safe-url");
|
|
45
|
+
var validateOpts = require("./validate-opts");
|
|
45
46
|
var websocket = require("./websocket");
|
|
46
47
|
var { boot } = require("./log");
|
|
47
48
|
var { RouterError } = require("./framework-error");
|
|
@@ -301,6 +302,13 @@ class Router {
|
|
|
301
302
|
constructor(opts) {
|
|
302
303
|
opts = opts || {};
|
|
303
304
|
this.routes = [];
|
|
305
|
+
// Registration-ordered middleware table. Each entry is
|
|
306
|
+
// `{ prefix, prefixSegments, fn }`: `prefix === null` is a global
|
|
307
|
+
// middleware (runs on every request); a non-null `prefix` is a
|
|
308
|
+
// path-scoped middleware that runs only when the request path
|
|
309
|
+
// matches the prefix on segment boundaries. Path-scoped and global
|
|
310
|
+
// entries interleave in registration order so a gate registered
|
|
311
|
+
// before a route still runs before it.
|
|
304
312
|
this.middleware = [];
|
|
305
313
|
// WebSocket routes are kept separate from HTTP routes — they're
|
|
306
314
|
// matched on the upgrade / Extended CONNECT nodePath, not on a method
|
|
@@ -453,8 +461,23 @@ class Router {
|
|
|
453
461
|
return conns.length;
|
|
454
462
|
}
|
|
455
463
|
|
|
456
|
-
use(
|
|
457
|
-
|
|
464
|
+
// use(mw) — global middleware (runs on every request)
|
|
465
|
+
// use(mw1, mw2, ...) — several global middlewares, in order
|
|
466
|
+
// use(prefix, mw1, mw2, ...) — path-scoped: mw runs only when the
|
|
467
|
+
// request path is at or beneath `prefix`
|
|
468
|
+
// on segment boundaries ("/admin" covers
|
|
469
|
+
// "/admin" + "/admin/x", not "/administrator")
|
|
470
|
+
// use([prefixA, prefixB], mw) — scoped to any of several prefixes
|
|
471
|
+
//
|
|
472
|
+
// Bad input throws at config time (a non-string / non-array prefix, a
|
|
473
|
+
// prefix that doesn't begin with "/", a non-function middleware) so an
|
|
474
|
+
// operator wiring typo surfaces at boot instead of silently dropping a
|
|
475
|
+
// security gate or 500-ing every request.
|
|
476
|
+
use() {
|
|
477
|
+
var entries = _normalizeUseArgs(Array.prototype.slice.call(arguments));
|
|
478
|
+
for (var i = 0; i < entries.length; i++) {
|
|
479
|
+
this.middleware.push(entries[i]);
|
|
480
|
+
}
|
|
458
481
|
}
|
|
459
482
|
|
|
460
483
|
// Internal: split a route registration's args into { spec, handlers }.
|
|
@@ -687,8 +710,24 @@ class Router {
|
|
|
687
710
|
}
|
|
688
711
|
req.query = Object.fromEntries(queryEntries);
|
|
689
712
|
|
|
690
|
-
// Run middleware
|
|
691
|
-
|
|
713
|
+
// Run middleware in registration order. Global entries
|
|
714
|
+
// (prefixSegmentsList === null) run on every request; path-scoped
|
|
715
|
+
// entries run only when req.pathname is at or beneath one of the
|
|
716
|
+
// mount's prefixes (segment-boundary match). A skipped scoped
|
|
717
|
+
// middleware does NOT short-circuit the chain — the next entry
|
|
718
|
+
// still runs.
|
|
719
|
+
for (var entry of this.middleware) {
|
|
720
|
+
if (entry.prefixSegmentsList !== null) {
|
|
721
|
+
var matched = false;
|
|
722
|
+
for (var pli = 0; pli < entry.prefixSegmentsList.length; pli++) {
|
|
723
|
+
if (_pathMatchesPrefix(entry.prefixSegmentsList[pli], req.pathname)) {
|
|
724
|
+
matched = true;
|
|
725
|
+
break;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
if (!matched) continue;
|
|
729
|
+
}
|
|
730
|
+
var mw = entry.fn;
|
|
692
731
|
var next = false;
|
|
693
732
|
try {
|
|
694
733
|
await mw(req, res, () => (next = true));
|
|
@@ -1206,6 +1245,152 @@ class Router {
|
|
|
1206
1245
|
}
|
|
1207
1246
|
}
|
|
1208
1247
|
|
|
1248
|
+
// ---- Path-scoped `use()` helpers ----
|
|
1249
|
+
//
|
|
1250
|
+
// These back `Router.use(prefix, mw)`. They live after the class
|
|
1251
|
+
// (hoisted function declarations are visible to the methods regardless
|
|
1252
|
+
// of source order) so the class methods sit contiguous with the
|
|
1253
|
+
// route-matching helpers above.
|
|
1254
|
+
|
|
1255
|
+
// Compile a `use(prefix, mw)` path prefix into the segment list the
|
|
1256
|
+
// matcher walks. A prefix is the literal-segment portion of a path
|
|
1257
|
+
// ("/admin", "/.well-known/jmap") — parameter segments (":id") are not
|
|
1258
|
+
// meaningful for a mounting prefix, so they're treated as literals.
|
|
1259
|
+
//
|
|
1260
|
+
// Normalization mirrors route-pattern handling: split on "/" and drop a
|
|
1261
|
+
// single trailing-slash artifact so "/admin" and "/admin/" mount the
|
|
1262
|
+
// same. The leading empty segment from the leading "/" is preserved so
|
|
1263
|
+
// the prefix anchors at the path root (a prefix that does not begin with
|
|
1264
|
+
// "/" is refused at the use() entry point).
|
|
1265
|
+
function _compilePrefix(prefix) {
|
|
1266
|
+
var segments = prefix.split("/");
|
|
1267
|
+
// A trailing "/" produces a final empty segment ("/admin/" → ["", "admin", ""]).
|
|
1268
|
+
// Drop it so the trailing slash doesn't force an extra path segment.
|
|
1269
|
+
if (segments.length > 1 && segments[segments.length - 1] === "") {
|
|
1270
|
+
segments.pop();
|
|
1271
|
+
}
|
|
1272
|
+
return segments;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// True when `pathname` is at or beneath the mounting prefix, matching on
|
|
1276
|
+
// segment boundaries. "/admin" matches "/admin" and "/admin/x" but NOT
|
|
1277
|
+
// "/administrator" (Express-style segment semantics) — a substring
|
|
1278
|
+
// prefix that lands mid-segment is a no-match so a security gate scoped
|
|
1279
|
+
// to "/admin" never leaks onto a sibling path that merely shares a
|
|
1280
|
+
// textual prefix.
|
|
1281
|
+
function _pathMatchesPrefix(prefixSegments, pathname) {
|
|
1282
|
+
var pathSegments = pathname.split("/");
|
|
1283
|
+
if (pathSegments.length < prefixSegments.length) return false;
|
|
1284
|
+
// Compare segment-for-segment. This is routing metadata (public URL
|
|
1285
|
+
// path), not secret material, so an ordinary equality walk is correct
|
|
1286
|
+
// — no constant-time comparison is warranted.
|
|
1287
|
+
return prefixSegments.every(function (seg, i) {
|
|
1288
|
+
return pathSegments[i] === seg;
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// Classify the first `use()` argument:
|
|
1293
|
+
// function → global mount (prefixes stays null)
|
|
1294
|
+
// string / string[] → path-scoped mount (one or more prefixes)
|
|
1295
|
+
// anything else → operator wiring typo, refused at config time
|
|
1296
|
+
// Returns the prefix array (or null for a global mount). Does not touch
|
|
1297
|
+
// the middleware functions — the caller validates those.
|
|
1298
|
+
function _usePrefixesFromFirstArg(first) {
|
|
1299
|
+
if (typeof first === "function") return null;
|
|
1300
|
+
if (typeof first !== "string" && !Array.isArray(first)) {
|
|
1301
|
+
throw new RouterError("router/use-bad-first-arg",
|
|
1302
|
+
"router.use: first argument must be a middleware function, a path " +
|
|
1303
|
+
"prefix string, or an array of prefix strings (got " +
|
|
1304
|
+
(first === null ? "null" : typeof first) + ")");
|
|
1305
|
+
}
|
|
1306
|
+
var prefixes = Array.isArray(first) ? first : [first];
|
|
1307
|
+
if (prefixes.length === 0) {
|
|
1308
|
+
throw new RouterError("router/use-empty-prefix-array",
|
|
1309
|
+
"router.use: path-prefix array must contain at least one prefix string");
|
|
1310
|
+
}
|
|
1311
|
+
// Array-of-non-empty-strings shape (the index-pointing throw on a
|
|
1312
|
+
// non-string / empty entry) is the shared validate-opts contract.
|
|
1313
|
+
validateOpts.optionalNonEmptyStringArray(
|
|
1314
|
+
prefixes, "router.use: path prefix", RouterError, "router/use-prefix-not-string");
|
|
1315
|
+
// Prefix-specific grammar: anchor at "/" + bounded length.
|
|
1316
|
+
for (var i = 0; i < prefixes.length; i++) {
|
|
1317
|
+
var grammarErr = _prefixGrammarError(prefixes[i]);
|
|
1318
|
+
if (grammarErr) throw grammarErr;
|
|
1319
|
+
}
|
|
1320
|
+
return prefixes;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// Return a RouterError describing why `prefix` is not a valid mounting
|
|
1324
|
+
// prefix, or null when it is valid. Split out so the per-prefix grammar
|
|
1325
|
+
// check is one branch, not an inline throw-block in the loop.
|
|
1326
|
+
function _prefixGrammarError(prefix) {
|
|
1327
|
+
if (prefix.charAt(0) !== "/") {
|
|
1328
|
+
return new RouterError("router/use-prefix-not-absolute",
|
|
1329
|
+
"router.use: path prefix '" + prefix + "' must begin with '/'");
|
|
1330
|
+
}
|
|
1331
|
+
if (prefix.length > MAX_ROUTE_PATTERN_LEN) {
|
|
1332
|
+
return new RouterError("router/use-prefix-too-long",
|
|
1333
|
+
"router.use: path prefix exceeds " + MAX_ROUTE_PATTERN_LEN +
|
|
1334
|
+
" chars (got " + prefix.length + ")");
|
|
1335
|
+
}
|
|
1336
|
+
return null;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// Index of the first non-function entry in `fns`, or -1 when all are
|
|
1340
|
+
// functions. Kept separate so the middleware-shape check is a scan, not
|
|
1341
|
+
// an inline throw inside the normalize flow.
|
|
1342
|
+
function _firstNonFunctionIndex(fns) {
|
|
1343
|
+
for (var i = 0; i < fns.length; i++) {
|
|
1344
|
+
if (typeof fns[i] !== "function") return i;
|
|
1345
|
+
}
|
|
1346
|
+
return -1;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// Validate + normalize the `use()` arguments into middleware-table
|
|
1350
|
+
// entries. Config-time entry-point tier: a non-string / non-array-of-
|
|
1351
|
+
// strings prefix or a non-function middleware is an operator wiring
|
|
1352
|
+
// typo and throws so it surfaces at boot, not as a silent dropped gate
|
|
1353
|
+
// or a request-time 500. Returns one entry per middleware function:
|
|
1354
|
+
// { prefix: null, prefixSegmentsList: null, fn } — global
|
|
1355
|
+
// { prefix: "/admin", prefixSegmentsList: [[...], ...], fn } — scoped
|
|
1356
|
+
// A scoped entry matches when ANY of its prefixes match the request
|
|
1357
|
+
// path on segment boundaries; the fn runs at most once per request even
|
|
1358
|
+
// when two of its prefixes both match (nested prefixes), so a gate never
|
|
1359
|
+
// double-executes.
|
|
1360
|
+
function _normalizeUseArgs(args) {
|
|
1361
|
+
if (args.length === 0) {
|
|
1362
|
+
throw new RouterError("router/use-no-args",
|
|
1363
|
+
"router.use: requires at least one middleware function");
|
|
1364
|
+
}
|
|
1365
|
+
var prefixes = _usePrefixesFromFirstArg(args[0]);
|
|
1366
|
+
// Global mount uses every arg as a middleware; a scoped mount drops the
|
|
1367
|
+
// leading prefix arg and uses the rest.
|
|
1368
|
+
var fns = (prefixes === null) ? args : args.slice(1);
|
|
1369
|
+
if (fns.length === 0) {
|
|
1370
|
+
throw new RouterError("router/use-no-middleware",
|
|
1371
|
+
"router.use: path-scoped mount requires at least one middleware " +
|
|
1372
|
+
"function after the prefix");
|
|
1373
|
+
}
|
|
1374
|
+
var nonFn = _firstNonFunctionIndex(fns);
|
|
1375
|
+
if (nonFn !== -1) {
|
|
1376
|
+
throw new RouterError("router/use-middleware-not-function",
|
|
1377
|
+
"router.use: middleware at position " + nonFn +
|
|
1378
|
+
" must be a function (got " +
|
|
1379
|
+
(fns[nonFn] === null ? "null" : typeof fns[nonFn]) + ")");
|
|
1380
|
+
}
|
|
1381
|
+
// Pre-compile each prefix to its segment list once at registration so
|
|
1382
|
+
// dispatch only walks segments, never re-splits the prefix per request.
|
|
1383
|
+
var segmentsList = (prefixes === null) ? null : prefixes.map(_compilePrefix);
|
|
1384
|
+
var label = (prefixes === null) ? null
|
|
1385
|
+
: (prefixes.length === 1 ? prefixes[0] : prefixes.slice());
|
|
1386
|
+
// One entry per fn, preserving the order each middleware was passed —
|
|
1387
|
+
// a path-scoped mount with two middlewares interleaves them in
|
|
1388
|
+
// registration order just like two separate use() calls would.
|
|
1389
|
+
return fns.map(function (fn) {
|
|
1390
|
+
return { prefix: label, prefixSegmentsList: segmentsList, fn: fn };
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1209
1394
|
/**
|
|
1210
1395
|
* @primitive b.router.serveStatic
|
|
1211
1396
|
* @signature b.router.serveStatic(dir)
|
|
@@ -1266,7 +1451,7 @@ function serveStatic(dir) {
|
|
|
1266
1451
|
*
|
|
1267
1452
|
* Builds a `Router` instance with the framework's security-on-by-
|
|
1268
1453
|
* default posture. Returned object exposes `get / post / put / patch
|
|
1269
|
-
* / delete` for route registration, `use(
|
|
1454
|
+
* / delete` for route registration, `use(...)` for middleware,
|
|
1270
1455
|
* `ws(path, handler, opts?)` for WebSocket routes, `onNotFound(fn)`
|
|
1271
1456
|
* and `onError(fn)` for fallthrough hooks, `inspectRoutes()` and
|
|
1272
1457
|
* `openapi()` for introspection, `closeWebSockets({ timeoutMs })`
|
|
@@ -1274,6 +1459,21 @@ function serveStatic(dir) {
|
|
|
1274
1459
|
* which boots an HTTP/2-capable TLS server (ALPN h2 + http/1.1) when
|
|
1275
1460
|
* `tlsOptions` is provided, an HTTP/1.1 server otherwise.
|
|
1276
1461
|
*
|
|
1462
|
+
* `use` has two forms. `use(mw)` (and `use(mw1, mw2, ...)`) mounts
|
|
1463
|
+
* global middleware that runs on every request. `use(prefix, mw1,
|
|
1464
|
+
* mw2, ...)` mounts path-scoped middleware that runs only when the
|
|
1465
|
+
* request path is at or beneath `prefix`, matched on segment
|
|
1466
|
+
* boundaries — `"/admin"` covers `"/admin"` and `"/admin/x"` but not
|
|
1467
|
+
* `"/administrator"`. The prefix may be an array of strings to scope a
|
|
1468
|
+
* gate to several path roots at once. Global and scoped middleware
|
|
1469
|
+
* interleave in registration order, so a gate registered before a
|
|
1470
|
+
* route still runs before it. A non-string / non-array prefix, a
|
|
1471
|
+
* prefix not beginning with `"/"`, or a non-function middleware throws
|
|
1472
|
+
* at registration time rather than dropping the gate or 500-ing every
|
|
1473
|
+
* request — scope a security middleware (`csrf`, `bearerAuth`,
|
|
1474
|
+
* `requireAal`, `requireMtls`) to a path with confidence it runs
|
|
1475
|
+
* exactly where mounted.
|
|
1476
|
+
*
|
|
1277
1477
|
* @opts
|
|
1278
1478
|
* tls0Rtt: "refuse" | "replay-cache", // RFC 8446 §8 anti-replay; default "refuse"
|
|
1279
1479
|
* allowedRedirectOrigins: string[], // exact-match HTTPS origins for cross-origin res.redirect()
|
|
@@ -1286,6 +1486,13 @@ function serveStatic(dir) {
|
|
|
1286
1486
|
* router.get("/users/:id", function (req, res) {
|
|
1287
1487
|
* res.json({ id: req.params.id });
|
|
1288
1488
|
* });
|
|
1489
|
+
*
|
|
1490
|
+
* // Global middleware — runs on every request.
|
|
1491
|
+
* router.use(b.middleware.securityHeaders());
|
|
1492
|
+
*
|
|
1493
|
+
* // Path-scoped middleware — the step-up gate runs only under /admin.
|
|
1494
|
+
* router.use("/admin", b.middleware.requireAal({ minimum: "AAL2" }));
|
|
1495
|
+
*
|
|
1289
1496
|
* router.listen(3000);
|
|
1290
1497
|
*/
|
|
1291
1498
|
function create(opts) {
|
package/lib/ssrf-guard.js
CHANGED
|
@@ -148,12 +148,27 @@ var IPV6_6TO4_PREFIX = _ipv6ToBytes("2002::");
|
|
|
148
148
|
// or attempted exfil to a sinkhole.
|
|
149
149
|
var IPV6_DISCARD_PREFIX = _ipv6ToBytes("100::");
|
|
150
150
|
|
|
151
|
-
// ---- Cloud metadata addresses (
|
|
151
|
+
// ---- Cloud metadata addresses (matched on CANONICAL bytes, not string) ----
|
|
152
|
+
// The documentation strings below are the human-readable canonical forms.
|
|
153
|
+
// Matching is byte-canonical (see _isCloudMetadataAddr): an IPv6 address has
|
|
154
|
+
// many textual representations (compressed `::`, fully-expanded
|
|
155
|
+
// `fd00:ec2:0:0:0:0:0:254`, mixed-case) that all decode to the same 16 bytes.
|
|
156
|
+
// A string-equality membership test matched only ONE spelling, so a hostile
|
|
157
|
+
// (or merely DoH-decoded — network-dns.js emits the expanded form) answer of
|
|
158
|
+
// `fd00:ec2:0:0:0:0:0:254` slipped past as "private" and rode the documented
|
|
159
|
+
// `allowInternal:true` waiver straight into the IMDS credential endpoint.
|
|
152
160
|
var CLOUD_METADATA_IPS = [
|
|
153
161
|
"169.254.169.254", // AWS, GCP, Azure, OpenStack, DO
|
|
154
162
|
"169.254.170.2", // AWS ECS task role
|
|
155
163
|
"fd00:ec2::254", // AWS IMDS over IPv6
|
|
156
164
|
];
|
|
165
|
+
// Canonical byte forms of the metadata IPs — v4 as a 4-byte Buffer, v6 as a
|
|
166
|
+
// 16-byte Buffer. Built once at load via the same parsers classify() uses,
|
|
167
|
+
// so every textual representation that decodes to these bytes is caught.
|
|
168
|
+
var CLOUD_METADATA_BYTES = CLOUD_METADATA_IPS.map(function (ip) {
|
|
169
|
+
var fam = net.isIP(ip);
|
|
170
|
+
return fam === 4 ? _ipv4ToBytes(ip) : _ipv6ToBytes(ip);
|
|
171
|
+
});
|
|
157
172
|
|
|
158
173
|
// ---- Helpers ----
|
|
159
174
|
|
|
@@ -180,6 +195,14 @@ function _ipv4ToInt(ip) {
|
|
|
180
195
|
nums[3];
|
|
181
196
|
}
|
|
182
197
|
|
|
198
|
+
function _ipv4ToBytes(ip) {
|
|
199
|
+
// Canonical 4-byte form of an IPv4 address. Returns null on malformed
|
|
200
|
+
// input so a metadata-membership test never matches garbage.
|
|
201
|
+
var n = _ipv4ToInt(ip);
|
|
202
|
+
if (!Number.isFinite(n)) return null;
|
|
203
|
+
return Buffer.from([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
|
|
204
|
+
}
|
|
205
|
+
|
|
183
206
|
function _ipv6ToBytes(ip) {
|
|
184
207
|
// Node's net.isIPv6 returns 6 for valid IPv6; we then expand
|
|
185
208
|
// shorthand via manual parsing. node:net doesn't export an
|
|
@@ -309,7 +332,10 @@ function classify(ip) {
|
|
|
309
332
|
var family = net.isIP(ip);
|
|
310
333
|
if (family === 0) return null;
|
|
311
334
|
|
|
312
|
-
|
|
335
|
+
// Cloud-metadata IPs are matched on their canonical byte form so every
|
|
336
|
+
// textual spelling (compressed `::`, fully-expanded zero-runs, mixed
|
|
337
|
+
// case) is caught — a string-equality test matched one spelling only.
|
|
338
|
+
if (_isCloudMetadataAddr(ip, family)) return "cloud-metadata";
|
|
313
339
|
|
|
314
340
|
if (family === 4) {
|
|
315
341
|
var ipInt = _ipv4ToInt(ip);
|
|
@@ -349,6 +375,24 @@ function classify(ip) {
|
|
|
349
375
|
return null;
|
|
350
376
|
}
|
|
351
377
|
|
|
378
|
+
// Canonical-bytes membership test for the cloud-metadata IP set. An IP
|
|
379
|
+
// matches iff its parsed bytes equal one of CLOUD_METADATA_BYTES, regardless
|
|
380
|
+
// of textual representation. This is the unconditional metadata gate — it
|
|
381
|
+
// must NOT be string-based, because IPv6 has many spellings of the same
|
|
382
|
+
// address (the DoH resolver in network-dns.js, for instance, emits the
|
|
383
|
+
// fully-expanded `fd00:ec2:0:0:0:0:0:254` rather than the compressed form).
|
|
384
|
+
function _isCloudMetadataAddr(ip, family) {
|
|
385
|
+
var fam = typeof family === "number" ? family : net.isIP(ip);
|
|
386
|
+
if (fam === 0) return false;
|
|
387
|
+
var bytes = fam === 4 ? _ipv4ToBytes(ip) : _ipv6ToBytes(ip);
|
|
388
|
+
if (!bytes) return false;
|
|
389
|
+
for (var i = 0; i < CLOUD_METADATA_BYTES.length; i++) {
|
|
390
|
+
var ref = CLOUD_METADATA_BYTES[i];
|
|
391
|
+
if (ref && ref.length === bytes.length && _bufEqual(bytes, ref)) return true;
|
|
392
|
+
}
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
|
|
352
396
|
function _bufEqual(a, b) {
|
|
353
397
|
// Compares Buffer-like byte arrays for equality. The buffers here
|
|
354
398
|
// are IP addresses, not secrets, so the comparison doesn't need
|
|
@@ -766,8 +810,11 @@ function checkUrlTextual(url, opts) {
|
|
|
766
810
|
// If the textual hostname IS an IP literal AND matches a cloud-
|
|
767
811
|
// metadata IP, refuse — even with `allowInternal: true` and a proxy.
|
|
768
812
|
// Metadata IPs leak instance credentials (AWS IMDS, GCP, Azure) and
|
|
769
|
-
// are not a configuration knob.
|
|
770
|
-
|
|
813
|
+
// are not a configuration knob. Matched on canonical bytes so a
|
|
814
|
+
// non-canonical IPv6 spelling (compressed / expanded / mixed-case)
|
|
815
|
+
// can't slip the textual gate the way it slipped classify().
|
|
816
|
+
var hostFamily = net.isIP(host);
|
|
817
|
+
if (hostFamily !== 0 && _isCloudMetadataAddr(host, hostFamily)) {
|
|
771
818
|
throw new ErrorClass(
|
|
772
819
|
"URL '" + parsed.toString() + "' resolves to cloud-metadata IP " + host +
|
|
773
820
|
" — refused unconditionally (not overridable via allowInternal + proxy)",
|