@blamejs/core 0.8.4 → 0.8.6
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 +4 -0
- package/lib/app-shutdown.js +142 -6
- package/lib/auth/oauth.js +28 -2
- package/lib/middleware/daily-byte-quota.js +275 -0
- package/lib/middleware/index.js +6 -0
- package/lib/middleware/require-mtls.js +179 -0
- package/lib/observability-otlp-exporter.js +37 -1
- package/package.json +3 -2
- package/sbom.cyclonedx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.8.x
|
|
10
10
|
|
|
11
|
+
- **0.8.6** (2026-05-06) — New primitives: `b.middleware.dailyByteQuota` + `b.appShutdown` extensions. **`b.middleware.dailyByteQuota`** — per-IP rolling 24-hour byte budget (24 hourly bins, slides per-second so a peer can't reset by waiting past midnight). Memory backend single-node by default; `opts.cache` wires `b.cache` for cluster-shared accounting. Refuses with 429 + `Retry-After` when peers exceed the quota; emits `middleware.daily_byte_quota.refused` audit + `middleware.daily_byte_quota.refused` metric. Inbound + outbound bytes counted (header bytes + content-length + outbound write/end byte counts). Fail-open on cache backend errors with audited reason — a flaky cache no longer takes the framework down. **`b.appShutdown` extensions** — `onUncaught` hook fires on `uncaughtException` / `unhandledRejection`; default is graceful-shutdown with exitCode=1, operators can wire a hook for relay to PagerDuty / observability before exit. `opts.signals` now accepts a custom signal list (defaults to `["SIGTERM","SIGINT"]`); operators add `SIGUSR2` (nodemon restart), `SIGHUP` (terminal disconnect), `SIGQUIT` (`kill -3`) without subclassing. `b.appShutdown.pidLock(lockPath)` — single-instance file lock that writes `process.pid`, refuses to acquire when another live process holds the lock, reaps stale lock files (PID gone), and releases on shutdown. **`b.observability.otlpExporter`** — new `system.observability.otlp_exporter.post_failed` audit emission distinguishes timeout / abort from generic network failure (operators can route timeout-rooted exporter degradation to a different alert channel). `stats()` now reports `droppedTotal` (queue overflow + export failed) and emits a `dropped_total` metric on every call so dashboards chart the running drop count. **`b.auth.oauth.fetchUserInfo`** — refuses on OIDC IdPs unless the caller threads `ufiOpts.idTokenSub` (the verified `sub` claim from `exchangeCode`'s `id_token`); cross-checks userinfo `sub === idTokenSub` to defend against token-substitution where a hostile IdP returns a different user's profile. Non-OIDC OAuth 2.0 deployments mis-flagged as `isOidc` opt out via `{ skipSubCheck: true }` with audited reason.
|
|
12
|
+
|
|
13
|
+
- **0.8.5** (2026-05-06) — Vendor-currency CI gate + `b.middleware.requireMtls` primitive. **Vendor-currency check** — `scripts/check-vendor-currency.js` + new CI job in `ci.yml` assert every npm-mapped vendored bundle in `lib/vendor/MANIFEST.json` matches the latest published version on the npm registry. Per-component check on meta-bundles (e.g. `peculiar-pki` → `@peculiar/x509` + `pkijs`). Master-branch corpus entries (`SecLists`) are checked against the GitHub Commits API for the bundled file's path on the source repo's default branch — if the upstream has commits newer than the manifest's `bundledAt` date, the gate fails. Registry errors stay advisory unless `BLAMEJS_VENDOR_CURRENCY_STRICT=1`. Operators run locally with `npm run check:vendor-currency`. **`b.middleware.requireMtls`** — new soft-enforcement middleware that rejects requests without an authenticated client certificate. Composes with `b.crypto.hashCertFingerprint` / `isCertRevoked` (added in v0.8.4) so operators pass `fingerprintAllowList: [...]` and `denyList: [...]` and the middleware does the constant-time match. `req.peerCert` + `req.peerFingerprint` are attached for downstream handlers. Audits `mtls.required.allowed` / `mtls.required.refused` with reason metadata.
|
|
14
|
+
|
|
11
15
|
- **0.8.4** (2026-05-06) — Supply-chain scanner findings + outbound HTTP posture + npm-publish unblock. **Outbound network surface** — `b.observability.otlpExporter` no longer defaults to `globalThis.fetch`; the default transport is now `b.httpClient` (`node:https` through the framework's PQC-hybrid agent + cert-pinning + SSRF guard). The prior default leaked an outbound network surface that supply-chain scanners flagged because it sat outside the framework's TLS posture; operators on fetch-only edge runtimes still override via `opts.fetchImpl`. **Dev-mode child-process isolation** — `b.dev.create()` now lazy-requires `child_process` (was top-level — flagged on every install regardless of whether `b.dev` was used) and refuses to construct when `NODE_ENV=production` unless the operator passes `opts.allowProduction:true` with an audited reason. A misconfigured production deploy that accidentally wires the dev-mode restart loop now crashes loudly at boot rather than spawning shells on every save. **Outbound posture additions** — `b.httpClient.request` adds `responseMode: "always-resolve"` (every response resolves with `{statusCode, headers, body}` regardless of HTTP status) plus `onRedirect({from, to, hop, headersStripped, statusCode, method})` hook (operators can throw to abort or rewrite the redirect chain). `b.wsClient.connect` adds `urlFor(attempt)` and `tlsOptsFor(attempt)` per-dial overrides for between-reconnect URL / TLS rotation; the new URL is re-validated through `ssrfGuard` so a hostile upstream can't redirect a reconnecting client at a private address. `b.wsClient` swallows post-`close()` `ECONNRESET` / `EPIPE` so a clean shutdown doesn't surface a noisy unhandled-error event. `b.pqcAgent.create({ ecdhCurve })` accepts a caller-supplied stricter list — operators can drop a group from the framework default but cannot widen with non-PQ groups (the prior hardcoded value blocked legitimate per-deployment narrowing). **Crypto helpers** — `b.crypto.hashCertFingerprint(pem|der)` returns `{ hex, colon }` SHA3-512 digests and `b.crypto.isCertRevoked(pemOrDer, denyList)` does a constant-time match. **Scheduler surface** — `b.scheduler.register(name, intervalMs, fn)` shorthand for the every-N-ms registration shape; `b.scheduler.getStatus()` returns an aggregate health surface for probes / dashboards (started flag, isLeader, per-task list, totals). **npm-publish unblock** — `test/layer-0-primitives/sd-jwt-vc.test.js` was asserting `DEFAULT_ALG === "ES256"` after v0.8.1 flipped the default to `ML-DSA-87`; the assertion now matches the lib (and `DEFAULT_HASH_ALG` for `sha3-512`). v0.8.1 / v0.8.2 / v0.8.3 all failed the npm-publish gate on this single test; this release re-enables the publish workflow.
|
|
12
16
|
|
|
13
17
|
- **0.8.3** (2026-05-06) — Release-gate fixes + post-v0.8.2 hardening. Wiki primitive-section validator gate flagged `b.middleware.csrfProtect` opts-key drift — the `requireOrigin` opt added in v0.8.1 wasn't documented in the wiki seeder; now listed alongside `checkOrigin` / `allowedOrigins`. Gitleaks secret-scan gate flagged `{ privateKey, cipherText }` KEM-envelope shapes in `lib/crypto.js` error messages + `CHANGELOG.md` v0.8.0 entry as generic-api-key false positives — `.gitleaks.toml` adds an explicit regex allowlist for the parameter-name shape and pins the v0.8.0 commit fingerprints. Functional additions: `b.httpClient.request` adds `responseMode: "always-resolve"` opt — every request resolves with `{ statusCode, headers, body }` regardless of HTTP status (operators using the framework as an inbound-proxy upstream no longer have to wrap each call in a try/catch to recover the body of a 4xx/5xx). `b.wsClient` swallows post-close `ECONNRESET` / `EPIPE` errors so a clean `close()` doesn't surface a noisy unhandled-error event when the kernel races the FIN with an in-flight write. `SECURITY.md` documents the `allowInternal: true` test-pattern (legitimate same-host integration tests opt in explicitly with audited reason — never as a production default).
|
package/lib/app-shutdown.js
CHANGED
|
@@ -239,21 +239,65 @@ function create(opts) {
|
|
|
239
239
|
};
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
// Operator-supplied uncaught-exception / unhandled-rejection hook.
|
|
243
|
+
// Default behaviour mirrors Node's: log + initiate graceful shutdown.
|
|
244
|
+
// Operators wire a custom hook to relay to PagerDuty / observability /
|
|
245
|
+
// crash-reporters before the process exits. A sync-throw in the hook
|
|
246
|
+
// is caught and logged but does NOT prevent the shutdown.
|
|
247
|
+
var onUncaught = typeof opts.onUncaught === "function" ? opts.onUncaught : null;
|
|
248
|
+
var uncaughtHandler = null;
|
|
249
|
+
var unhandledRejHandler = null;
|
|
250
|
+
|
|
251
|
+
// Operator-supplied set of signals that initiate shutdown. Defaults
|
|
252
|
+
// to ["SIGTERM", "SIGINT"]. SIGUSR2 (nodemon's restart signal),
|
|
253
|
+
// SIGHUP (terminal disconnect), SIGQUIT (graceful from kill -3) are
|
|
254
|
+
// common operator requests. Each signal still routes through the
|
|
255
|
+
// same _signalCallback so the shutdown semantics are identical.
|
|
256
|
+
var operatorSignals = Array.isArray(opts.signals) && opts.signals.length > 0
|
|
257
|
+
? opts.signals.slice() : ["SIGTERM", "SIGINT"];
|
|
258
|
+
|
|
259
|
+
function _installUncaught() {
|
|
260
|
+
if (uncaughtHandler || unhandledRejHandler) return;
|
|
261
|
+
uncaughtHandler = function (err, origin) {
|
|
262
|
+
log.error("uncaught " + (origin || "exception") + ": " + ((err && err.message) || String(err)));
|
|
263
|
+
if (onUncaught) {
|
|
264
|
+
try { onUncaught(err, origin); }
|
|
265
|
+
catch (e) { log.error("onUncaught hook threw: " + ((e && e.message) || String(e))); }
|
|
266
|
+
}
|
|
267
|
+
shutdown().finally(function () { process.exitCode = process.exitCode || 1; });
|
|
268
|
+
};
|
|
269
|
+
unhandledRejHandler = function (reason) {
|
|
270
|
+
uncaughtHandler(reason instanceof Error ? reason : new Error(String(reason)), "unhandledRejection");
|
|
271
|
+
};
|
|
272
|
+
process.on("uncaughtException", uncaughtHandler);
|
|
273
|
+
process.on("unhandledRejection", unhandledRejHandler);
|
|
274
|
+
}
|
|
275
|
+
function _uninstallUncaught() {
|
|
276
|
+
if (uncaughtHandler) { process.removeListener("uncaughtException", uncaughtHandler); uncaughtHandler = null; }
|
|
277
|
+
if (unhandledRejHandler) { process.removeListener("unhandledRejection", unhandledRejHandler); unhandledRejHandler = null; }
|
|
278
|
+
}
|
|
279
|
+
|
|
242
280
|
function installSignals() {
|
|
243
281
|
if (signalsInstalled) return;
|
|
244
282
|
signalsInstalled = true;
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
283
|
+
for (var si = 0; si < operatorSignals.length; si++) {
|
|
284
|
+
var sig = operatorSignals[si];
|
|
285
|
+
if (typeof sig !== "string" || sig.length === 0) continue;
|
|
286
|
+
signalHandlers[sig] = _signalCallback(sig);
|
|
287
|
+
process.on(sig, signalHandlers[sig]);
|
|
288
|
+
}
|
|
289
|
+
if (onUncaught || opts.installUncaught === true) _installUncaught();
|
|
249
290
|
}
|
|
250
291
|
|
|
251
292
|
function uninstallSignals() {
|
|
252
293
|
if (!signalsInstalled) return;
|
|
253
|
-
|
|
254
|
-
|
|
294
|
+
var keys = Object.keys(signalHandlers);
|
|
295
|
+
for (var ki = 0; ki < keys.length; ki++) {
|
|
296
|
+
process.removeListener(keys[ki], signalHandlers[keys[ki]]);
|
|
297
|
+
}
|
|
255
298
|
signalsInstalled = false;
|
|
256
299
|
signalHandlers = {};
|
|
300
|
+
_uninstallUncaught();
|
|
257
301
|
}
|
|
258
302
|
|
|
259
303
|
if (installSignalHandlers) installSignals();
|
|
@@ -377,9 +421,101 @@ function standardPhases(components) {
|
|
|
377
421
|
return phases;
|
|
378
422
|
}
|
|
379
423
|
|
|
424
|
+
// pidLock — single-instance file lock for processes that must run
|
|
425
|
+
// exactly once on a host. Writes process.pid to lockPath atomically
|
|
426
|
+
// (open+lock+write) and refuses to acquire if another live process
|
|
427
|
+
// already holds it. Stale lock files (PID gone or different exe) are
|
|
428
|
+
// reaped automatically. The lock is released on shutdown via an
|
|
429
|
+
// addPhase, so operators wire it like:
|
|
430
|
+
//
|
|
431
|
+
// var pidLock = b.appShutdown.pidLock("/var/run/blamejs.pid");
|
|
432
|
+
// pidLock.acquire(); // throws if locked elsewhere
|
|
433
|
+
// appShutdownInstance.addPhase({ name: "pidLock", run: pidLock.release });
|
|
434
|
+
//
|
|
435
|
+
// On Windows the underlying flock() call is unavailable; the pidLock
|
|
436
|
+
// falls back to "open with exclusive create" semantics (O_EXCL via
|
|
437
|
+
// fs.openSync) which gives the same single-instance guarantee but
|
|
438
|
+
// without the cross-process advisory lock — the lock file presence
|
|
439
|
+
// IS the lock.
|
|
440
|
+
var nodeFs = require("fs");
|
|
441
|
+
var nodePath = require("path");
|
|
442
|
+
|
|
443
|
+
function pidLock(lockPath) {
|
|
444
|
+
if (typeof lockPath !== "string" || lockPath.length === 0) {
|
|
445
|
+
throw new AppShutdownError("app-shutdown/bad-pidlock-path",
|
|
446
|
+
"pidLock(lockPath): lockPath must be a non-empty string (absolute path recommended)");
|
|
447
|
+
}
|
|
448
|
+
var fd = null;
|
|
449
|
+
var ownsLock = false;
|
|
450
|
+
|
|
451
|
+
function _isLivePid(pid) {
|
|
452
|
+
if (!pid || pid <= 0) return false;
|
|
453
|
+
try { process.kill(pid, 0); return true; } // signal 0 = existence-check
|
|
454
|
+
catch (e) { return e.code === "EPERM"; } // EPERM means process exists, just no rights
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function _readExisting() {
|
|
458
|
+
try {
|
|
459
|
+
var raw = nodeFs.readFileSync(lockPath, "utf8");
|
|
460
|
+
var pid = parseInt(String(raw).trim(), 10);
|
|
461
|
+
return isFinite(pid) && pid > 0 ? pid : null;
|
|
462
|
+
} catch (_e) { return null; }
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function acquire() {
|
|
466
|
+
if (ownsLock) return;
|
|
467
|
+
nodeFs.mkdirSync(nodePath.dirname(lockPath), { recursive: true });
|
|
468
|
+
var existing = _readExisting();
|
|
469
|
+
if (existing && _isLivePid(existing) && existing !== process.pid) {
|
|
470
|
+
throw new AppShutdownError("app-shutdown/pidlock-held",
|
|
471
|
+
"pidLock: '" + lockPath + "' already held by live PID " + existing);
|
|
472
|
+
}
|
|
473
|
+
if (existing) {
|
|
474
|
+
// Stale lock — owner is dead. Reap.
|
|
475
|
+
try { nodeFs.unlinkSync(lockPath); } catch (_e) { /* race: someone else reaped it */ }
|
|
476
|
+
}
|
|
477
|
+
try {
|
|
478
|
+
fd = nodeFs.openSync(lockPath, nodeFs.constants.O_WRONLY | nodeFs.constants.O_CREAT | nodeFs.constants.O_EXCL, 0o600);
|
|
479
|
+
} catch (e) {
|
|
480
|
+
if (e.code === "EEXIST") {
|
|
481
|
+
// Race: another process took the lock between our reap and create.
|
|
482
|
+
var winner = _readExisting();
|
|
483
|
+
throw new AppShutdownError("app-shutdown/pidlock-held",
|
|
484
|
+
"pidLock: '" + lockPath + "' acquired by PID " + (winner || "<unknown>") + " between read and write");
|
|
485
|
+
}
|
|
486
|
+
throw new AppShutdownError("app-shutdown/pidlock-open-failed",
|
|
487
|
+
"pidLock: failed to open '" + lockPath + "': " + e.message);
|
|
488
|
+
}
|
|
489
|
+
nodeFs.writeSync(fd, String(process.pid) + "\n");
|
|
490
|
+
nodeFs.fsyncSync(fd);
|
|
491
|
+
ownsLock = true;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function release() {
|
|
495
|
+
if (!ownsLock) return;
|
|
496
|
+
try { nodeFs.closeSync(fd); } catch (_e) { /* best-effort close */ }
|
|
497
|
+
fd = null;
|
|
498
|
+
try {
|
|
499
|
+
var current = _readExisting();
|
|
500
|
+
if (current === process.pid) nodeFs.unlinkSync(lockPath);
|
|
501
|
+
} catch (_e) { /* lock already gone — fine */ }
|
|
502
|
+
ownsLock = false;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function held() { return ownsLock; }
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
acquire: acquire,
|
|
509
|
+
release: release,
|
|
510
|
+
held: held,
|
|
511
|
+
path: lockPath,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
380
515
|
module.exports = {
|
|
381
516
|
create: create,
|
|
382
517
|
standardPhases: standardPhases,
|
|
518
|
+
pidLock: pidLock,
|
|
383
519
|
AppShutdownError: AppShutdownError,
|
|
384
520
|
DEFAULT_GRACE_MS: DEFAULT_GRACE_MS,
|
|
385
521
|
};
|
package/lib/auth/oauth.js
CHANGED
|
@@ -526,19 +526,45 @@ function create(opts) {
|
|
|
526
526
|
return await _normalizeTokens(tokens, { skipNonceCheck: true });
|
|
527
527
|
}
|
|
528
528
|
|
|
529
|
-
|
|
529
|
+
// OIDC requires fetchUserInfo to be called AFTER the id_token has
|
|
530
|
+
// been verified and its sub claim is known — otherwise the
|
|
531
|
+
// userinfo response can't be cross-checked against the id_token's
|
|
532
|
+
// sub, and a hostile IdP could swap the userinfo for a different
|
|
533
|
+
// user. RFC 7662 §3 doesn't mandate the cross-check but every OIDC
|
|
534
|
+
// conformance suite requires it. We refuse to call userinfo when
|
|
535
|
+
// isOidc=true unless the caller threaded the verified idTokenSub
|
|
536
|
+
// (or explicitly opted out via skipSubCheck for a non-OIDC OAuth
|
|
537
|
+
// 2.0 server presented as isOidc=false).
|
|
538
|
+
async function fetchUserInfo(accessToken, ufiOpts) {
|
|
539
|
+
ufiOpts = ufiOpts || {};
|
|
530
540
|
if (!accessToken) {
|
|
531
541
|
throw new OAuthError("auth-oauth/no-access-token",
|
|
532
542
|
"fetchUserInfo: access token is required");
|
|
533
543
|
}
|
|
544
|
+
if (isOidc && ufiOpts.idTokenSub === undefined && ufiOpts.skipSubCheck !== true) {
|
|
545
|
+
throw new OAuthError("auth-oauth/userinfo-no-id-token-sub",
|
|
546
|
+
"fetchUserInfo: OIDC providers require ufiOpts.idTokenSub " +
|
|
547
|
+
"(the verified sub claim from the id_token returned by " +
|
|
548
|
+
"exchangeCode) so the userinfo response can be cross-checked. " +
|
|
549
|
+
"Pass { idTokenSub: tokens.idToken.payload.sub } or, for non-" +
|
|
550
|
+
"OIDC OAuth 2.0 deployments mis-flagged as isOidc, opt out " +
|
|
551
|
+
"explicitly with { skipSubCheck: true } and an audited reason.");
|
|
552
|
+
}
|
|
534
553
|
var endpoint = await _resolveEndpoint("userinfoEndpoint");
|
|
535
|
-
|
|
554
|
+
var profile = await _fetchJson(endpoint, {
|
|
536
555
|
headers: {
|
|
537
556
|
"Authorization": "Bearer " + accessToken,
|
|
538
557
|
"Accept": "application/json",
|
|
539
558
|
"User-Agent": "blamejs",
|
|
540
559
|
},
|
|
541
560
|
});
|
|
561
|
+
if (isOidc && ufiOpts.idTokenSub !== undefined && profile && profile.sub !== ufiOpts.idTokenSub) {
|
|
562
|
+
throw new OAuthError("auth-oauth/userinfo-sub-mismatch",
|
|
563
|
+
"fetchUserInfo: userinfo.sub (" + profile.sub + ") does not match " +
|
|
564
|
+
"the id_token sub (" + ufiOpts.idTokenSub + ") — possible token " +
|
|
565
|
+
"substitution attack");
|
|
566
|
+
}
|
|
567
|
+
return profile;
|
|
542
568
|
}
|
|
543
569
|
|
|
544
570
|
async function revokeToken(token, ropts) {
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* dailyByteQuota middleware — per-IP rolling 24-hour byte budget.
|
|
4
|
+
*
|
|
5
|
+
* Tracks request + response bytes per peer IP across a rolling 24-hour
|
|
6
|
+
* window. When a peer exceeds the operator-configured quota, further
|
|
7
|
+
* requests are rejected with 429 + Retry-After. The window slides per-
|
|
8
|
+
* second so a peer that hammers the framework for 23 hours and 59
|
|
9
|
+
* minutes can't reset by waiting an instant past midnight.
|
|
10
|
+
*
|
|
11
|
+
* var quota = b.middleware.dailyByteQuota({
|
|
12
|
+
* bytesPerDay: b.constants.BYTES.gib(2), // 2 GiB / IP / day
|
|
13
|
+
* getKey: function (req) {
|
|
14
|
+
* // default: req.ip — operator overrides for tenant-id / api-key
|
|
15
|
+
* return req.ip;
|
|
16
|
+
* },
|
|
17
|
+
* cache: null, // single-node memory by default
|
|
18
|
+
* audit: b.audit,
|
|
19
|
+
* onExceeded: function (req, res, info) {
|
|
20
|
+
* res.setHeader("Retry-After", info.retryAfterSec);
|
|
21
|
+
* res.statusCode = 429;
|
|
22
|
+
* res.end(JSON.stringify({ error: "quota-exceeded", info: info }));
|
|
23
|
+
* },
|
|
24
|
+
* });
|
|
25
|
+
* router.use(quota);
|
|
26
|
+
*
|
|
27
|
+
* The middleware fires twice per request:
|
|
28
|
+
* - On entry: peek the running counter, refuse if already past quota
|
|
29
|
+
* - On res.end / res.write: account both directions of byte transfer
|
|
30
|
+
*
|
|
31
|
+
* Single-node memory backend uses a Map<ip, { bins: Uint32Array(24),
|
|
32
|
+
* windowStartHour: number }>. Each bin holds bytes for one rolling hour;
|
|
33
|
+
* sweeping happens on every account() call so cold storage doesn't grow
|
|
34
|
+
* unbounded. Cluster-aware operators wire opts.cache (b.cache instance)
|
|
35
|
+
* and the same pattern runs in the shared backend.
|
|
36
|
+
*
|
|
37
|
+
* Failure modes:
|
|
38
|
+
* - cache backend unreachable → fail-open (count drops, request
|
|
39
|
+
* proceeds), audit emitted to operator alerting; the alternative
|
|
40
|
+
* fail-closed would let a flaky cache take down the framework
|
|
41
|
+
* - peer key resolution returns null → request bypasses the quota
|
|
42
|
+
* (operator's getKey decided this IP is out-of-scope)
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
var C = require("../constants");
|
|
46
|
+
var defineClass = require("../framework-error").defineClass;
|
|
47
|
+
var lazyRequire = require("../lazy-require");
|
|
48
|
+
var validateOpts = require("../validate-opts");
|
|
49
|
+
|
|
50
|
+
var audit = lazyRequire(function () { return require("../audit"); });
|
|
51
|
+
var observability = lazyRequire(function () { return require("../observability"); });
|
|
52
|
+
var requestHelpers = lazyRequire(function () { return require("../request-helpers"); });
|
|
53
|
+
|
|
54
|
+
var DailyByteQuotaError = defineClass("DailyByteQuotaError", { alwaysPermanent: true });
|
|
55
|
+
|
|
56
|
+
var BINS_PER_DAY = 24; // allow:raw-byte-literal — 24 hours in a day
|
|
57
|
+
var BIN_MS = C.TIME.hours(1);
|
|
58
|
+
|
|
59
|
+
// Default getKey — req.ip OR the trusted-proxy-resolved peer address
|
|
60
|
+
// when the operator wired b.middleware.requestId or similar earlier in
|
|
61
|
+
// the chain. We don't try to be clever here: req.ip is the canonical
|
|
62
|
+
// shape every other middleware reads.
|
|
63
|
+
function _defaultGetKey(req) {
|
|
64
|
+
return requestHelpers().clientIp(req, { trustProxy: false });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function _hourBin(nowMs) { return Math.floor(nowMs / BIN_MS); }
|
|
68
|
+
function _newEntry() { return { bins: new Array(BINS_PER_DAY).fill(0), startHour: 0 }; }
|
|
69
|
+
|
|
70
|
+
// Shared sliding-window helper — both backends call this so the
|
|
71
|
+
// per-bin shift / zero / total math lives in one place. Returns the
|
|
72
|
+
// (possibly mutated) entry; caller persists if the entry is shared
|
|
73
|
+
// state (cache backend writes back).
|
|
74
|
+
function _slideAndSum(entry, nowHour) {
|
|
75
|
+
if (entry.startHour === 0) entry.startHour = nowHour - (BINS_PER_DAY - 1);
|
|
76
|
+
var advance = nowHour - (entry.startHour + (BINS_PER_DAY - 1));
|
|
77
|
+
var moved = false;
|
|
78
|
+
if (advance > 0) {
|
|
79
|
+
moved = true;
|
|
80
|
+
if (advance >= BINS_PER_DAY) {
|
|
81
|
+
for (var i = 0; i < BINS_PER_DAY; i++) entry.bins[i] = 0;
|
|
82
|
+
} else {
|
|
83
|
+
for (var j = 0; j < BINS_PER_DAY - advance; j++) entry.bins[j] = entry.bins[j + advance];
|
|
84
|
+
for (var k = BINS_PER_DAY - advance; k < BINS_PER_DAY; k++) entry.bins[k] = 0;
|
|
85
|
+
}
|
|
86
|
+
entry.startHour = nowHour - (BINS_PER_DAY - 1);
|
|
87
|
+
}
|
|
88
|
+
var total = 0;
|
|
89
|
+
for (var t = 0; t < BINS_PER_DAY; t++) total += entry.bins[t];
|
|
90
|
+
return { entry: entry, total: total, moved: moved };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function _memoryBackend() {
|
|
94
|
+
var store = new Map();
|
|
95
|
+
function _get(key) {
|
|
96
|
+
var entry = store.get(key);
|
|
97
|
+
if (!entry) { entry = _newEntry(); store.set(key, entry); }
|
|
98
|
+
return entry;
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
async total(key, nowMs) {
|
|
102
|
+
return _slideAndSum(_get(key), _hourBin(nowMs)).total;
|
|
103
|
+
},
|
|
104
|
+
async account(key, bytes, nowMs) {
|
|
105
|
+
var slid = _slideAndSum(_get(key), _hourBin(nowMs));
|
|
106
|
+
slid.entry.bins[BINS_PER_DAY - 1] += bytes;
|
|
107
|
+
},
|
|
108
|
+
_resetForTest: function () { store.clear(); },
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function _cacheBackend(cache) {
|
|
113
|
+
function _key(k) { return "dailyByteQuota:" + k; }
|
|
114
|
+
async function _read(key) {
|
|
115
|
+
var raw = await cache.get(_key(key));
|
|
116
|
+
return raw && typeof raw === "object" && Array.isArray(raw.bins) ? raw : _newEntry();
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
async total(key, nowMs) {
|
|
120
|
+
var entry = await _read(key);
|
|
121
|
+
var slid = _slideAndSum(entry, _hourBin(nowMs));
|
|
122
|
+
if (slid.moved) await cache.set(_key(key), slid.entry, { ttlMs: BIN_MS * BINS_PER_DAY });
|
|
123
|
+
return slid.total;
|
|
124
|
+
},
|
|
125
|
+
async account(key, bytes, nowMs) {
|
|
126
|
+
var entry = await _read(key);
|
|
127
|
+
var slid = _slideAndSum(entry, _hourBin(nowMs));
|
|
128
|
+
slid.entry.bins[BINS_PER_DAY - 1] += bytes;
|
|
129
|
+
await cache.set(_key(key), slid.entry, { ttlMs: BIN_MS * BINS_PER_DAY });
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function create(opts) {
|
|
135
|
+
opts = opts || {};
|
|
136
|
+
validateOpts(opts, [
|
|
137
|
+
"bytesPerDay", "cache", "getKey", "audit",
|
|
138
|
+
"onExceeded", "skipPaths", "now",
|
|
139
|
+
], "middleware.dailyByteQuota");
|
|
140
|
+
|
|
141
|
+
if (typeof opts.bytesPerDay !== "number" || !isFinite(opts.bytesPerDay) || opts.bytesPerDay <= 0) {
|
|
142
|
+
throw new DailyByteQuotaError("daily-byte-quota/bad-quota",
|
|
143
|
+
"middleware.dailyByteQuota: opts.bytesPerDay must be a positive finite number; " +
|
|
144
|
+
"use b.constants.BYTES.gib(N) / mib(N) for readable values");
|
|
145
|
+
}
|
|
146
|
+
var bytesPerDay = opts.bytesPerDay;
|
|
147
|
+
var getKey = typeof opts.getKey === "function" ? opts.getKey : _defaultGetKey;
|
|
148
|
+
var auditOn = opts.audit !== false;
|
|
149
|
+
var onExceeded = typeof opts.onExceeded === "function" ? opts.onExceeded : null;
|
|
150
|
+
var skipPaths = Array.isArray(opts.skipPaths) ? opts.skipPaths.slice() : [];
|
|
151
|
+
var now = typeof opts.now === "function" ? opts.now : function () { return Date.now(); };
|
|
152
|
+
var backend = opts.cache && typeof opts.cache.get === "function"
|
|
153
|
+
? _cacheBackend(opts.cache)
|
|
154
|
+
: _memoryBackend();
|
|
155
|
+
|
|
156
|
+
function _shouldSkip(req) {
|
|
157
|
+
if (skipPaths.length === 0) return false;
|
|
158
|
+
var p = req.url || req.originalUrl || "";
|
|
159
|
+
var qpos = p.indexOf("?");
|
|
160
|
+
if (qpos !== -1) p = p.slice(0, qpos);
|
|
161
|
+
for (var i = 0; i < skipPaths.length; i++) {
|
|
162
|
+
if (typeof skipPaths[i] === "string" && p === skipPaths[i]) return true;
|
|
163
|
+
if (skipPaths[i] instanceof RegExp && skipPaths[i].test(p)) return true;
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function _emitAudit(action, outcome, metadata) {
|
|
169
|
+
if (!auditOn) return;
|
|
170
|
+
try {
|
|
171
|
+
audit().safeEmit({
|
|
172
|
+
action: "middleware.daily_byte_quota." + action,
|
|
173
|
+
outcome: outcome,
|
|
174
|
+
metadata: metadata || {},
|
|
175
|
+
});
|
|
176
|
+
} catch (_e) { /* drop-silent — audit is best-effort */ }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function _emitMetric(verb, n, labels) {
|
|
180
|
+
try { observability().safeEvent("middleware.daily_byte_quota." + verb, n || 1, labels || {}); }
|
|
181
|
+
catch (_e) { /* drop-silent */ }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return async function dailyByteQuotaMiddleware(req, res, next) {
|
|
185
|
+
if (_shouldSkip(req)) return next();
|
|
186
|
+
var key;
|
|
187
|
+
try { key = getKey(req); }
|
|
188
|
+
catch (e) {
|
|
189
|
+
_emitAudit("get_key_failed", "failure", { error: (e && e.message) || String(e) });
|
|
190
|
+
return next(); // fail-open on operator-supplied key resolution
|
|
191
|
+
}
|
|
192
|
+
if (!key) return next();
|
|
193
|
+
|
|
194
|
+
var nowMs = now();
|
|
195
|
+
var total;
|
|
196
|
+
try { total = await backend.total(key, nowMs); }
|
|
197
|
+
catch (e) {
|
|
198
|
+
_emitAudit("backend_error", "failure", { phase: "total", error: (e && e.message) || String(e) });
|
|
199
|
+
return next(); // fail-open on cache miss
|
|
200
|
+
}
|
|
201
|
+
if (total >= bytesPerDay) {
|
|
202
|
+
_emitMetric("refused", 1, { reason: "quota-exceeded" });
|
|
203
|
+
_emitAudit("refused", "denied", { key: key, total: total, quota: bytesPerDay });
|
|
204
|
+
var info = {
|
|
205
|
+
quota: bytesPerDay,
|
|
206
|
+
total: total,
|
|
207
|
+
retryAfterSec: Math.max(C.TIME.seconds(1) / C.TIME.seconds(1) | 0, Math.ceil(BIN_MS / C.TIME.seconds(1))),
|
|
208
|
+
};
|
|
209
|
+
if (onExceeded) {
|
|
210
|
+
try { return onExceeded(req, res, info); }
|
|
211
|
+
catch (e) { _emitAudit("on_exceeded_threw", "failure", { error: (e && e.message) || String(e) }); }
|
|
212
|
+
}
|
|
213
|
+
if (!res.writableEnded) {
|
|
214
|
+
res.writeHead(429, {
|
|
215
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
216
|
+
"Retry-After": String(info.retryAfterSec),
|
|
217
|
+
"Cache-Control": "no-store",
|
|
218
|
+
});
|
|
219
|
+
res.end(JSON.stringify({ error: "quota-exceeded", quota: bytesPerDay, total: total }));
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Account both inbound + outbound bytes. Inbound is roughly the
|
|
225
|
+
// header bytes (we don't proxy the body buffer to count). Outbound
|
|
226
|
+
// is observed via writableLength as res.write / res.end fire.
|
|
227
|
+
var inboundBytes = 0;
|
|
228
|
+
if (req.headers && typeof req.headers === "object") {
|
|
229
|
+
// Approximate: each header line is "Name: Value\r\n". Sum the
|
|
230
|
+
// string lengths; the actual byte count differs only on multi-
|
|
231
|
+
// byte UTF-8, which is uncommon in standard headers.
|
|
232
|
+
var keys = Object.keys(req.headers);
|
|
233
|
+
for (var hi = 0; hi < keys.length; hi++) {
|
|
234
|
+
var v = req.headers[keys[hi]];
|
|
235
|
+
inboundBytes += keys[hi].length + 2 + (typeof v === "string" ? v.length : 0) + 2; // allow:raw-byte-literal — ": " + "\r\n" overhead
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (req.headers && req.headers["content-length"]) {
|
|
239
|
+
var clen = parseInt(req.headers["content-length"], 10);
|
|
240
|
+
if (isFinite(clen) && clen > 0) inboundBytes += clen;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Patch res.write / res.end to account outbound bytes.
|
|
244
|
+
var outboundBytes = 0;
|
|
245
|
+
var origWrite = res.write.bind(res);
|
|
246
|
+
var origEnd = res.end.bind(res);
|
|
247
|
+
res.write = function (chunk, encoding, cb) {
|
|
248
|
+
if (chunk) {
|
|
249
|
+
outboundBytes += Buffer.isBuffer(chunk) ? chunk.length :
|
|
250
|
+
Buffer.byteLength(chunk, typeof encoding === "string" ? encoding : "utf8");
|
|
251
|
+
}
|
|
252
|
+
return origWrite(chunk, encoding, cb);
|
|
253
|
+
};
|
|
254
|
+
res.end = function (chunk, encoding, cb) {
|
|
255
|
+
if (chunk) {
|
|
256
|
+
outboundBytes += Buffer.isBuffer(chunk) ? chunk.length :
|
|
257
|
+
Buffer.byteLength(chunk, typeof encoding === "string" ? encoding : "utf8");
|
|
258
|
+
}
|
|
259
|
+
// Account on response end so a slow long-poll doesn't block the
|
|
260
|
+
// accounting until the client drops.
|
|
261
|
+
backend.account(key, inboundBytes + outboundBytes, now())
|
|
262
|
+
.catch(function (e) { _emitAudit("backend_error", "failure", { phase: "account", error: (e && e.message) || String(e) }); });
|
|
263
|
+
return origEnd(chunk, encoding, cb);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
return next();
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
module.exports = {
|
|
271
|
+
create: create,
|
|
272
|
+
DailyByteQuotaError: DailyByteQuotaError,
|
|
273
|
+
_memoryBackend: _memoryBackend, // exported for tests
|
|
274
|
+
BINS_PER_DAY: BINS_PER_DAY,
|
|
275
|
+
};
|
package/lib/middleware/index.js
CHANGED
|
@@ -29,6 +29,7 @@ var botGuard = require("./bot-guard");
|
|
|
29
29
|
var compression = require("./compression");
|
|
30
30
|
var cookies = require("./cookies");
|
|
31
31
|
var cors = require("./cors");
|
|
32
|
+
var dailyByteQuota = require("./daily-byte-quota");
|
|
32
33
|
var cspNonce = require("./csp-nonce");
|
|
33
34
|
var csrfProtect = require("./csrf-protect");
|
|
34
35
|
var dbRoleFor = require("./db-role-for");
|
|
@@ -47,6 +48,7 @@ var requireAal = require("./require-aal");
|
|
|
47
48
|
var requireAuth = require("./require-auth");
|
|
48
49
|
var requireContentType = require("./require-content-type");
|
|
49
50
|
var requireMethods = require("./require-methods");
|
|
51
|
+
var requireMtls = require("./require-mtls");
|
|
50
52
|
var requireStepUp = require("./require-step-up");
|
|
51
53
|
var securityHeaders = require("./security-headers");
|
|
52
54
|
var securityTxt = require("./security-txt");
|
|
@@ -63,6 +65,7 @@ module.exports = {
|
|
|
63
65
|
errorHandler: errorHandler.create,
|
|
64
66
|
botGuard: botGuard.create,
|
|
65
67
|
cors: cors.create,
|
|
68
|
+
dailyByteQuota: dailyByteQuota.create,
|
|
66
69
|
rateLimit: rateLimit.create,
|
|
67
70
|
attachUser: attachUser.create,
|
|
68
71
|
bearerAuth: bearerAuth.create,
|
|
@@ -70,6 +73,7 @@ module.exports = {
|
|
|
70
73
|
requireAuth: requireAuth.create,
|
|
71
74
|
requireContentType: requireContentType.create,
|
|
72
75
|
requireMethods: requireMethods.create,
|
|
76
|
+
requireMtls: requireMtls.create,
|
|
73
77
|
requireStepUp: requireStepUp.create,
|
|
74
78
|
csrfProtect: csrfProtect.create,
|
|
75
79
|
fetchMetadata: fetchMetadata.create,
|
|
@@ -106,6 +110,7 @@ module.exports = {
|
|
|
106
110
|
errorHandler: errorHandler,
|
|
107
111
|
botGuard: botGuard,
|
|
108
112
|
cors: cors,
|
|
113
|
+
dailyByteQuota: dailyByteQuota,
|
|
109
114
|
rateLimit: rateLimit,
|
|
110
115
|
attachUser: attachUser,
|
|
111
116
|
bearerAuth: bearerAuth,
|
|
@@ -113,6 +118,7 @@ module.exports = {
|
|
|
113
118
|
requireAuth: requireAuth,
|
|
114
119
|
requireContentType: requireContentType,
|
|
115
120
|
requireMethods: requireMethods,
|
|
121
|
+
requireMtls: requireMtls,
|
|
116
122
|
requireStepUp: requireStepUp,
|
|
117
123
|
csrfProtect: csrfProtect,
|
|
118
124
|
fetchMetadata: fetchMetadata,
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* requireMtls middleware — soft-enforcement gate for routes that
|
|
4
|
+
* require a client certificate.
|
|
5
|
+
*
|
|
6
|
+
* Operators terminate TLS at the framework's HTTPS server with
|
|
7
|
+
* `requestCert: true` (the framework already wires this when
|
|
8
|
+
* `b.app({ tlsOptions: { requestCert: true, ca: [...] } })` is
|
|
9
|
+
* configured). For routes that MUST receive an authenticated peer
|
|
10
|
+
* cert — e.g. the inbound side of an mTLS service mesh, OAuth 2.0
|
|
11
|
+
* mTLS Client Authentication (RFC 8705), or operator-specific
|
|
12
|
+
* service-to-service endpoints — wire this middleware in front of
|
|
13
|
+
* the route to reject any request that didn't present a valid
|
|
14
|
+
* client cert.
|
|
15
|
+
*
|
|
16
|
+
* var requireMtls = b.middleware.requireMtls({
|
|
17
|
+
* fingerprintAllowList: [
|
|
18
|
+
* "AB:CD:EF:...", // colon-separated SHA3-512 hex
|
|
19
|
+
* ],
|
|
20
|
+
* denyList: [], // explicit revocations
|
|
21
|
+
* onAuthenticated: function (req, res, next) {
|
|
22
|
+
* req.peerSubject = req.peerCert.subject;
|
|
23
|
+
* next();
|
|
24
|
+
* },
|
|
25
|
+
* audit: b.audit,
|
|
26
|
+
* });
|
|
27
|
+
* router.use("/internal", requireMtls);
|
|
28
|
+
*
|
|
29
|
+
* Failure modes (all reject 401):
|
|
30
|
+
* - No peer cert presented (client did not negotiate mTLS)
|
|
31
|
+
* - Peer cert present but unauthorized at TLS layer
|
|
32
|
+
* (req.client.authorized === false)
|
|
33
|
+
* - Fingerprint not on the operator-supplied allow-list
|
|
34
|
+
* - Fingerprint on the operator-supplied deny-list
|
|
35
|
+
*
|
|
36
|
+
* Audit shape (when audit is wired): emits `mtls.required.allowed`
|
|
37
|
+
* (success) or `mtls.required.refused` (denied) with the peer-cert
|
|
38
|
+
* fingerprint + subject + reason in metadata. Drop-silent if no
|
|
39
|
+
* audit is wired.
|
|
40
|
+
*
|
|
41
|
+
* The fingerprint allow / deny comparison routes through
|
|
42
|
+
* b.crypto.isCertRevoked — both forms (lowercase hex / uppercase
|
|
43
|
+
* colon-separated) match. Allow-list of empty / null = "any
|
|
44
|
+
* peer cert authorized at the TLS layer"; specifying a non-empty
|
|
45
|
+
* allow-list ALSO requires the fingerprint to match.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
var defineClass = require("../framework-error").defineClass;
|
|
49
|
+
var lazyRequire = require("../lazy-require");
|
|
50
|
+
var validateOpts = require("../validate-opts");
|
|
51
|
+
|
|
52
|
+
var crypto = lazyRequire(function () { return require("../crypto"); });
|
|
53
|
+
var audit = lazyRequire(function () { return require("../audit"); });
|
|
54
|
+
|
|
55
|
+
var RequireMtlsError = defineClass("RequireMtlsError", { alwaysPermanent: true });
|
|
56
|
+
|
|
57
|
+
function _normalizeFingerprintEntry(entry) {
|
|
58
|
+
if (typeof entry !== "string" || entry.length === 0) {
|
|
59
|
+
throw new RequireMtlsError("require-mtls/bad-fingerprint",
|
|
60
|
+
"fingerprint allow/deny entries must be non-empty strings " +
|
|
61
|
+
"(SHA3-512 hex or colon-separated form)");
|
|
62
|
+
}
|
|
63
|
+
return entry;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function create(opts) {
|
|
67
|
+
opts = opts || {};
|
|
68
|
+
validateOpts(opts, [
|
|
69
|
+
"fingerprintAllowList", "denyList",
|
|
70
|
+
"onAuthenticated", "audit",
|
|
71
|
+
"auditAction", "errorMessage",
|
|
72
|
+
], "middleware.requireMtls");
|
|
73
|
+
|
|
74
|
+
var allowList = Array.isArray(opts.fingerprintAllowList)
|
|
75
|
+
? opts.fingerprintAllowList.map(_normalizeFingerprintEntry) : null;
|
|
76
|
+
var denyList = Array.isArray(opts.denyList)
|
|
77
|
+
? opts.denyList.map(_normalizeFingerprintEntry) : [];
|
|
78
|
+
var onAuthenticated = typeof opts.onAuthenticated === "function" ? opts.onAuthenticated : null;
|
|
79
|
+
var auditOn = opts.audit !== false;
|
|
80
|
+
var actionBase = typeof opts.auditAction === "string" && opts.auditAction.length > 0
|
|
81
|
+
? opts.auditAction : "mtls.required";
|
|
82
|
+
var errorMessage = typeof opts.errorMessage === "string" && opts.errorMessage.length > 0
|
|
83
|
+
? opts.errorMessage : "client certificate required";
|
|
84
|
+
|
|
85
|
+
function _emit(outcome, metadata) {
|
|
86
|
+
if (!auditOn) return;
|
|
87
|
+
try {
|
|
88
|
+
audit().safeEmit({
|
|
89
|
+
action: actionBase + (outcome === "success" ? ".allowed" : ".refused"),
|
|
90
|
+
outcome: outcome,
|
|
91
|
+
metadata: metadata || {},
|
|
92
|
+
});
|
|
93
|
+
} catch (_e) { /* drop-silent — audit is best-effort, never blocks the request */ }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function _refuse(res, reason, metadata) {
|
|
97
|
+
_emit("denied", Object.assign({ reason: reason }, metadata || {}));
|
|
98
|
+
if (typeof res.writeHead === "function") {
|
|
99
|
+
res.writeHead(401, {
|
|
100
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
101
|
+
"WWW-Authenticate": "Mutual",
|
|
102
|
+
"Cache-Control": "no-store",
|
|
103
|
+
});
|
|
104
|
+
res.end(JSON.stringify({ error: errorMessage, reason: reason }));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return function requireMtlsMiddleware(req, res, next) {
|
|
109
|
+
// Node's TLSSocket exposes:
|
|
110
|
+
// req.client.authorized — boolean, peer cert chain valid
|
|
111
|
+
// req.client.authorizationError — string when authorized=false
|
|
112
|
+
// req.socket.getPeerCertificate() — the cert (raw + parsed fields)
|
|
113
|
+
// Behind a TLS-terminating proxy (e.g. nginx, envoy) operators
|
|
114
|
+
// pass the peer cert as a header (X-Client-Cert) and pre-populate
|
|
115
|
+
// req.peerCert before this middleware fires. We don't inject a
|
|
116
|
+
// proxy-header parser here — that's an operator-side decision tied
|
|
117
|
+
// to the chosen proxy's signing model.
|
|
118
|
+
var sock = req.socket || req.connection || null;
|
|
119
|
+
var authorized = sock && sock.authorized === true;
|
|
120
|
+
var peerCert = req.peerCert || null;
|
|
121
|
+
if (!peerCert && sock && typeof sock.getPeerCertificate === "function") {
|
|
122
|
+
try { peerCert = sock.getPeerCertificate(true) || null; }
|
|
123
|
+
catch (_e) { peerCert = null; }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!authorized) {
|
|
127
|
+
var authzError = (sock && sock.authorizationError) || "no-peer-cert";
|
|
128
|
+
return _refuse(res, "tls-unauthorized", { authorizationError: String(authzError) });
|
|
129
|
+
}
|
|
130
|
+
if (!peerCert || !peerCert.raw) {
|
|
131
|
+
return _refuse(res, "no-peer-cert", {});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Compute fingerprint via the framework's SHA3-512 helper. Buffer
|
|
135
|
+
// form: peerCert.raw is the DER. Hex/colon both available for
|
|
136
|
+
// allow/deny matching.
|
|
137
|
+
var fp;
|
|
138
|
+
try {
|
|
139
|
+
fp = crypto().hashCertFingerprint(peerCert.raw);
|
|
140
|
+
} catch (e) {
|
|
141
|
+
return _refuse(res, "fingerprint-failed", { error: (e && e.message) || String(e) });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (denyList.length > 0 && crypto().isCertRevoked(peerCert.raw, denyList)) {
|
|
145
|
+
return _refuse(res, "fingerprint-on-deny-list", {
|
|
146
|
+
fingerprint: fp.colon,
|
|
147
|
+
subject: (peerCert.subject && peerCert.subject.CN) || null,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
if (allowList && allowList.length > 0 && !crypto().isCertRevoked(peerCert.raw, allowList)) {
|
|
151
|
+
return _refuse(res, "fingerprint-not-allowed", {
|
|
152
|
+
fingerprint: fp.colon,
|
|
153
|
+
subject: (peerCert.subject && peerCert.subject.CN) || null,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Authenticated — attach the parsed peer cert + fingerprint to
|
|
158
|
+
// the request so downstream handlers don't have to re-parse, then
|
|
159
|
+
// emit success and call next (or operator's onAuthenticated hook).
|
|
160
|
+
req.peerCert = peerCert;
|
|
161
|
+
req.peerFingerprint = fp;
|
|
162
|
+
_emit("success", {
|
|
163
|
+
fingerprint: fp.colon,
|
|
164
|
+
subject: (peerCert.subject && peerCert.subject.CN) || null,
|
|
165
|
+
});
|
|
166
|
+
if (onAuthenticated) {
|
|
167
|
+
try { return onAuthenticated(req, res, next); }
|
|
168
|
+
catch (e) {
|
|
169
|
+
return _refuse(res, "on-authenticated-threw", { error: (e && e.message) || String(e) });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return next();
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = {
|
|
177
|
+
create: create,
|
|
178
|
+
RequireMtlsError: RequireMtlsError,
|
|
179
|
+
};
|
|
@@ -41,6 +41,7 @@ var { defineClass } = require("./framework-error");
|
|
|
41
41
|
var OtlpExporterError = defineClass("OtlpExporterError", { alwaysPermanent: true });
|
|
42
42
|
|
|
43
43
|
var observability = lazyRequire(function () { return require("./observability"); });
|
|
44
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
44
45
|
var httpClient = lazyRequire(function () { return require("./http-client"); });
|
|
45
46
|
|
|
46
47
|
// Default OTLP transport — uses the framework's own b.httpClient
|
|
@@ -251,10 +252,21 @@ function create(opts) {
|
|
|
251
252
|
var inFlight = false;
|
|
252
253
|
var stopping = false;
|
|
253
254
|
|
|
255
|
+
var auditOn = opts.audit !== false;
|
|
254
256
|
function _emitMetric(verb, n, labels) {
|
|
255
257
|
try { observability().safeEvent("otlp.exporter." + verb, n || 1, labels || {}); }
|
|
256
258
|
catch (_e) { /* drop-silent */ }
|
|
257
259
|
}
|
|
260
|
+
function _emitAudit(action, outcome, metadata) {
|
|
261
|
+
if (!auditOn) return;
|
|
262
|
+
try {
|
|
263
|
+
audit().safeEmit({
|
|
264
|
+
action: "system.observability.otlp_exporter." + action,
|
|
265
|
+
outcome: outcome,
|
|
266
|
+
metadata: metadata || {},
|
|
267
|
+
});
|
|
268
|
+
} catch (_e) { /* drop-silent — audit is best-effort, never crashes the exporter */ }
|
|
269
|
+
}
|
|
258
270
|
|
|
259
271
|
function queue_(span) {
|
|
260
272
|
if (stopping) { droppedExportFailed += 1; return; }
|
|
@@ -302,7 +314,19 @@ function create(opts) {
|
|
|
302
314
|
}
|
|
303
315
|
return { ok: false, status: status, retryable: retryable };
|
|
304
316
|
} catch (e) {
|
|
305
|
-
// Network error / abort
|
|
317
|
+
// Network error / abort. AbortController abort surfaces with
|
|
318
|
+
// name=AbortError; tag the audit so operators can distinguish
|
|
319
|
+
// a genuine network drop from "we timed out reaching the
|
|
320
|
+
// collector". Both are retryable but the audit metadata helps
|
|
321
|
+
// root-cause when collector latency is the issue.
|
|
322
|
+
var abortReason = e && (e.name === "AbortError" || /aborted|timeout/i.test(e.message || ""));
|
|
323
|
+
_emitAudit("post_failed", "failure", {
|
|
324
|
+
attempt: attempt,
|
|
325
|
+
retryable: attempt < maxAttempts,
|
|
326
|
+
reason: abortReason ? "timeout" : "network",
|
|
327
|
+
error: (e && e.message) || String(e),
|
|
328
|
+
});
|
|
329
|
+
if (abortReason) _emitMetric("export_timeout", 1, { attempt: String(attempt) });
|
|
306
330
|
if (attempt < maxAttempts) {
|
|
307
331
|
await _sleep(_backoffMs(attempt));
|
|
308
332
|
return await _post(payload, attempt + 1);
|
|
@@ -354,10 +378,22 @@ function create(opts) {
|
|
|
354
378
|
}
|
|
355
379
|
|
|
356
380
|
function stats() {
|
|
381
|
+
var totalDropped = droppedQueueOverflow + droppedExportFailed;
|
|
382
|
+
// Operator-facing dropped-count metric — fires every stats() call
|
|
383
|
+
// so dashboards / probes that scrape stats can chart the running
|
|
384
|
+
// total even when individual drop sites already emit per-event
|
|
385
|
+
// metrics. The metric is monotonic for the lifetime of the
|
|
386
|
+
// exporter; a process restart resets it (intended).
|
|
387
|
+
_emitMetric("dropped_total", 0, {
|
|
388
|
+
queue_overflow: String(droppedQueueOverflow),
|
|
389
|
+
export_failed: String(droppedExportFailed),
|
|
390
|
+
total: String(totalDropped),
|
|
391
|
+
});
|
|
357
392
|
return {
|
|
358
393
|
queueLength: queue.length,
|
|
359
394
|
droppedQueueOverflow: droppedQueueOverflow,
|
|
360
395
|
droppedExportFailed: droppedExportFailed,
|
|
396
|
+
droppedTotal: totalDropped,
|
|
361
397
|
};
|
|
362
398
|
}
|
|
363
399
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blamejs/core",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.6",
|
|
4
4
|
"description": "The Node framework that owns its stack.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "blamejs contributors",
|
|
@@ -70,7 +70,8 @@
|
|
|
70
70
|
],
|
|
71
71
|
"scripts": {
|
|
72
72
|
"test": "node test/smoke.js",
|
|
73
|
-
"prepack": "node scripts/check-pack-against-gitignore.js"
|
|
73
|
+
"prepack": "node scripts/check-pack-against-gitignore.js",
|
|
74
|
+
"check:vendor-currency": "node scripts/check-vendor-currency.js"
|
|
74
75
|
},
|
|
75
76
|
"dependencies": {},
|
|
76
77
|
"devDependencies": {}
|
package/sbom.cyclonedx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:88336eab-78a7-45f2-bc73-faf15cf6bbfb",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-06T22:
|
|
8
|
+
"timestamp": "2026-05-06T22:51:45.376Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.8.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.8.6",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.8.
|
|
25
|
+
"version": "0.8.6",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.8.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.8.6",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.8.
|
|
57
|
+
"ref": "@blamejs/core@0.8.6",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|