@blamejs/core 0.6.27 → 0.6.28
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 +1 -0
- package/README.md +2 -2
- package/lib/log-stream-webhook.js +6 -1
- package/lib/log-stream.js +17 -2
- package/lib/mtls-engine-default.js +158 -11
- package/lib/network-dns.js +46 -7
- package/lib/ntp-check.js +5 -1
- package/lib/redis-client.js +15 -1
- package/lib/ssrf-guard.js +19 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,7 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.6.x
|
|
10
10
|
|
|
11
|
+
- **0.6.28** (2026-05-02) — bug-finding pass: live integration test suite + every framework bug it caught. **`b.ssrfGuard.checkUrl` cloud-metadata hard-deny** — `allowInternal: true` no longer permits 169.254.169.254 (AWS / GCP / Azure metadata). Loopback / private / link-local / reserved stay allowInternal-overridable per existing semantics; cloud-metadata is unconditional because a blanket override would let any compromised request exfiltrate instance credentials. **`b.mtlsCa` issues server certs and dual-EKU certs** — `generateClientCert({ usage: "client" | "server" | "both", sans: [...] })`. `usage` controls EKU (`client` = clientAuth, `server` = serverAuth, `both` = both); `sans` accepts `DNS:` / `IP:` / bare-DNS entries; serverAuth without an explicit SAN auto-adds the CN. Operators wiring inbound mTLS reverse-proxy fronts no longer hit "unsuitable certificate purpose" on the handshake. **`b.mtlsCa` algorithm auto-detect** — the engine probes webcrypto + the vendored x509 library at first cert issuance and picks the highest-PQC option that round-trips: SLH-DSA-SHAKE-256f → SLH-DSA-SHAKE-128f → ML-DSA-87 → ML-DSA-65 → ECDSA-P384-SHA384 (current bridge — what the X.509 ecosystem accepts today). When the bridge condition lifts and the vendor refresh ships, the same `b.mtlsCa.create(...)` call self-upgrades; `b.mtlsCa.status().cert.label` and `.posture` surface the chosen algorithm. **`b.redisClient.create({ ca, servername })`** — managed-Redis / on-prem-cluster operators connecting over `rediss://` against a private CA can pin trust roots; `servername` auto-suppresses for IP literals so `rediss://127.0.0.1:6380` no longer trips node:tls's IP-as-SNI rule. **`b.network.dns.useDnsOverTls` / `useDnsOverHttps` accept `ca`** — same trust-pinning surface for self-signed / private-PKI DoT and DoH endpoints. **DoT TLS handshake errors** now route as DnsError on the lookup promise rather than leaking as `unhandledRejection` — the secureConnect / error event-listener race had let cert-verification failures escape the per-query Promise wrapping. **DoT socket lifecycle** — was unconditionally `sock.unref()`'d after construct, causing node to exit during in-flight lookups when no other I/O kept the loop alive; now ref'd while a query is in flight and unref'd when idle. **`b.ntpCheck.querySingle` honours IPv6 servers** — was hardcoded to `dgram.createSocket("udp4")`, so `::1` / `fd00::…` queries failed with EINVAL; now auto-detects family from the server string. **`b.logStream.shutdown` drains in-flight emit microtasks** before closing sink fds — fire-and-forget emits queued just before shutdown were silently dropped because `close()` ran ahead of the microtask. **`b.logStream` webhook-sink `close()` flushes BEFORE setting `closed = true`** — `_flush`'s while loop bailed on `!closed`, leaving any records buffered just before shutdown stranded. **Integration test suite** (`test/integration/`, runner at `scripts/test-integration.js`) — 13 live test files across `b.redisClient` plain + TLS, `b.queue` Redis backend lifecycle, `b.mail` SMTP + STARTTLS + multi-rcpt + dot-stuffing transparency, `b.mail.dkim` rsa-sha256 + ed25519-sha256 + bad-algorithm reject, `b.ntpCheck` v4 + v6 + bootCheck + bad-host + bad-port, `b.network.dns` plain + DoT + DoH with strict CA pinning + bad-servername + cache, `b.network.heartbeat` http + tcp + state-change callbacks, `b.objectStore` sigv4 PUT / GET / list / delete on plain HTTP + TLS variants, `b.cache` memory + Redis-backed cluster, `b.httpClient` direct + Squid forward proxy + TLS pin, `b.ssrfGuard` classify + checkUrl + cloud-metadata block, `b.logStream` local + webhook + deferred-syslog error path, `b.mtlsCa` CA bootstrap + clientAuth + serverAuth + dual-EKU live mTLS handshakes. Companion `docker-compose.test.yml` stands up redis (plain + TLS) / postgres / mysql / mongo / minio (HTTP + HTTPS variants) / rabbitmq (plain + TLS) / nats / syslog / ntp / mailpit / coredns (plain + DoT + DoH) / haproxy / caddy / mitmproxy / squid / pki-init (auto-generates Ed25519 CA + per-service leaf certs into a docker volume); host port bindings dual-stack on `127.0.0.1` AND `[::1]`. `scripts/check-services.js` does host-side TCP / TLS handshake / SNTPv4 / DNS-A / Redis-PING / SMTP-banner / HTTP probes across every endpoint; `scripts/test-integration.js` exports the test CA via `docker cp` and sets `NODE_EXTRA_CA_CERTS` per-test child process so the framework's TLS verification stays strict (no `rejectUnauthorized: false` bypass anywhere in the test surface). **Test architecture rule** — live tests go in `test/integration/`, NEVER in `test/layer-N-*/`: smoke must remain pure (no docker dependency, runs in CI / on a developer laptop / inside prepack-guard) and a "skip silently when service is down" branch in a layer-N test makes the gate's pass count misleading and masks bugs that only surface against a live backend. The `0.6.27` `queue-redis.test.js` was moved to `test/integration/queue-redis.test.js` to enforce this. Smoke and wiki e2e green; integration suite ships green against the docker-compose stack.
|
|
11
12
|
- **0.6.27** (2026-05-02) — Redis backend for `b.queue` so multi-replica apps can share a single queue without each needing to be cluster leader. **Bespoke RESP2 client** (`lib/redis-client.js`) — zero npm runtime deps. TCP via `node:net` + TLS via `node:tls` (`rediss://` auto-detected), legacy single-arg AUTH + ACL `AUTH user pass`, `SELECT db`, pipelining, exponential-backoff reconnect, EVAL helper. **`b.queue` protocol "redis"** (`lib/queue-redis.js`) — full enqueue/lease/extendLease/complete/fail/sweepExpired/size/purge/dlqList/dlqRetry/dlqSize parity with the local backend. Atomicity comes from server-side Lua scripts so concurrent consumers can't double-lease and a sweep can't race a complete. Storage layout: per-job HASH (sealed payload + lastError via `cryptoField.sealRow("_blamejs_jobs", row)` — same crypto config as the local backend), per-queue ready ZSET scored by availableAt, per-queue inflight ZSET scored by leaseExpiresAt, per-queue dlq ZSET scored by finishedAt, plus a queues SET so sweepExpired walks every known queue without a global secondary index. Cron-repeat handled in `complete()` JS — re-enqueues the next firing as a fresh jobId with availableAt=next-cron-fire. **`b.queue.bootFromEnv({ env })`** — env-driven init mirroring `b.network.bootFromEnv` and `b.logStream.bootFromEnv`. Reads `BLAMEJS_QUEUE_PROTOCOL` (`local`|`redis`), `BLAMEJS_QUEUE_REDIS_URL`, `BLAMEJS_QUEUE_REDIS_PASSWORD`, `BLAMEJS_QUEUE_REDIS_USERNAME`, `BLAMEJS_QUEUE_REDIS_TLS`, `BLAMEJS_QUEUE_REDIS_KEY_PREFIX`. Operators flip from local to Redis without a code change; both wiki docker-compose configs declare the new env knobs. Removed `redis` from `DEFERRED_PROTOCOLS`. **Out of scope for v1** (deferred to follow-up patches with explicit re-open conditions): Redis Cluster (slot-routing), Sentinel (managed primary failover), priority ordering on the Redis backend (queue-local supports `priority` opt; Redis backend orders strictly by availableAt for v1), flow children with `dependsOn` cascade. **Wiki**: queue-cache page documents the Redis backend opts schema, bootFromEnv, and the layout. **Tests**: 24 RESP2 protocol parser unit tests (`test/layer-0-primitives/redis-client.test.js`) cover URL parsing, command encoding (binary-safe), every reply type (simple string / error / integer / bulk / nil bulk / nested array / pipelined / incomplete-mid-frame). Live Redis round-trip tests (`test/layer-0-primitives/queue-redis.test.js`) cover enqueue+lease, availableAt scheduling, visibility-timeout sweep, fail+retry path, DLQ list/retry/size, extendLease, purge, and concurrent-leaser no-double-lease — skip cleanly when `BLAMEJS_TEST_REDIS_URL` is not set so the smoke suite passes on dev boxes without a Redis container. Smoke 6780 OK.
|
|
12
13
|
- **0.6.26** (2026-05-02) — `blamejs restore` and `blamejs audit verify-chain` subcommands wrap the existing `b.restore` and `b.audit.verifyChain` primitives so operators can drive them from runbooks without writing app code. **`blamejs restore`**: `list` (enumerate bundles in storage), `inspect` (manifest summary without touching live data), `apply` (live in-place restore with rollback preserved), `rollback` (revert to most-recent OR named restore point), `list-rollbacks` (enumerate preserved rollback points). Two ways to identify a bundle — `--bundle <dir>` matches the shape `blamejs backup extract` produces (parent dir is treated as storage root, basename as bundle id), or `--storage-root <root> --bundle-id <id>` for multi-bundle stores. `apply` honors `--max-pulled-bytes` / `--max-pulled-files` (defaults 4 GiB / 100K), `--rollback-root` (default `<data-dir>.rollbacks`), `--no-audit`, and `BLAMEJS_BACKUP_PASSPHRASE` env. **`blamejs audit verify-chain`**: walks the live audit chain end-to-end, reports tampering with `breakAt` / `breakRowId` / expected-vs-actual prevHash; honours `--max-rows` to bound long walks; default table is `audit_log`. **Wiki CLI snapshot test now validates subcommand pairs**, not just top commands. Walks every wiki + README invocation of the form `blamejs <cmd> <sub>` and verifies that <sub> exists in the perCommand[<cmd>].subcommands list parsed from `lib/cli.js`. Surfaced one real drift on first run: `examples/wiki/seeders/prod/pages/backup-restore.js` documented `blamejs audit verify-signing` after vault rotation, but no such subcommand existed (only `verify-bundle`); fixed by shipping the new `verify-chain` subcommand and updating the wiki to reference it. Two top-level command gaps surfaced and fixed: `blamejs restore` (now real) and `blamejs network status` (was prose-promised in `network-config.js`; reworded to point at `b.network.snapshot()` for /healthz / custom diagnostics routes since wiring a CLI command for it is operator-side work). README CLI table updated with `restore` row and the two new audit subcommands.
|
|
13
14
|
- **0.6.25** (2026-05-02) — `b.logStream` gains an AWS CloudWatch Logs sink AND framework-level env wiring. **CloudWatch sink**: `protocol: "cloudwatch"` POSTs `PutLogEvents` over HTTPS with SigV4 signing (service `logs`); operator pre-creates the log group + log stream (the framework does NOT auto-create). Honors IAM role + STS session-token credentials. Respects all three CloudWatch caps automatically: 10,000 events / 1 MiB total payload / 256 KiB-per-event. Per-event oversize dropped at `emit()`-time with `onDrop` fired (truncated message in the drop notification). Per-batch oversize split mid-flush. Permanent AWS errors (`ResourceNotFoundException` / `AccessDeniedException` / `InvalidParameterException` / `UnrecognizedClientException` / `SerializationException`) skip the retry budget. `InvalidSequenceTokenException` (legacy CW accounts) extracts the expected token from the error message and retries with it once. `lib/object-store/sigv4.js` `signRequest()` is now service-agnostic — accepts `opts.service` (default still `"s3"` for back-compat). Removed `cloudwatch` from `DEFERRED_PROTOCOLS`. **`b.logStream.bootFromEnv({ env })`**: framework-level env-driven init mirroring `b.network.bootFromEnv`. Reads `BLAMEJS_LOG_STREAM_PROTOCOL` (`local`/`webhook`/`otlp`/`cloudwatch`), `BLAMEJS_LOG_STREAM_URL`, `BLAMEJS_LOG_STREAM_TOKEN`, `BLAMEJS_LOG_STREAM_SERVICE_NAME`, `BLAMEJS_LOG_STREAM_SERVICE_VERSION`, `BLAMEJS_LOG_STREAM_CLOUDWATCH_LOG_GROUP`, `BLAMEJS_LOG_STREAM_CLOUDWATCH_LOG_STREAM`, `BLAMEJS_LOG_STREAM_PATH`, plus standard AWS_*. Operators get a working log-stream sink without writing build-app code. Wiki app's `build-app.js` replaced its inline env-reading with one `b.logStream.bootFromEnv()` call; both docker-compose configs declare the new env knobs. **Wiki env-snapshot test**: parallel to api-snapshot.json — walks `process.env.X` / `env.X` / `safeEnv.readVar("X")` reads in the wiki app + framework `lib/`, walks docker-compose env declarations, captures the union as `examples/wiki/env-snapshot.json`, fails the e2e gate when env vars are added/removed without updating the snapshot OR when source-only / compose-only gaps appear (env knob declared but unread, env read but undocumented). The validator immediately surfaced 13 real gaps in the wiki app: 5 `WIKI_*` env vars read by source but missing from docker-compose (`WIKI_VAULT_MODE`, `WIKI_DB_AT_REST`, `WIKI_AUDIT_SIGNING_MODE`, `WIKI_BIND`, `WIKI_SITE_URL`), 2 framework env vars (`BLAMEJS_AUDIT_SIGNING_MODE`, `BLAMEJS_TMPDIR`) read by `lib/db.js` via `safeEnv.readVar` but never declared in the wiki's compose configs, and 6 dead env knobs in compose that nothing read. All 13 fixed. Update workflow: `BLAMEJS_UPDATE_ENV_SNAPSHOT=1 node examples/wiki/test/validate-env-snapshot.js` (mirrors the api-snapshot UX). Wiki observability page documents the new sink alongside webhook + otlp; README "What ships in the box" calls out all four log-stream sinks. Tests cover endpoint resolution, event-byte accounting, batch sorting + sequence-token round-trip, permanent-error classifier, validation, round-trip via mock CloudWatch, STS session-token propagation, ResourceNotFoundException no-retry path, 256 KiB per-event hard cap, dispatcher integration, AND batch splitting on the 1-MiB cap (5 quarter-MB events POST as 4 + 1 batches).
|
package/README.md
CHANGED
|
@@ -43,8 +43,8 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
43
43
|
|
|
44
44
|
- **Data layer** — SQLite with sealed-by-default columns (`b.db`), migrations, seeders, atomic-file writes; bring-your-own external Postgres / MySQL / etc. with pool tuning + role-aware connect + read-replica routing (`b.externalDb`); declarative role-narrowed views and Postgres row-level-security migrations (`b.db.declareView`, `b.db.declareRowPolicy`); S3 / R2 / B2 / GCS / Azure object store with multipart upload + SSE + bucket ops (`b.storage`, `b.objectStore`); durable queue with priority + cron + flows on the local SQLite backend OR a shared Redis backend for multi-replica deploys (`b.queue`, `b.jobs`); cluster-shared cache (`b.cache`).
|
|
45
45
|
- **Identity & access** — passwords (Argon2id) + policy primitive (NIST 800-63B / PCI-DSS 4.0 / HIPAA-AAL2 profiles, HaveIBeenPwned k-anonymity breach check, length / context / dictionary / complexity rules, rotation + history) (`b.auth.password`); passkeys (WebAuthn), TOTP, JWT (PQ-default), OAuth, sessions with optional IP / UA fingerprint drift detection + anomaly scoring, brute-force lockout (`b.auth.*`, `b.session`); RBAC + optional per-role DB binding + role-spec `requireMfa` + per-route MFA freshness window + ABAC predicate registry (`b.permissions`); API keys with rotation (`b.apiKey`); break-glass column gates with second-factor + audit (`b.breakGlass`); two-person-rule approval workflow with m-of-n quorum + cooling-off lock + approver-role gate + cancellation (`b.dualControl`).
|
|
46
|
-
- **Crypto** — envelope-versioned PQC at rest (ML-KEM-1024 + P-384 hybrid, XChaCha20-Poly1305, SHAKE256), vault sealing, field-level crypto + cryptographic erasure (`b.cryptoField.eraseRow`), signed webhooks (SLH-DSA-SHAKE-256f), ECIES API encryption (`b.crypto`, `b.vault`, `b.webhook`); pure-JS mTLS CA, PQC TLS gates inbound + outbound (`b.mtlsCa`, `b.pqcGate`, `b.pqcAgent`).
|
|
47
|
-
- **HTTP** — router with schema-validated routes + OpenAPI publication; full middleware stack (CSRF, CORS, rate-limit, security headers, CSP nonce, body parser, compression, SSE, request log, request-time DB role binding via `b.middleware.dbRoleFor`, in-process CIDR fence via `b.middleware.networkAllowlist`) wired by `createApp`; HTTP/1.1 + HTTP/2 outbound client with SSRF gate, scheme + userinfo + per-host (wildcard / per-method) destination allowlist, redirects, multipart, interceptors, progress, encrypted cookie jar (`b.httpClient`, `b.ssrfGuard`, `b.safeUrl`); operator-tunable network configurability — env-driven NTP / NTS (RFC 8915 authenticated time), DNS with IPv6 / DoH / DoT / cache / lookup timeout, outbound HTTP proxy (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`), runtime DPI trust-store CA additions, application-level heartbeats, TCP socket defaults (`b.network`).
|
|
46
|
+
- **Crypto** — envelope-versioned PQC at rest (ML-KEM-1024 + P-384 hybrid, XChaCha20-Poly1305, SHAKE256), vault sealing, field-level crypto + cryptographic erasure (`b.cryptoField.eraseRow`), signed webhooks (SLH-DSA-SHAKE-256f), ECIES API encryption (`b.crypto`, `b.vault`, `b.webhook`); pure-JS mTLS CA that issues clientAuth / serverAuth / dual-EKU certs with SAN entries and auto-detects the highest-PQC signature algorithm the vendored x509 library accepts (today: ECDSA-P384-SHA384 bridge; self-upgrades to SLH-DSA / ML-DSA when the X.509 ecosystem catches up), PQC TLS gates inbound + outbound (`b.mtlsCa`, `b.pqcGate`, `b.pqcAgent`).
|
|
47
|
+
- **HTTP** — router with schema-validated routes + OpenAPI publication; full middleware stack (CSRF, CORS, rate-limit, security headers, CSP nonce, body parser, compression, SSE, request log, request-time DB role binding via `b.middleware.dbRoleFor`, in-process CIDR fence via `b.middleware.networkAllowlist`) wired by `createApp`; HTTP/1.1 + HTTP/2 outbound client with SSRF gate (cloud-metadata IPs hard-denied unconditionally; private / loopback / link-local overridable per call), scheme + userinfo + per-host (wildcard / per-method) destination allowlist, redirects, multipart, interceptors, progress, encrypted cookie jar (`b.httpClient`, `b.ssrfGuard`, `b.safeUrl`); operator-tunable network configurability — env-driven NTP / NTS (RFC 8915 authenticated time), IPv4-or-IPv6 NTP servers, DNS with IPv6 / DoH / DoT (private-CA trust pinning via `opts.ca`) / cache / lookup timeout, outbound HTTP proxy (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`), runtime DPI trust-store CA additions, application-level heartbeats, TCP socket defaults (`b.network`).
|
|
48
48
|
- **Defensive parsers** — `b.safeJson`, `b.safeBuffer`, `b.safeSql`, `b.safeSchema`, `b.parsers` (XML / TOML / YAML / .env), `b.config` (schema-validated env), `b.fileType` magic-byte content classification with deny-on-upload categories (image / document / archive / executable / etc.).
|
|
49
49
|
- **Communication** — WebSockets with channel/room fan-out across cluster replicas (`b.websocket`, `b.websocketChannels`); mail with multipart + attachments + DKIM + calendar invites + bounce intake (`b.mail`, `b.mailBounce`); generic notification dispatcher with operator-supplied transports (`b.notify`).
|
|
50
50
|
- **Observability** — tamper-evident audit chain with SLH-DSA-signed checkpoints, metrics, tracing (OTel pass-through when wired), PII redaction, log-stream sinks (local file rotation, generic webhook, OTLP/HTTP-JSON to an OTel collector, AWS CloudWatch Logs via SigV4), OTLP/HTTP-JSON exporter for traces + metrics (`b.audit`, `b.metrics`, `b.tracing`, `b.redact`, `b.logStream`, `b.otelExport`); operator-callable boot-time security policy assertions (`b.security.assertProduction`) and tamper-evident config-baseline drift detection signed with the audit-signing key (`b.configDrift`).
|
|
@@ -168,9 +168,14 @@ function create(config) {
|
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
async function close() {
|
|
171
|
-
closed
|
|
171
|
+
// Drain BEFORE flipping closed=true. _flush()'s while loop bails on
|
|
172
|
+
// !closed, so flipping the flag first leaves any buffered records
|
|
173
|
+
// stranded — the very records the operator queued just before
|
|
174
|
+
// calling shutdown(). Order: stop the timer, drain, then refuse
|
|
175
|
+
// any new enqueues by setting closed.
|
|
172
176
|
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
|
173
177
|
await _flush();
|
|
178
|
+
closed = true;
|
|
174
179
|
}
|
|
175
180
|
|
|
176
181
|
function stats() {
|
package/lib/log-stream.js
CHANGED
|
@@ -79,6 +79,10 @@ var audit = lazyRequire(function () { return require("./audit"); });
|
|
|
79
79
|
|
|
80
80
|
var initialized = false;
|
|
81
81
|
var sinks = {};
|
|
82
|
+
// Pending emit promises, tracked so shutdown can drain them before
|
|
83
|
+
// closing sink fds. Without this, fire-and-forget emits queued just
|
|
84
|
+
// before shutdown raced with close() and silently dropped records.
|
|
85
|
+
var _inflight = new Set();
|
|
82
86
|
var minLevel = "info";
|
|
83
87
|
var incomingHandlers = [];
|
|
84
88
|
|
|
@@ -124,11 +128,14 @@ function emit(level, message, meta) {
|
|
|
124
128
|
|
|
125
129
|
// Fire-and-forget to all sinks. Sink errors don't bubble — they're
|
|
126
130
|
// captured by audit (system.log.sink-failure) so an external sink
|
|
127
|
-
// outage doesn't take down the app's request handlers.
|
|
131
|
+
// outage doesn't take down the app's request handlers. Pending
|
|
132
|
+
// emits are tracked in _inflight so shutdown() can drain them
|
|
133
|
+
// before closing fds (otherwise records queued just before
|
|
134
|
+
// shutdown would be lost when close() ran ahead of the microtask).
|
|
128
135
|
Object.keys(sinks).forEach(function (name) {
|
|
129
136
|
var sink = sinks[name];
|
|
130
137
|
if (!_shouldEmit(level, sink.levelFilter)) return;
|
|
131
|
-
Promise.resolve()
|
|
138
|
+
var p = Promise.resolve()
|
|
132
139
|
.then(function () { return sink.raw.emit(record); })
|
|
133
140
|
.catch(function (e) {
|
|
134
141
|
audit().safeEmit({
|
|
@@ -138,6 +145,8 @@ function emit(level, message, meta) {
|
|
|
138
145
|
metadata: { sink: name, level: level },
|
|
139
146
|
});
|
|
140
147
|
});
|
|
148
|
+
_inflight.add(p);
|
|
149
|
+
p.then(function () { _inflight.delete(p); }, function () { _inflight.delete(p); });
|
|
141
150
|
});
|
|
142
151
|
}
|
|
143
152
|
|
|
@@ -183,6 +192,12 @@ async function deliverIncoming(payload, opts) {
|
|
|
183
192
|
|
|
184
193
|
async function shutdown() {
|
|
185
194
|
if (!initialized) return;
|
|
195
|
+
// Drain any in-flight emits before closing sink fds so records
|
|
196
|
+
// queued just before shutdown actually reach disk / the wire.
|
|
197
|
+
if (_inflight.size > 0) {
|
|
198
|
+
try { await Promise.all(Array.from(_inflight)); }
|
|
199
|
+
catch (_e) { /* sink errors already audited via the catch above */ }
|
|
200
|
+
}
|
|
186
201
|
for (var name in sinks) {
|
|
187
202
|
try {
|
|
188
203
|
if (typeof sinks[name].raw.close === "function") await sinks[name].raw.close();
|
|
@@ -48,10 +48,103 @@ class MtlsEngineError extends FrameworkError {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
var CA_KEY_ALG = { name: "ECDSA", namedCurve: "P-384" };
|
|
52
|
-
var CA_SIG_ALG = { name: "ECDSA", hash: "SHA-384" };
|
|
53
51
|
var CA_KEY_USAGES = ["sign", "verify"];
|
|
54
52
|
|
|
53
|
+
// Algorithm priority — each entry probed at first use; the first one
|
|
54
|
+
// the vendored x509 library AND webcrypto can both honour wins.
|
|
55
|
+
// Ordered highest-PQC-posture first so the engine self-upgrades the
|
|
56
|
+
// moment the vendor bundle gains PQ-sig X.509 support.
|
|
57
|
+
//
|
|
58
|
+
// keyAlg: passed to webcrypto.subtle.generateKey + import
|
|
59
|
+
// sigAlg: passed to x509.X509CertificateGenerator.create
|
|
60
|
+
// label : surfaced via b.mtlsCa.status() so operators can audit
|
|
61
|
+
// which algorithm the in-flight CA generation is using
|
|
62
|
+
var ALG_CANDIDATES = [
|
|
63
|
+
// Pure-PQC stateless hash-based — matches lib/audit-sign's posture.
|
|
64
|
+
// FIPS 205 (SPHINCS+ family). Awaiting node:tls + browser cert-store
|
|
65
|
+
// verification support; currently issuance-only on most stacks.
|
|
66
|
+
{
|
|
67
|
+
label: "SLH-DSA-SHAKE-256f",
|
|
68
|
+
keyAlg: { name: "SLH-DSA-SHAKE-256f" },
|
|
69
|
+
sigAlg: { name: "SLH-DSA-SHAKE-256f" },
|
|
70
|
+
posture: "pqc-pure",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
label: "SLH-DSA-SHAKE-128f",
|
|
74
|
+
keyAlg: { name: "SLH-DSA-SHAKE-128f" },
|
|
75
|
+
sigAlg: { name: "SLH-DSA-SHAKE-128f" },
|
|
76
|
+
posture: "pqc-pure",
|
|
77
|
+
},
|
|
78
|
+
// Pure-PQC lattice — FIPS 204 (Dilithium family). Smaller than SLH-DSA,
|
|
79
|
+
// accepted by the same emerging cert-store deployments.
|
|
80
|
+
{
|
|
81
|
+
label: "ML-DSA-87",
|
|
82
|
+
keyAlg: { name: "ML-DSA-87" },
|
|
83
|
+
sigAlg: { name: "ML-DSA-87" },
|
|
84
|
+
posture: "pqc-pure",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
label: "ML-DSA-65",
|
|
88
|
+
keyAlg: { name: "ML-DSA-65" },
|
|
89
|
+
sigAlg: { name: "ML-DSA-65" },
|
|
90
|
+
posture: "pqc-pure",
|
|
91
|
+
},
|
|
92
|
+
// Documented bridge — used until cert ecosystems verify the above.
|
|
93
|
+
// The framework's hybrid KEM posture (X25519MLKEM768) covers handshake
|
|
94
|
+
// KEX; these certs sign with ECDSA P-384 + SHA-384.
|
|
95
|
+
{
|
|
96
|
+
label: "ECDSA-P384-SHA384",
|
|
97
|
+
keyAlg: { name: "ECDSA", namedCurve: "P-384" },
|
|
98
|
+
sigAlg: { name: "ECDSA", hash: "SHA-384" },
|
|
99
|
+
posture: "classical",
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
// First-call probe cache. Re-runs after engine reload (test reset path).
|
|
104
|
+
var _selectedAlg = null;
|
|
105
|
+
|
|
106
|
+
async function _probeCandidate(c) {
|
|
107
|
+
try {
|
|
108
|
+
var pair = await webcrypto.subtle.generateKey(c.keyAlg, true, CA_KEY_USAGES);
|
|
109
|
+
if (!pair || !pair.publicKey) return false;
|
|
110
|
+
// Also confirm the x509 generator accepts the sigAlg by issuing a
|
|
111
|
+
// throwaway self-signed cert. Some keyAlgs work in webcrypto but
|
|
112
|
+
// aren't yet wired through @peculiar/x509's encoder — without this
|
|
113
|
+
// round-trip we'd select an algorithm we can't actually mint certs
|
|
114
|
+
// with and hit a confusing failure on first issuance.
|
|
115
|
+
await x509.X509CertificateGenerator.create({
|
|
116
|
+
serialNumber: "01",
|
|
117
|
+
subject: "CN=probe",
|
|
118
|
+
issuer: "CN=probe",
|
|
119
|
+
notBefore: new Date(),
|
|
120
|
+
notAfter: new Date(Date.now() + 1000),
|
|
121
|
+
signingAlgorithm: c.sigAlg,
|
|
122
|
+
publicKey: pair.publicKey,
|
|
123
|
+
signingKey: pair.privateKey,
|
|
124
|
+
});
|
|
125
|
+
return true;
|
|
126
|
+
} catch (_e) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function _selectAlgorithm() {
|
|
132
|
+
if (_selectedAlg) return _selectedAlg;
|
|
133
|
+
for (var i = 0; i < ALG_CANDIDATES.length; i++) {
|
|
134
|
+
var c = ALG_CANDIDATES[i];
|
|
135
|
+
var ok = await _probeCandidate(c);
|
|
136
|
+
if (ok) { _selectedAlg = c; return c; }
|
|
137
|
+
}
|
|
138
|
+
// Should never happen — ECDSA-P384-SHA384 is universal.
|
|
139
|
+
throw new MtlsEngineError("mtls-engine/no-algorithm",
|
|
140
|
+
"no candidate algorithm passed the webcrypto + x509 probe");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Backwards-compat shape for callers that read these directly. Resolved
|
|
144
|
+
// lazily so the algorithm choice is the first-probe result.
|
|
145
|
+
var CA_KEY_ALG = null;
|
|
146
|
+
var CA_SIG_ALG = null;
|
|
147
|
+
|
|
55
148
|
var P12_CONTENT_ENC = { name: "AES-CBC", length: 256 };
|
|
56
149
|
var P12_KDF_HASH = "SHA-512";
|
|
57
150
|
var P12_MAC_HASH = "SHA-512";
|
|
@@ -63,6 +156,7 @@ var DEFAULT_CA_NAME = "blamejs CA";
|
|
|
63
156
|
var BAG_ID_KEY = "1.2.840.113549.1.12.10.1.2"; // pkcs-12-pkcs-8ShroudedKeyBag
|
|
64
157
|
var BAG_ID_CERT = "1.2.840.113549.1.12.10.1.3"; // pkcs-12-certBag
|
|
65
158
|
var EKU_CLIENT_AUTH_OID = "1.3.6.1.5.5.7.3.2";
|
|
159
|
+
var EKU_SERVER_AUTH_OID = "1.3.6.1.5.5.7.3.1";
|
|
66
160
|
|
|
67
161
|
function _pemBlock(label, der) {
|
|
68
162
|
var b64 = Buffer.from(der).toString("base64");
|
|
@@ -106,6 +200,8 @@ async function generateCa(opts) {
|
|
|
106
200
|
? Math.floor(opts.generation) : 1;
|
|
107
201
|
var caName = opts.name || DEFAULT_CA_NAME;
|
|
108
202
|
|
|
203
|
+
var alg = await _selectAlgorithm();
|
|
204
|
+
CA_KEY_ALG = alg.keyAlg; CA_SIG_ALG = alg.sigAlg;
|
|
109
205
|
var keys = await webcrypto.subtle.generateKey(CA_KEY_ALG, true, CA_KEY_USAGES);
|
|
110
206
|
var now = new Date();
|
|
111
207
|
var ca = await x509.X509CertificateGenerator.createSelfSigned({
|
|
@@ -137,12 +233,58 @@ async function signClientCert(opts) {
|
|
|
137
233
|
? Math.floor(opts.validityDays) : LEAF_DEFAULT_DAYS;
|
|
138
234
|
var cn = _normaliseCn(opts.cn);
|
|
139
235
|
|
|
236
|
+
// Extended Key Usage: defaults to clientAuth (the historical behaviour).
|
|
237
|
+
// Operators issuing server certs for inbound mTLS reverse-proxy fronts
|
|
238
|
+
// pass `usage: "server"` (sets serverAuth EKU), or `usage: "both"`
|
|
239
|
+
// (clientAuth + serverAuth — dual-purpose certs for service-to-service
|
|
240
|
+
// mTLS where the same workload is both initiator and acceptor).
|
|
241
|
+
var usage = opts.usage || "client";
|
|
242
|
+
var ekuOids = [];
|
|
243
|
+
if (usage === "client" || usage === "both") ekuOids.push(EKU_CLIENT_AUTH_OID);
|
|
244
|
+
if (usage === "server" || usage === "both") ekuOids.push(EKU_SERVER_AUTH_OID);
|
|
245
|
+
if (ekuOids.length === 0) {
|
|
246
|
+
throw new MtlsEngineError("mtls-engine/bad-usage",
|
|
247
|
+
"signClientCert: opts.usage must be 'client' | 'server' | 'both', got " +
|
|
248
|
+
JSON.stringify(opts.usage));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Subject Alternative Names — required for serverAuth (modern TLS
|
|
252
|
+
// clients only honor SANs, not CN). Accept opts.sans as an array of
|
|
253
|
+
// strings (DNS names by default; "DNS:foo" / "IP:1.2.3.4" forms also).
|
|
254
|
+
var sanExt = null;
|
|
255
|
+
if (Array.isArray(opts.sans) && opts.sans.length > 0) {
|
|
256
|
+
var sanEntries = opts.sans.map(function (s) {
|
|
257
|
+
var str = String(s);
|
|
258
|
+
if (/^DNS:/i.test(str)) return { type: "dns", value: str.slice(4) };
|
|
259
|
+
if (/^IP:/i.test(str)) return { type: "ip", value: str.slice(3) };
|
|
260
|
+
// Bare entries default to DNS — matches operator expectation
|
|
261
|
+
return { type: "dns", value: str };
|
|
262
|
+
});
|
|
263
|
+
sanExt = new x509.SubjectAlternativeNameExtension(sanEntries);
|
|
264
|
+
} else if (usage === "server" || usage === "both") {
|
|
265
|
+
// serverAuth without a SAN is unverifiable by modern TLS clients.
|
|
266
|
+
// Auto-add the CN as a DNS SAN so the most common case "just works".
|
|
267
|
+
sanExt = new x509.SubjectAlternativeNameExtension([{ type: "dns", value: cn }]);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
var alg = await _selectAlgorithm();
|
|
271
|
+
CA_KEY_ALG = alg.keyAlg; CA_SIG_ALG = alg.sigAlg;
|
|
140
272
|
var caKey = await _importPemPrivateKey(opts.caKeyPem, CA_KEY_ALG, ["sign"]);
|
|
141
273
|
var caCert = _parseCertPem(opts.caCertPem);
|
|
142
274
|
var clientKeys = await webcrypto.subtle.generateKey(CA_KEY_ALG, true, CA_KEY_USAGES);
|
|
143
275
|
|
|
144
276
|
var now = new Date();
|
|
145
277
|
var notAfter = new Date(now.getTime() + C.TIME.days(validityDays));
|
|
278
|
+
var extensions = [
|
|
279
|
+
new x509.BasicConstraintsExtension(false, undefined, true),
|
|
280
|
+
new x509.KeyUsagesExtension(
|
|
281
|
+
x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment,
|
|
282
|
+
true
|
|
283
|
+
),
|
|
284
|
+
new x509.ExtendedKeyUsageExtension(ekuOids, true),
|
|
285
|
+
];
|
|
286
|
+
if (sanExt) extensions.push(sanExt);
|
|
287
|
+
|
|
146
288
|
var clientCert = await x509.X509CertificateGenerator.create({
|
|
147
289
|
serialNumber: crypto.generateToken(16),
|
|
148
290
|
subject: "CN=" + cn,
|
|
@@ -152,14 +294,7 @@ async function signClientCert(opts) {
|
|
|
152
294
|
signingAlgorithm: CA_SIG_ALG,
|
|
153
295
|
publicKey: clientKeys.publicKey,
|
|
154
296
|
signingKey: caKey,
|
|
155
|
-
extensions:
|
|
156
|
-
new x509.BasicConstraintsExtension(false, undefined, true),
|
|
157
|
-
new x509.KeyUsagesExtension(
|
|
158
|
-
x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment,
|
|
159
|
-
true
|
|
160
|
-
),
|
|
161
|
-
new x509.ExtendedKeyUsageExtension([EKU_CLIENT_AUTH_OID], true),
|
|
162
|
-
],
|
|
297
|
+
extensions: extensions,
|
|
163
298
|
});
|
|
164
299
|
var pem = await _exportKeyPairToPem(clientKeys);
|
|
165
300
|
return {
|
|
@@ -168,6 +303,7 @@ async function signClientCert(opts) {
|
|
|
168
303
|
ca: opts.caCertPem,
|
|
169
304
|
issuedAt: now.toISOString(),
|
|
170
305
|
expiresAt: notAfter.toISOString(),
|
|
306
|
+
usage: usage,
|
|
171
307
|
};
|
|
172
308
|
}
|
|
173
309
|
|
|
@@ -265,7 +401,18 @@ async function packageP12(opts) {
|
|
|
265
401
|
|
|
266
402
|
function algorithmEnvelope() {
|
|
267
403
|
return {
|
|
268
|
-
cert: {
|
|
404
|
+
cert: {
|
|
405
|
+
keyAlg: CA_KEY_ALG,
|
|
406
|
+
sigAlg: CA_SIG_ALG,
|
|
407
|
+
label: _selectedAlg && _selectedAlg.label,
|
|
408
|
+
posture: _selectedAlg && _selectedAlg.posture,
|
|
409
|
+
// Operators querying status() before any cert has been issued
|
|
410
|
+
// get the candidate priority list — the engine probes lazily so
|
|
411
|
+
// the chosen algorithm isn't known until first use.
|
|
412
|
+
priority: ALG_CANDIDATES.map(function (c) {
|
|
413
|
+
return { label: c.label, posture: c.posture };
|
|
414
|
+
}),
|
|
415
|
+
},
|
|
269
416
|
p12: {
|
|
270
417
|
contentEncryption: P12_CONTENT_ENC,
|
|
271
418
|
kdfHash: P12_KDF_HASH,
|
package/lib/network-dns.js
CHANGED
|
@@ -136,7 +136,7 @@ function setCacheTtlMs(ms, negativeMs) {
|
|
|
136
136
|
|
|
137
137
|
function useDnsOverHttps(opts) {
|
|
138
138
|
opts = opts || {};
|
|
139
|
-
validateOpts(opts, ["provider", "url", "method"], "dns.useDnsOverHttps");
|
|
139
|
+
validateOpts(opts, ["provider", "url", "method", "ca"], "dns.useDnsOverHttps");
|
|
140
140
|
var url = opts.url;
|
|
141
141
|
if (!url && opts.provider) {
|
|
142
142
|
var p = String(opts.provider).toLowerCase();
|
|
@@ -155,22 +155,34 @@ function useDnsOverHttps(opts) {
|
|
|
155
155
|
"dns.useDnsOverHttps: method must be 'GET' | 'POST' | undefined (auto), got " +
|
|
156
156
|
JSON.stringify(method));
|
|
157
157
|
}
|
|
158
|
-
|
|
158
|
+
if (opts.ca !== undefined && opts.ca !== null &&
|
|
159
|
+
!Buffer.isBuffer(opts.ca) && typeof opts.ca !== "string" && !Array.isArray(opts.ca)) {
|
|
160
|
+
throw new DnsError("dns/bad-doh-ca",
|
|
161
|
+
"dns.useDnsOverHttps: ca must be a PEM string, Buffer, or array of either");
|
|
162
|
+
}
|
|
163
|
+
STATE.doh = { url: url, method: method, ca: opts.ca || null };
|
|
159
164
|
_clearCache();
|
|
160
165
|
_emitObs("network.dns.doh.set", { url: url, method: method || "auto" });
|
|
161
166
|
}
|
|
162
167
|
|
|
163
168
|
function useDnsOverTls(opts) {
|
|
164
169
|
opts = opts || {};
|
|
165
|
-
validateOpts(opts, ["host", "port", "servername"], "dns.useDnsOverTls");
|
|
170
|
+
validateOpts(opts, ["host", "port", "servername", "ca"], "dns.useDnsOverTls");
|
|
166
171
|
if (typeof opts.host !== "string" || opts.host.length === 0) {
|
|
167
172
|
throw new DnsError("dns/bad-dot-host", "dns.useDnsOverTls: host required");
|
|
168
173
|
}
|
|
174
|
+
if (opts.ca !== undefined && opts.ca !== null &&
|
|
175
|
+
!Buffer.isBuffer(opts.ca) && typeof opts.ca !== "string" && !Array.isArray(opts.ca)) {
|
|
176
|
+
throw new DnsError("dns/bad-dot-ca",
|
|
177
|
+
"dns.useDnsOverTls: ca must be a PEM string, Buffer, or array of either");
|
|
178
|
+
}
|
|
169
179
|
STATE.dot = {
|
|
170
180
|
host: opts.host,
|
|
171
181
|
port: opts.port || 853,
|
|
172
182
|
servername: opts.servername || opts.host,
|
|
183
|
+
ca: opts.ca || null,
|
|
173
184
|
};
|
|
185
|
+
_resetDotPool();
|
|
174
186
|
_clearCache();
|
|
175
187
|
_emitObs("network.dns.dot.set", { host: STATE.dot.host, port: STATE.dot.port });
|
|
176
188
|
}
|
|
@@ -278,6 +290,7 @@ async function _dohLookup(host, family) {
|
|
|
278
290
|
minVersion: "TLSv1.3",
|
|
279
291
|
ecdhCurve: C.TLS_GROUP_CURVE_STR,
|
|
280
292
|
};
|
|
293
|
+
if (STATE.doh.ca) reqOpts.ca = STATE.doh.ca;
|
|
281
294
|
if (usePost) {
|
|
282
295
|
reqOpts.headers["content-type"] = "application/dns-message";
|
|
283
296
|
reqOpts.headers["content-length"] = enc.buf.length;
|
|
@@ -317,14 +330,19 @@ function _dotPoolKey() {
|
|
|
317
330
|
}
|
|
318
331
|
|
|
319
332
|
function _dotConnect() {
|
|
320
|
-
var
|
|
333
|
+
var connectOpts = {
|
|
321
334
|
host: STATE.dot.host,
|
|
322
335
|
port: STATE.dot.port,
|
|
323
336
|
servername: STATE.dot.servername,
|
|
324
337
|
minVersion: "TLSv1.3",
|
|
325
338
|
ecdhCurve: C.TLS_GROUP_CURVE_STR,
|
|
326
|
-
}
|
|
327
|
-
|
|
339
|
+
};
|
|
340
|
+
if (STATE.dot.ca) connectOpts.ca = STATE.dot.ca;
|
|
341
|
+
var sock = tls.connect(connectOpts);
|
|
342
|
+
// The pool entry is ref()'d while a query is in flight and unref()'d
|
|
343
|
+
// when idle — _dotLookup toggles this around its query. Calling
|
|
344
|
+
// unref() unconditionally here let node exit during a normal lookup
|
|
345
|
+
// when no other I/O kept the event loop alive.
|
|
328
346
|
return sock;
|
|
329
347
|
}
|
|
330
348
|
|
|
@@ -352,9 +370,18 @@ async function _dotLookup(host, family) {
|
|
|
352
370
|
idle: true,
|
|
353
371
|
ready: new Promise(function (res, rej) {
|
|
354
372
|
sock.once("secureConnect", function () { res(); });
|
|
355
|
-
sock.once("error", function (e) {
|
|
373
|
+
sock.once("error", function (e) {
|
|
374
|
+
rej(new DnsError("dns/dot-handshake",
|
|
375
|
+
"DoT TLS handshake to " + STATE.dot.host + ":" + STATE.dot.port +
|
|
376
|
+
" failed: " + ((e && e.message) || String(e))));
|
|
377
|
+
});
|
|
356
378
|
}),
|
|
357
379
|
};
|
|
380
|
+
// Pre-attach a no-op observer so a handshake failure isn't reported
|
|
381
|
+
// as an unhandledRejection in the window between this assignment
|
|
382
|
+
// and the first lookup awaiting entry.ready. The real reject path
|
|
383
|
+
// routes through _dotLookup's then(_, onErr) handler below.
|
|
384
|
+
entry.ready.catch(function () { /* observed; routed via per-lookup handler */ });
|
|
358
385
|
_dotPool.set(key, entry);
|
|
359
386
|
sock.on("error", function () { _dotEvict(key); });
|
|
360
387
|
sock.on("close", function () { if (_dotPool.get(key) === entry) _dotPool.delete(key); });
|
|
@@ -365,6 +392,8 @@ async function _dotLookup(host, family) {
|
|
|
365
392
|
entry._tail = waitTicket.then(function () {
|
|
366
393
|
return new Promise(function (resolve, reject) {
|
|
367
394
|
entry.idle = false;
|
|
395
|
+
// Hold the event loop open while a query is in flight.
|
|
396
|
+
try { entry.sock.ref(); } catch (_e) {}
|
|
368
397
|
Promise.resolve(entry.ready).then(function () {
|
|
369
398
|
var lenBuf = Buffer.alloc(2);
|
|
370
399
|
lenBuf.writeUInt16BE(enc.buf.length, 0);
|
|
@@ -378,6 +407,9 @@ async function _dotLookup(host, family) {
|
|
|
378
407
|
entry.sock.removeListener("error", onErr);
|
|
379
408
|
entry.idle = true;
|
|
380
409
|
entry.lastUsedAt = Date.now();
|
|
410
|
+
// Release the event-loop hold so an idle pool socket doesn't
|
|
411
|
+
// keep node alive between queries.
|
|
412
|
+
try { entry.sock.unref(); } catch (_e) {}
|
|
381
413
|
if (err) reject(err); else resolve(val);
|
|
382
414
|
}
|
|
383
415
|
function onData(chunk) {
|
|
@@ -398,6 +430,13 @@ async function _dotLookup(host, family) {
|
|
|
398
430
|
entry.sock.on("error", onErr);
|
|
399
431
|
entry.sock.write(lenBuf);
|
|
400
432
|
entry.sock.write(enc.buf);
|
|
433
|
+
}, function (handshakeErr) {
|
|
434
|
+
// entry.ready rejected (TLS handshake / cert verification failure).
|
|
435
|
+
// Route as the lookup's reject so callers see a real DnsError
|
|
436
|
+
// instead of an unhandledRejection from the TLSSocket event.
|
|
437
|
+
entry.idle = true;
|
|
438
|
+
try { entry.sock.unref(); } catch (_e) {}
|
|
439
|
+
reject(handshakeErr);
|
|
401
440
|
});
|
|
402
441
|
});
|
|
403
442
|
});
|
package/lib/ntp-check.js
CHANGED
|
@@ -86,7 +86,11 @@ function querySingle(server, opts) {
|
|
|
86
86
|
var timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
87
87
|
|
|
88
88
|
return new Promise(function (resolve, reject) {
|
|
89
|
-
|
|
89
|
+
// udp6 for IPv6 literals (`::1`, `fd00::…`), udp4 otherwise. Without
|
|
90
|
+
// this branch a query to an IPv6 NTP host fails with EINVAL because
|
|
91
|
+
// you can't send IPv6 packets through a udp4 socket.
|
|
92
|
+
var family = server.indexOf(":") !== -1 ? "udp6" : "udp4";
|
|
93
|
+
var socket = dgram.createSocket(family);
|
|
90
94
|
var settled = false;
|
|
91
95
|
|
|
92
96
|
function done(err, result) {
|
package/lib/redis-client.js
CHANGED
|
@@ -162,6 +162,17 @@ function create(opts) {
|
|
|
162
162
|
var commandTimeoutMs = Number(opts.commandTimeoutMs) || 10000;
|
|
163
163
|
var maxReconnectAttempts = opts.maxReconnectAttempts === undefined ? 10
|
|
164
164
|
: Number(opts.maxReconnectAttempts);
|
|
165
|
+
// TLS verification controls. Operators using rediss:// against private
|
|
166
|
+
// CAs (managed Redis services, on-prem clusters with internal PKI)
|
|
167
|
+
// pin the trust roots via opts.ca; rejectUnauthorized stays on by
|
|
168
|
+
// default — never weaken verification to make a connection succeed.
|
|
169
|
+
var caBundle = opts.ca || null;
|
|
170
|
+
// SNI is only legal for hostnames; IP literals must omit servername.
|
|
171
|
+
var servername = opts.servername;
|
|
172
|
+
if (servername === undefined) {
|
|
173
|
+
servername = (/^\d+\.\d+\.\d+\.\d+$/.test(host) || host.indexOf(":") !== -1)
|
|
174
|
+
? undefined : host;
|
|
175
|
+
}
|
|
165
176
|
|
|
166
177
|
var socket = null;
|
|
167
178
|
var connected = false;
|
|
@@ -260,7 +271,10 @@ function create(opts) {
|
|
|
260
271
|
reject(_err("CONNECT", "redis connect failed: " + ((e && e.message) || String(e))));
|
|
261
272
|
}
|
|
262
273
|
if (useTls) {
|
|
263
|
-
|
|
274
|
+
var tlsConnectOpts = { host: host, port: port };
|
|
275
|
+
if (servername) tlsConnectOpts.servername = servername;
|
|
276
|
+
if (caBundle) tlsConnectOpts.ca = caBundle;
|
|
277
|
+
sock = tls.connect(tlsConnectOpts, onOk);
|
|
264
278
|
} else {
|
|
265
279
|
sock = net.connect({ host: host, port: port }, onOk);
|
|
266
280
|
}
|
package/lib/ssrf-guard.js
CHANGED
|
@@ -339,6 +339,25 @@ async function checkUrl(url, opts) {
|
|
|
339
339
|
var category = classify(addr);
|
|
340
340
|
if (!category) continue;
|
|
341
341
|
|
|
342
|
+
// Cloud-metadata IPs are NEVER allowed — they leak instance
|
|
343
|
+
// credentials (AWS IMDS, GCP metadata, Azure IMDS) and a blanket
|
|
344
|
+
// allowInternal bypass would let any compromised request exfiltrate
|
|
345
|
+
// them. Operators with a legitimate need to talk to the metadata
|
|
346
|
+
// service do it through the cloud SDK with explicit IAM, never
|
|
347
|
+
// through the framework's outbound HTTP. Same hard-deny applies
|
|
348
|
+
// to allowInternal=[cidr-list]: the list grants exception for
|
|
349
|
+
// private ranges, not for the metadata IP that happens to fall
|
|
350
|
+
// inside link-local.
|
|
351
|
+
if (category === "cloud-metadata") {
|
|
352
|
+
throw new ErrorClass(
|
|
353
|
+
"URL '" + parsed.toString() + "' resolves to " + addr +
|
|
354
|
+
" (cloud-metadata) — blocked unconditionally; allowInternal does NOT override " +
|
|
355
|
+
"this class because metadata IPs leak instance credentials",
|
|
356
|
+
"ssrf-guard/blocked-cloud-metadata",
|
|
357
|
+
{ url: parsed.toString(), ip: addr, category: category }
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
342
361
|
if (allowInternal === true) continue;
|
|
343
362
|
if (Array.isArray(allowInternal) && allowInternal.some(function (cidr) {
|
|
344
363
|
return cidrContains(cidr, addr);
|
package/package.json
CHANGED
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:c6865ad2-35d9-413c-85ae-ddb9b7595c07",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-02T17:54:24.793Z",
|
|
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.6.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.6.28",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.6.
|
|
25
|
+
"version": "0.6.28",
|
|
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.6.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.6.28",
|
|
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.6.
|
|
57
|
+
"ref": "@blamejs/core@0.6.28",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|