@blamejs/core 0.8.16 → 0.8.18

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 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.18** (2026-05-08) — Database query-builder hardening on `b.db.from(...).where|whereRaw` and `b.safeSql`. **`Query.where(field, "LIKE", value)`** — now escapes SQL `%` and `_` wildcard metacharacters in operator-supplied values and emits `LIKE ? ESCAPE '\\'`. Closes the column-disclosure class where `q=%@%` enumerated the entire table. Operators who deliberately want LIKE wildcards bypass via `whereRaw()`. **`Query.where(field, "IN", [...])`** — now expands an array to `IN (?, ?, ?)` and binds each value separately. Pre-v0.8.18 the array bound to a single placeholder and silently matched zero rows (node:sqlite `?` doesn't support array-binding). Refuses non-array / empty-array inputs. **`Query.whereRaw(sql, params)`** — placeholder-counting now skips `?` characters inside SQL string literals (single + double quoted, including `''`-doubled escapes), line comments (`-- ...`), and block comments (`/* ... */`). Pre-v0.8.18 the naive regex counted literal-`?` and either threw on count mismatch OR let through fragments with masked missing real placeholders. **`b.safeSql.BANNED_IDENTIFIERS`** — extended to refuse `pragma` / `attach` / `detach` / `analyze` / `vacuum` / `reindex` as bare identifiers. Closes the SQLite-specific escape-the-parameterized-model surface (PRAGMAs disable security-relevant protections; ATTACH mounts external databases). **`b.db.declareTable`** — refuses any app schema name that begins with the framework's `_blamejs_` prefix (was an exact-match Set; an app schema entry like `_blamejs_audit_log_archive` slipped past the gate and could shadow / look-alike framework tables).
12
+
13
+ - **0.8.17** (2026-05-08) — Email auth + DANE / TLS-RPT spec-conformance fixes (ARC / DMARC / SPF / A-R / DANE / TLS-RPT). Ten more RFC-cited gaps closed. **ARC (`b.mail.arc`)** — signer's AMS canonicalization now includes the current hop's `ARC-Authentication-Results` per RFC 8617 §5.1.1 (auto-prepends to `h=` unless operator passes `excludeAarFromAms: true`). Microsoft + Google receivers DO include AAR in `h=`; the prior framework default produced chains those receivers couldn't verify. Verifier counterpart: when reconstructing the AMS canonical input, only the CURRENT hop's AAR is kept (prior-hop AARs stripped). Verifier now enforces `t=` (signing time) + `x=` (expiration) time windows per RFC 8617 §5.2 with operator-tunable `clockSkewMs` (default 5 min) — pre-v0.8.17 the verifier parsed but never enforced. **DMARC (`b.mail.dmarc`)** — multiple `v=DMARC1` records on a domain now treated as having no DMARC record per RFC 7489 §6.6.3 (`_fetchDmarcRecord` returns null on multi-match). Subdomain `sp=` fallback wired: when `_dmarc.<from-domain>` returns no record, the verifier walks one label up to the (heuristic) organizational domain and applies its `sp=` (subdomain policy) — falls back to `p=` when `sp=` absent. Result includes `policyOriginDomain` + `orgDomainPolicyApplied: true` so operators can audit which record drove the verdict. (Heuristic only — operators with PSL needs supply their own `dnsLookup` for full Public Suffix List walk.) **SPF (`b.mail.spf`)** — initial query for the sender's SPF record no longer counts toward the 10-lookup limit per RFC 7208 §4.6.4. Pre-v0.8.17 was off-by-one — senders at the spec ceiling got false `permerror`. **A-R (`b.mail.authResults`)** — result vocabulary is now METHOD-SPECIFIC per RFC 8601 §2.7 (per-method `AR_RESULTS_BY_METHOD` map). The flat `AR_VALID_RESULTS` table previously accepted `hardfail` for DKIM (only valid for DMARC §2.7.4) and accepted `temperror` / `permerror` for methods that don't recognize them. **DANE (`b.network.smtp.dane`)** — `daneTlsa()` now REFUSES to return records unless the caller passes `opts.dnssecValidated: true` per RFC 7672 §1.3 (TLSA records that are not DNSSEC-validated MUST NOT be used). Pre-v0.8.17 silently used un-validated records — silent escalation class. `daneVerifyChain` enforces RFC 6698 §2.1.4 + RFC 7672 §3.1.1 chain-order: a DANE-TA match at chain position `i` is accepted only when its DER Subject equals the DER Issuer of cert at position `i-1` (i.e. it's actually the parent in the chain, not a random non-leaf cert that happens to hash-match). Chain-order is best-effort — synthetic test fixtures without ASN.1-parseable DER fall through with `chainOrderUnverified: true` flagged on the match. **TLS-RPT (`b.network.smtp.tlsRpt.fetchPolicy`)** — record without `rua=` now returns `null` (record ignored) per RFC 8460 §3. Pre-v0.8.17 returned `{ version: "TLSRPTv1", rua: [] }` which operators incorrectly treated as a valid record with no destinations. Bug fix only — no new operator-facing primitives.
14
+
11
15
  - **0.8.16** (2026-05-08) — Email auth + transport spec-conformance fixes (DKIM / SPF / MTA-STS / OCSP). Ten RFC-cited gaps closed against shipped primitives. **DKIM (`b.mail.dkim`)** — verifier now refuses any signature whose `h=` tag does not include `from` (RFC 6376 §3.5 cornerstone bypass — without From-coverage the signature does not bind to the visible sender; receivers were treating these as valid); refuses unrecognized `v=` tag values per RFC 6376 §3.5 (only `v=1` accepted); enforces empty `p=` as explicit key revocation per RFC 6376 §3.6.1 (verdict `fail`, not `permerror` — well-formed signature against a withdrawn key); enforces `k=` algorithm-family tag agreement with the signature's `a=` family (e.g. `k=rsa` paired with `a=ed25519-sha256` permerrors per RFC 6376 §3.6.1); selector validator now accepts multi-label selectors per RFC 6376 §3.1 ABNF (`sub-domain *("." sub-domain)` — common for time-rotated keys like `2024.s1`). **SPF (`b.mail.spf`)** — refuses domains that publish multiple `v=spf1` TXT records with `permerror` per RFC 7208 §4.5 (most operators don't realize multi-record SPF was always invalid; this surfaces the misconfig instead of silently picking the first); `include:` mechanism now permerrors when the included domain has no SPF record per RFC 7208 §5.2 (closes the silent-authorization class where `include:gone-domain.example` followed by `+all` would silently allow). **MTA-STS (`b.smtpPolicy.mtaSts.fetch`)** — now requires the `_mta-sts.<domain>` TXT precondition record per RFC 8461 §3.1 before fetching the HTTPS policy (closes the silent-escalation class where the framework would fetch policies for domains that never opted in, AND defeats operator-side rotation when the `id=` in the TXT changes); cache TTL is now bounded by the policy's `max_age` value per RFC 8461 §3.2 (clamped between 1 hour floor and 1 year ceiling) instead of the framework's hardcoded 60-min default. **OCSP (`b.network.tls.ocsp.evaluate`)** — `evaluateOcspResponse` now enforces the `thisUpdate` / `nextUpdate` time window per RFC 6960 §4.2.2.1, rejecting responses whose validity window has expired or hasn't started yet (with operator-tunable `clockSkewMs`, default 5 min). Pre-v0.8.16 a captured "good" response could replay forever even after the cert was revoked; this defeats `requireGood` posture. Bug fix only — no new operator-facing primitives, no surface change beyond the additional refusals.
12
16
 
13
17
  - **0.8.15** (2026-05-08) — Transport-layer CVE absorption + AI-protocol primitives. **`b.sse`** — Server-Sent Events transport with newline-injection refusal in `event:` / `id:` / `data:` fields and the `Last-Event-ID` reconnect header (CVE-2026-33128 h3, CVE-2026-29085 Hono, CVE-2026-44217 sse-channel — three CVEs published in the same vulnerability class). Channel API: `channel.send({event,id,data,retry})` validates each field, refuses LF/CR/NUL, splits multi-line `data` into per-spec multiple `data:` lines, drives a `:keepalive` heartbeat with operator-tunable interval. `b.sse.serializeEvent({...})` exposes the encoder for buffered pipelines. **`b.mcp.serverGuard`** — Model Context Protocol server-side hardening. Bearer auth required by default (CVE-2026-33032 nginx-ui auth-bypass class), `redirect_uri` exact-match allowlist enforced per OAuth 2.1 / RFC 9700 §4.1.1 (CVE-2025-6514 mcp-remote OAuth RCE class), dynamic client-registration refused unless `allowDynamicRegister: true` with operator-supplied registration allowlist (confused-deputy class), tool/resource name allowlists at the guard layer. JSON-RPC 2.0 envelope validator. **`b.graphqlFederation.guardSdl`** — refuses `_service.sdl` / `_entities` probes without a router-token Bearer + optional single-use nonce; closes the schema-leak class where operators disable introspection thinking the schema is hidden. **`b.ai.input.classify`** — pattern-based prompt-injection classifier covering OWASP LLM01:2025 + NIST COSAIS RFI shapes: instruction-override / persona-jailbreak / role-reset markers / OpenAI system-tag templates / tool-call injection / exfil-callback / encoded-bypass (base64/rot13) / markdown+HTML smuggling / BIDI/zero-width/control char density. Severity-3 hits → `verdict: "malicious"`; 2+ severity-2 hits → `"suspicious"`; otherwise `"clean"`. Inline-on-every-request perf cost — no LLM, no network. **`b.a2a`** — A2A (Linux Foundation Agentic AI Foundation) v1.x signed agent-card primitive. `signCard` produces an envelope with a detached ML-DSA-87 signature over the SHA3-512 of the canonical-JSON serialization (RFC 8785-aligned); `verifyCard` validates signature + expiry + issuer match. Endpoints HTTPS-only (or localhost) at validation time. **`b.darkPatterns`** — FTC Negative Option Rule click-to-cancel UX-parity attestation primitive. `recordSignupFlow` / `recordCancelFlow` capture operator-attested click counts, CTA contrast / font weight, channel, confirmation steps; `assertParity` returns `{ ok, breaches }` against `ftc-2024` / `ca-sb942` / `strict` postures. `middleware({lookupAttestation, resourceIdFromReq})` refuses cancel-endpoint requests with HTTP 451 if no parity attestation is on file. **WebSocket control-frame size cap** — `lib/websocket.js` `_handleFrame` refuses any control frame (opcodes ≥ 0x8: CLOSE/PING/PONG) with payload length > 125 or `fin = false` (RFC 6455 §5.5). Closes the 2× outbound-bandwidth amplification class where a 1 MiB PING was echoed verbatim as PONG. **`b.requestHelpers.safeHeadersDistinct(req)`** — defensive accessor for `req.headersDistinct` that bypasses Node's faulty getter (Node CVE-2026-21710 — reading `__proto__` on the underlying header bag throws synchronously inside the getter, escaping handler-level try/catch). Computes the same null-prototype shape directly from `req.rawHeaders`. **TLS / SNI hardening** — `router.listen()` now wraps any operator-supplied `tlsOptions.SNICallback` so synchronous throws (Node CVE-2026-21637) become a clean async `(err, null)` callback rather than crashing the listener. Inbound TLS now defaults to `minVersion: "TLSv1.3"` when the operator's `tlsOptions` doesn't pin one (closes the gap where bare `{key, cert}` inherited Node's TLSv1.2 default). **httpClient identity decoding** — `b.httpClient` now sends `Accept-Encoding: identity` by default (h1 + h2 paths) — refuses compressed responses unless the operator explicitly opts in. Closes the undici unbounded-decompression amplification class (CVE-2026-22036). Operators that need compressed responses pass an explicit `Accept-Encoding` header. **Engine pin** — `engines.node` raised from `>=24.0.0` to `>=24.4.0` to ensure the undici fix is bundled.
package/lib/db-query.js CHANGED
@@ -117,6 +117,33 @@ class Query {
117
117
  value = lookup.value;
118
118
  }
119
119
  cryptoField && _validateField(field);
120
+ if (op === "IN") {
121
+ // node:sqlite ? does not support array-binding. Pre-v0.8.18
122
+ // `where(field, "IN", [1,2,3])` silently bound the entire
123
+ // array to a single placeholder and matched zero rows.
124
+ // Expand to (?, ?, ?) and push each value separately.
125
+ if (!Array.isArray(value) || value.length === 0) {
126
+ throw new Error("where IN requires a non-empty array of values");
127
+ }
128
+ var placeholders = value.map(function () { return "?"; }).join(", ");
129
+ this._where.push('"' + field + '" IN (' + placeholders + ")");
130
+ for (var i = 0; i < value.length; i += 1) this._whereParams.push(value[i]);
131
+ return this;
132
+ }
133
+ if (op === "LIKE" && typeof value === "string") {
134
+ // Escape SQL LIKE metacharacters % and _ in operator-supplied
135
+ // input. Without this, a single `%` in untrusted input becomes
136
+ // a wildcard that matches everything — a column-disclosure
137
+ // class (`q=%@%` enumerates entire table). Use a backslash as
138
+ // the escape character (uniform across SQLite + Postgres) and
139
+ // emit the corresponding ESCAPE clause so the engine treats it
140
+ // as the escape token. Operators who deliberately want LIKE
141
+ // wildcards in their value bypass via whereRaw().
142
+ var escaped = value.replace(/[\\%_]/g, "\\$&");
143
+ this._where.push('"' + field + '" LIKE ? ESCAPE ' + "'\\\\'");
144
+ this._whereParams.push(escaped);
145
+ return this;
146
+ }
120
147
  this._where.push('"' + field + '" ' + op + " ?");
121
148
  this._whereParams.push(value);
122
149
  return this;
@@ -152,7 +179,14 @@ class Query {
152
179
  throw new Error("whereRaw: sql must be a non-empty string");
153
180
  }
154
181
  var p = Array.isArray(params) ? params : (params == null ? [] : [params]);
155
- var holders = (sql.match(/\?/g) || []).length;
182
+ // Count `?` placeholders, but skip occurrences inside string
183
+ // literals ('...' or "..."), line comments (-- to EOL), and
184
+ // block comments (/* ... */). Pre-v0.8.18 the naive regex
185
+ // counted `?` inside literals (e.g. `WHERE name = 'a?b' AND id
186
+ // = ?`) which caused mismatched-count errors OR — worse — let
187
+ // through fragments where the literal-`?` placebo masked a
188
+ // missed real placeholder.
189
+ var holders = _countPlaceholders(sql);
156
190
  if (holders !== p.length) {
157
191
  throw new Error("whereRaw: " + holders + " placeholder(s) in sql but " +
158
192
  p.length + " param(s) supplied");
@@ -392,6 +426,46 @@ class Query {
392
426
  }
393
427
  }
394
428
 
429
+ // Count `?` placeholders outside string literals + comments.
430
+ // Tracks SQL single-quoted, double-quoted, line-comment, and block-
431
+ // comment state to avoid counting `?` characters that are part of
432
+ // literal text the SQL engine never interprets as a binding marker.
433
+ function _countPlaceholders(sql) {
434
+ var count = 0;
435
+ var i = 0;
436
+ var len = sql.length;
437
+ while (i < len) {
438
+ var ch = sql.charAt(i);
439
+ var next = i + 1 < len ? sql.charAt(i + 1) : "";
440
+ if (ch === "'" || ch === '"') {
441
+ var quote = ch;
442
+ i += 1;
443
+ while (i < len) {
444
+ if (sql.charAt(i) === quote) {
445
+ // SQL doubles the quote char to escape it within a literal.
446
+ if (sql.charAt(i + 1) === quote) { i += 2; continue; }
447
+ i += 1; break;
448
+ }
449
+ i += 1;
450
+ }
451
+ continue;
452
+ }
453
+ if (ch === "-" && next === "-") {
454
+ while (i < len && sql.charAt(i) !== "\n") i += 1;
455
+ continue;
456
+ }
457
+ if (ch === "/" && next === "*") {
458
+ i += 2;
459
+ while (i < len && !(sql.charAt(i) === "*" && sql.charAt(i + 1) === "/")) i += 1;
460
+ i += 2;
461
+ continue;
462
+ }
463
+ if (ch === "?") count += 1;
464
+ i += 1;
465
+ }
466
+ return count;
467
+ }
468
+
395
469
  function _validateField(field) {
396
470
  if (typeof field !== "string" ||
397
471
  field.length === 0 ||
package/lib/db.js CHANGED
@@ -744,13 +744,20 @@ async function init(opts) {
744
744
  }
745
745
  }
746
746
 
747
- // Refuse app schema entries that collide with framework-reserved names
747
+ // Refuse app schema entries that collide with framework-reserved names.
748
+ // Pre-v0.8.18 this was an exact-match Set; an app could ship
749
+ // `_blamejs_audit_log_archive` (or similar prefix-collision) and the
750
+ // framework would silently provision it next to the reserved
751
+ // namespace, allowing a row-by-row look-alike attack against audit
752
+ // archive tooling.
748
753
  for (var ri = 0; ri < opts.schema.length; ri++) {
749
- if (RESERVED_TABLE_NAMES.has(opts.schema[ri].name)) {
754
+ var appName = opts.schema[ri].name;
755
+ if (RESERVED_TABLE_NAMES.has(appName) ||
756
+ (typeof appName === "string" && appName.indexOf("_blamejs_") === 0)) {
750
757
  throw new DbError("db/reserved-table-name",
751
- "table name '" + opts.schema[ri].name + "' is reserved by the framework. " +
758
+ "table name '" + appName + "' is reserved by the framework. " +
752
759
  "Pick a different name (the framework provisions audit_log, consent_log, " +
753
- "and _blamejs_* tables automatically).");
760
+ "and any '_blamejs_*'-prefixed tables automatically).");
754
761
  }
755
762
  }
756
763
 
@@ -202,6 +202,20 @@ function sign(opts) {
202
202
  throw new MailAuthError("arc-sign/bad-headers",
203
203
  "sign: headersToSign must be a non-empty array of header names");
204
204
  }
205
+ // RFC 8617 §5.1.1 + Microsoft + Google receiver interop — the
206
+ // current hop's ARC-Authentication-Results MUST appear in the AMS
207
+ // h= list. Pre-v0.8.17 the framework default omitted it; receivers
208
+ // that include AAR in their canonicalization (M365, Gmail) failed
209
+ // to verify framework-signed chains. Auto-prepend if absent.
210
+ // Operators that explicitly want to opt out (deprecated, do not)
211
+ // pass `excludeAarFromAms: true`.
212
+ var hasAar = headersToSign.some(function (n) {
213
+ return String(n).toLowerCase() === "arc-authentication-results";
214
+ });
215
+ if (!hasAar && opts.excludeAarFromAms !== true) {
216
+ headersToSign = headersToSign.slice();
217
+ headersToSign.unshift("ARC-Authentication-Results");
218
+ }
205
219
  for (var hi = 0; hi < headersToSign.length; hi += 1) {
206
220
  if (typeof headersToSign[hi] !== "string" || headersToSign[hi].length === 0) {
207
221
  throw new MailAuthError("arc-sign/bad-headers",
@@ -263,8 +277,17 @@ function sign(opts) {
263
277
  amsTags.push("b=");
264
278
  var amsUnsigned = amsTags.join("; ");
265
279
 
280
+ // RFC 8617 §5.1.1 — the current hop's ARC-Authentication-Results
281
+ // is part of the AMS canonicalization input when h= covers it.
282
+ // Synthesize a virtual entry at the top of parsedHeaders so the
283
+ // header-name lookup below sees it; the canonicalizer reads
284
+ // parsedHeaders[idx] like any other header.
285
+ var amsParsedHeaders = [{
286
+ name: "ARC-Authentication-Results",
287
+ value: " " + aarValue,
288
+ }].concat(parsedHeaders);
266
289
  var canonHeaders = "";
267
- var headerNamesLc = parsedHeaders.map(function (h) { return h.name.toLowerCase(); });
290
+ var headerNamesLc = amsParsedHeaders.map(function (h) { return h.name.toLowerCase(); });
268
291
  for (var j = 0; j < headersToSign.length; j += 1) {
269
292
  var wantLc = headersToSign[j].toLowerCase();
270
293
  var idx = -1;
@@ -272,14 +295,12 @@ function sign(opts) {
272
295
  if (headerNamesLc[k] === wantLc) idx = k;
273
296
  }
274
297
  if (idx === -1) continue;
275
- var h = parsedHeaders[idx];
298
+ var h = amsParsedHeaders[idx];
276
299
  canonHeaders += _canonRelaxedHeader(h.name, h.value);
277
300
  }
278
- // Per RFC 8617 §5.1 — include the AAR for the current hop in the
279
- // AMS canonicalization stream when h= covers it. Per AMS spec, h=
280
- // typically does NOT include the AAR (it's a *prior* hop's
281
- // attestation), so the canonical input is (h-listed headers) +
282
- // (AMS with empty b=).
301
+ // RFC 8617 §5.1 — current-hop AAR is included via h= (prepended
302
+ // above); canonical input is (h-listed headers) + (AMS with empty
303
+ // b=).
283
304
  var amsCanonInput = canonHeaders +
284
305
  _canonRelaxedHeader("ARC-Message-Signature", amsUnsigned).replace(/\r\n$/, "");
285
306
 
package/lib/mail-auth.js CHANGED
@@ -161,8 +161,14 @@ async function spfVerify(opts) {
161
161
  }
162
162
 
163
163
  var lookups = { count: 0, limit: SPF_DNS_LOOKUP_LIMIT };
164
+ // RFC 7208 §4.6.4 — the initial query for the sender domain's SPF
165
+ // record itself does NOT count toward the 10-lookup limit. Only
166
+ // include / a / mx / ptr / exists / redirect mechanisms count.
167
+ // Pre-v0.8.17 this was off-by-one — senders at the spec ceiling
168
+ // got false permerror.
164
169
  var result = await _spfEvaluateDomain(domain.toLowerCase(), opts.ip,
165
- opts.dnsLookup, lookups);
170
+ opts.dnsLookup, lookups,
171
+ { isInitial: true });
166
172
  return {
167
173
  result: result.verdict, // pass | fail | softfail | neutral | none | temperror | permerror
168
174
  domain: domain,
@@ -171,11 +177,14 @@ async function spfVerify(opts) {
171
177
  };
172
178
  }
173
179
 
174
- async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups) {
180
+ async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups, ctx) {
181
+ ctx = ctx || {};
175
182
  if (lookups.count > lookups.limit) {
176
183
  return { verdict: "permerror", explanation: "DNS lookup limit exceeded (RFC 7208 §4.6.4)" };
177
184
  }
178
- lookups.count += 1;
185
+ // Initial query for the sender's SPF record doesn't count (RFC 7208
186
+ // §4.6.4); only include / a / mx / ptr / exists / redirect do.
187
+ if (!ctx.isInitial) lookups.count += 1;
179
188
 
180
189
  var fetched;
181
190
  try { fetched = await _fetchSpfRecord(domain, dnsLookup); }
@@ -265,11 +274,16 @@ async function _fetchDmarcRecord(domain, dnsLookup) {
265
274
  ((e && e.message) || String(e)));
266
275
  }
267
276
  if (!Array.isArray(records)) return null;
277
+ var matches = [];
268
278
  for (var i = 0; i < records.length; i += 1) {
269
279
  var rec = Array.isArray(records[i]) ? records[i].join("") : records[i];
270
- if (typeof rec === "string" && rec.indexOf("v=DMARC1") === 0) return rec;
280
+ if (typeof rec === "string" && rec.indexOf("v=DMARC1") === 0) matches.push(rec);
271
281
  }
272
- return null;
282
+ if (matches.length === 0) return null;
283
+ // RFC 7489 §6.6.3 — when multiple v=DMARC1 records are published,
284
+ // the receiver MUST treat the domain as having no DMARC record.
285
+ if (matches.length > 1) return null;
286
+ return matches[0];
273
287
  }
274
288
 
275
289
  function _parseDmarcRecord(text) {
@@ -323,10 +337,40 @@ async function dmarcEvaluate(opts) {
323
337
  "dmarc.evaluate: opts.from is missing the @domain part");
324
338
  }
325
339
 
326
- var policy;
327
- try { var rec = await _fetchDmarcRecord(fromDomain, opts.dnsLookup);
328
- policy = rec ? _parseDmarcRecord(rec) : null; }
329
- catch (e) {
340
+ var policy = null;
341
+ var policyOriginDomain = null;
342
+ var orgDomainPolicyApplied = false;
343
+ try {
344
+ var rec = await _fetchDmarcRecord(fromDomain, opts.dnsLookup);
345
+ if (rec) {
346
+ policy = _parseDmarcRecord(rec);
347
+ policyOriginDomain = fromDomain;
348
+ } else {
349
+ // RFC 7489 §6.6.3 — when no DMARC record exists at the From
350
+ // domain, the receiver MUST walk to the organizational domain
351
+ // and query for a record there, then apply that record's `sp=`
352
+ // subdomain policy (or fall back to `p=`). Without a PSL we
353
+ // approximate the org domain by dropping one label at a time —
354
+ // covers the common one-level-subdomain case (mail.example.com
355
+ // → example.com) but not multi-label public suffixes (.co.uk).
356
+ // Operators with PSL-aware needs override `dnsLookup` and
357
+ // implement the full lookup themselves.
358
+ var labels = fromDomain.split(".");
359
+ if (labels.length >= 3) {
360
+ var parent = labels.slice(1).join(".");
361
+ var parentRec = await _fetchDmarcRecord(parent, opts.dnsLookup);
362
+ if (parentRec) {
363
+ var parentPolicy = _parseDmarcRecord(parentRec);
364
+ // Apply sp= if set, else fall back to p=. The result is the
365
+ // policy the receiver applies to mail from this subdomain.
366
+ parentPolicy.p = parentPolicy.sp || parentPolicy.p;
367
+ policy = parentPolicy;
368
+ policyOriginDomain = parent;
369
+ orgDomainPolicyApplied = true;
370
+ }
371
+ }
372
+ }
373
+ } catch (e) {
330
374
  return { result: "temperror", explanation: e.message,
331
375
  policy: null, alignment: { spf: false, dkim: false } };
332
376
  }
@@ -359,6 +403,8 @@ async function dmarcEvaluate(opts) {
359
403
  return {
360
404
  result: pass ? "pass" : "fail",
361
405
  policy: policy,
406
+ policyOriginDomain: policyOriginDomain,
407
+ orgDomainPolicyApplied: orgDomainPolicyApplied,
362
408
  alignment: { spf: spfAligned, dkim: dkimAligned },
363
409
  recommendedAction: recommendedAction,
364
410
  explanation: pass
@@ -502,19 +548,45 @@ async function arcVerify(rfc822, opts) {
502
548
  // 3. Per-hop AMS + AS verification.
503
549
  var perHop = [];
504
550
  var anyFail = false;
551
+ // RFC 8617 §5.2 — operator-tunable clock skew on t= (signing
552
+ // timestamp) and x= (expiration) tags. Default 5 min.
553
+ var arcClockSkewMs = typeof opts.clockSkewMs === "number" && opts.clockSkewMs >= 0 // allow:numeric-opt-Infinity — operator-supplied skew, default 5 min
554
+ ? opts.clockSkewMs : C.TIME.minutes(5);
555
+ var nowSec = Math.floor(Date.now() / 1000); // allow:raw-byte-literal — Unix epoch seconds divisor
505
556
 
506
557
  for (var hopIdx = 0; hopIdx < hops.length; hopIdx += 1) {
507
558
  var hop = hops[hopIdx];
508
559
 
560
+ // RFC 8617 §5.2 — verifier MUST reject AMS or AS with t= timestamp
561
+ // in the future or x= expiration in the past (with operator skew
562
+ // tolerance). Pre-v0.8.17 the verifier parsed t= but never
563
+ // enforced it.
564
+ var amsTags = _parseArcTagList(hop["arc-message-signature"]);
565
+ var asTags = _parseArcTagList(hop["arc-seal"]);
566
+ var amsT = amsTags.t ? parseInt(amsTags.t, 10) : null;
567
+ var amsX = amsTags.x ? parseInt(amsTags.x, 10) : null;
568
+ var asT = asTags.t ? parseInt(asTags.t, 10) : null;
569
+ var asX = asTags.x ? parseInt(asTags.x, 10) : null;
570
+ var skewSec = Math.floor(arcClockSkewMs / 1000); // allow:raw-byte-literal — sec divisor
571
+ var timeFault = null;
572
+ if (amsT && isFinite(amsT) && amsT - skewSec > nowSec) timeFault = "ams-t-future";
573
+ if (amsX && isFinite(amsX) && amsX + skewSec < nowSec) timeFault = "ams-x-expired";
574
+ if (asT && isFinite(asT) && asT - skewSec > nowSec) timeFault = "as-t-future";
575
+ if (asX && isFinite(asX) && asX + skewSec < nowSec) timeFault = "as-x-expired";
576
+
509
577
  // AMS — RFC 8617 §5.1.1. Same shape as a DKIM-Signature; reuses
510
578
  // the DKIM verifier by injecting a temporary message that has
511
579
  // the AMS as the signing header.
512
- var amsResult = await _verifyArc(rfc822, hop, hops, "ams", opts.dnsLookup, dkim);
580
+ var amsResult = timeFault
581
+ ? { result: "fail", errors: ["ams: " + timeFault + " (RFC 8617 §5.2)"] }
582
+ : await _verifyArc(rfc822, hop, hops, "ams", opts.dnsLookup, dkim);
513
583
 
514
584
  // AS — RFC 8617 §5.1.2. Signs the catenation of all prior
515
585
  // ARC-{AAR,AMS,AS} headers plus current AAR + AMS, then the AS
516
586
  // itself with empty b=.
517
- var asResult = await _verifyArc(rfc822, hop, hops, "as", opts.dnsLookup, dkim);
587
+ var asResult = timeFault
588
+ ? { result: "fail", errors: ["as: " + timeFault + " (RFC 8617 §5.2)"] }
589
+ : await _verifyArc(rfc822, hop, hops, "as", opts.dnsLookup, dkim);
518
590
 
519
591
  perHop.push({
520
592
  instance: hop.instance,
@@ -683,11 +755,17 @@ async function _verifyAmsViaDkim(rfc822, hop, sigValue, tags, dkim, dnsLookup) {
683
755
  var name = line.slice(0, colonAt).trim().toLowerCase();
684
756
  if (name === "arc-message-signature" ||
685
757
  name === "arc-seal" ||
686
- name === "arc-authentication-results" ||
687
758
  name === "dkim-signature") {
688
- // Drop pre-existing ARC + DKIM headers from the synthetic.
689
759
  continue;
690
760
  }
761
+ if (name === "arc-authentication-results") {
762
+ // RFC 8617 §5.1.1 — keep only the CURRENT hop's AAR (signer
763
+ // canonicalizes it via h=). Pre-v0.8.17 stripped every AAR
764
+ // unconditionally, breaking verification on chains that
765
+ // included AAR in h= (Microsoft + Google interop).
766
+ var instMatch = /\bi\s*=\s*(\d+)/.exec(line.slice(colonAt + 1));
767
+ if (!instMatch || parseInt(instMatch[1], 10) !== hop.instance) continue;
768
+ }
691
769
  rebuilt.push(line);
692
770
  }
693
771
  rebuilt.unshift(renamedHeader);
@@ -862,15 +940,39 @@ async function arcEvaluate(rfc822, opts) {
862
940
  // });
863
941
  // // → "Authentication-Results: mx.example.com;\r\n spf=pass smtp.mailfrom=user@sender.example;\r\n dkim=pass header.d=sender.example;\r\n dmarc=pass header.from=user@sender.example;\r\n arc=pass"
864
942
 
865
- var AR_VALID_RESULTS = {
866
- pass: 1, fail: 1, neutral: 1, none: 1, softfail: 1, policy: 1,
867
- permerror: 1, temperror: 1, hardfail: 1, bestguesspass: 1,
868
- };
869
- var AR_VALID_METHODS = {
870
- auth: 1, dkim: 1, "dkim-adsp": 1, dmarc: 1, "domainkeys": 1,
871
- "iprev": 1, "sender-id": 1, spf: 1, arc: 1, "smime": 1, dane: 1,
872
- "vbr": 1, "dnswl": 1, "x-original-authentication-results": 1,
943
+ // RFC 8601 §2.7 — result vocabulary is METHOD-SPECIFIC, not a flat
944
+ // allowlist. The flat AR_VALID_RESULTS table previously accepted
945
+ // `hardfail` for DKIM (only valid for DMARC §2.7.4) and `temperror` /
946
+ // `permerror` for methods that don't recognize them. Per-method maps
947
+ // match the spec sections cited.
948
+ var AR_RESULTS_BY_METHOD = {
949
+ // §2.7.1 auth
950
+ auth: { pass: 1, fail: 1, none: 1, permerror: 1, temperror: 1 },
951
+ // §2.7.2 — domainkeys (legacy; vocabulary kept narrow)
952
+ domainkeys: { pass: 1, fail: 1, neutral: 1, none: 1, permerror: 1, temperror: 1, policy: 1 },
953
+ // §2.7.3 — DKIM
954
+ dkim: { pass: 1, fail: 1, neutral: 1, none: 1, permerror: 1, temperror: 1, policy: 1 },
955
+ "dkim-adsp": { pass: 1, fail: 1, discard: 1, nxdomain: 1, none: 1, permerror: 1, temperror: 1 },
956
+ // §2.7.4 — SPF (uses softfail; not hardfail)
957
+ spf: { pass: 1, fail: 1, softfail: 1, neutral: 1, none: 1, permerror: 1, temperror: 1, policy: 1 },
958
+ "sender-id": { pass: 1, fail: 1, softfail: 1, neutral: 1, none: 1, permerror: 1, temperror: 1, policy: 1 },
959
+ // §2.7.5 — IPRev
960
+ iprev: { pass: 1, fail: 1, permerror: 1, temperror: 1 },
961
+ // §2.7.6 — DMARC (this is the ONE place hardfail is valid in some drafts; keep it)
962
+ dmarc: { pass: 1, fail: 1, none: 1, permerror: 1, temperror: 1, hardfail: 1, bestguesspass: 1 },
963
+ // RFC 8617 §4.1 — ARC
964
+ arc: { pass: 1, fail: 1, none: 1 },
965
+ // RFC 8616 — DANE
966
+ dane: { pass: 1, fail: 1, none: 1, permerror: 1, temperror: 1 },
967
+ // VBR + DNSWL + S/MIME — vocabulary kept conservative
968
+ smime: { pass: 1, fail: 1, neutral: 1, none: 1, permerror: 1, temperror: 1, policy: 1 },
969
+ vbr: { pass: 1, fail: 1, none: 1, permerror: 1, temperror: 1 },
970
+ dnswl: { pass: 1, none: 1, temperror: 1 },
971
+ "x-original-authentication-results": { pass: 1, fail: 1, neutral: 1, none: 1, softfail: 1, hardfail: 1, policy: 1, permerror: 1, temperror: 1, bestguesspass: 1, discard: 1, nxdomain: 1 },
873
972
  };
973
+ var AR_VALID_METHODS = Object.keys(AR_RESULTS_BY_METHOD).reduce(function (acc, m) {
974
+ acc[m] = 1; return acc;
975
+ }, {});
874
976
 
875
977
  function authResultsEmit(opts) {
876
978
  validateOpts.requireObject(opts, "authResults.emit", MailAuthError, "mail-auth/ar-bad-input");
@@ -908,9 +1010,10 @@ function authResultsEmit(opts) {
908
1010
  throw new MailAuthError("mail-auth/ar-bad-method",
909
1011
  "authResults.emit: unknown method '" + r.method + "'");
910
1012
  }
911
- if (!AR_VALID_RESULTS[result]) {
1013
+ var methodResults = AR_RESULTS_BY_METHOD[method];
1014
+ if (!methodResults || !methodResults[result]) {
912
1015
  throw new MailAuthError("mail-auth/ar-bad-result",
913
- "authResults.emit: unknown result '" + r.result + "' for method '" + method + "'");
1016
+ "authResults.emit: result '" + r.result + "' is not in the RFC 8601 §2.7 vocabulary for method '" + method + "'");
914
1017
  }
915
1018
  var clause = method + "=" + result;
916
1019
  if (r.reason && typeof r.reason === "string" && !/[\r\n\0;]/.test(r.reason)) {
@@ -221,11 +221,12 @@ function mtaStsMatchMx(mxHost, mxList) {
221
221
 
222
222
  // ---- DANE TLSA (RFC 6698) ----
223
223
 
224
- async function daneTlsa(domain, port) {
224
+ async function daneTlsa(domain, port, opts) {
225
225
  if (typeof domain !== "string" || domain.length === 0) {
226
226
  throw new SmtpPolicyError("smtp/bad-domain",
227
227
  "dane.tlsa: domain must be a non-empty string");
228
228
  }
229
+ opts = opts || {};
229
230
  var p = typeof port === "number" ? port : 25; // allow:raw-byte-literal — IANA SMTP port
230
231
  var qname = "_" + p + "._tcp." + domain.toLowerCase();
231
232
  // node:dns has resolveTlsa() since Node 18.16.0.
@@ -240,6 +241,20 @@ async function daneTlsa(domain, port) {
240
241
  throw new SmtpPolicyError("smtp/dane-lookup-failed",
241
242
  "TLSA lookup for " + qname + " failed: " + ((e && e.message) || String(e)));
242
243
  }
244
+ // RFC 7672 §1.3 — TLSA records that are NOT DNSSEC-validated MUST
245
+ // NOT be used. node:dns.resolveTlsa does not surface the AD bit
246
+ // through its high-level API, so the framework requires the caller
247
+ // to assert via opts.dnssecValidated when running on a non-DNSSEC-
248
+ // aware resolver. The default REFUSES to use the records — operators
249
+ // MUST opt in explicitly. Pre-v0.8.17 this was silently used.
250
+ // Operators with a DNSSEC-validating resolver (Unbound, dnsmasq with
251
+ // DNSSEC, etc.) pass `dnssecValidated: true`; those without should
252
+ // not use DANE at all (RFC 7672 §1.3 explicit).
253
+ if (opts.dnssecValidated !== true) {
254
+ throw new SmtpPolicyError("smtp/dane-no-dnssec",
255
+ "dane.tlsa: TLSA records must be DNSSEC-validated before use (RFC 7672 §1.3); " +
256
+ "pass opts.dnssecValidated: true to acknowledge the resolver's DNSSEC posture");
257
+ }
243
258
  // Normalize node's response shape to { usage, selector, mtype, dataHex }.
244
259
  return (records || []).map(function (r) {
245
260
  return {
@@ -285,6 +300,60 @@ function daneRecordShape(rec) {
285
300
  // SMTP DANE surface (operators relying on PKIX modes pair this with
286
301
  // b.network.tls's CA store + Node's TLSSocket validation).
287
302
 
303
+ // Extract the issuer Name DER (raw SEQUENCE bytes) from a cert.
304
+ // Used for DANE-TA chain-order verification: the matched DANE-TA cert's
305
+ // subject must equal the next-down cert's issuer.
306
+ function _extractIssuerDer(certDer) {
307
+ var top;
308
+ try { top = asn1.readNode(certDer); }
309
+ catch (_e) { return null; }
310
+ if (top.tag !== asn1.TAG.SEQUENCE) return null;
311
+ var children;
312
+ try { children = asn1.readSequence(top.value); }
313
+ catch (_e) { return null; }
314
+ if (children.length === 0) return null;
315
+ var tbs = children[0];
316
+ var tbsKids;
317
+ try { tbsKids = asn1.readSequence(tbs.value); }
318
+ catch (_e) { return null; }
319
+ var idx = 0;
320
+ if (tbsKids.length > 0 &&
321
+ tbsKids[0].tagClass === asn1.TAG_CLASS.CONTEXT_SPECIFIC &&
322
+ tbsKids[0].tag === 0) { // allow:raw-byte-literal — X.509 [0] EXPLICIT version tag
323
+ idx = 1;
324
+ }
325
+ // TBSCertificate fields: serial, signature, issuer, validity, subject, ...
326
+ // Issuer = idx + 2.
327
+ var issuerIdx = idx + 2;
328
+ if (issuerIdx >= tbsKids.length) return null;
329
+ return tbsKids[issuerIdx].raw;
330
+ }
331
+
332
+ function _extractSubjectDer(certDer) {
333
+ var top;
334
+ try { top = asn1.readNode(certDer); }
335
+ catch (_e) { return null; }
336
+ if (top.tag !== asn1.TAG.SEQUENCE) return null;
337
+ var children;
338
+ try { children = asn1.readSequence(top.value); }
339
+ catch (_e) { return null; }
340
+ if (children.length === 0) return null;
341
+ var tbs = children[0];
342
+ var tbsKids;
343
+ try { tbsKids = asn1.readSequence(tbs.value); }
344
+ catch (_e) { return null; }
345
+ var idx = 0;
346
+ if (tbsKids.length > 0 &&
347
+ tbsKids[0].tagClass === asn1.TAG_CLASS.CONTEXT_SPECIFIC &&
348
+ tbsKids[0].tag === 0) { // allow:raw-byte-literal — X.509 [0] EXPLICIT version tag
349
+ idx = 1;
350
+ }
351
+ // Subject = idx + 4 (after serial / signature / issuer / validity).
352
+ var subjectIdx = idx + 4;
353
+ if (subjectIdx >= tbsKids.length) return null;
354
+ return tbsKids[subjectIdx].raw;
355
+ }
356
+
288
357
  function _extractSubjectPublicKeyInfo(certDer) {
289
358
  // SPKI (selector=1) is the SubjectPublicKeyInfo SEQUENCE inside
290
359
  // tbsCertificate. Tolerant of malformed input — returns null when
@@ -376,10 +445,36 @@ function daneVerifyChain(certChain, tlsaRecords, opts) {
376
445
  for (var t = 0; t < tlsaRecords.length; t += 1) {
377
446
  var rec = tlsaRecords[t];
378
447
  var usage = rec.usage;
379
- if (usage === 2) { // DANE-TA match against any non-leaf cert (TA in chain)
448
+ if (usage === 2) { // allow:raw-byte-literal — TLSA cert-usage code (RFC 6698 §2.1.1) — DANE-TA: match against trust anchor IN the chain (RFC 7672 §3.1.1).
449
+ // The framework now enforces chain order: the matched DANE-TA
450
+ // cert at position i must have its Subject equal to the Issuer
451
+ // of cert at position i-1 (i.e. it must actually be the parent
452
+ // in the chain, not a random non-leaf cert that happens to
453
+ // hash-match the TLSA record).
380
454
  for (var i = 1; i < certChain.length; i += 1) {
381
455
  var rv = _matchTlsaAgainstCert(rec, certChain[i]);
382
- if (rv) { matches.push({ tlsaIndex: t, certIndex: i, usage: "DANE-TA", mtype: rv.mtype }); break; }
456
+ if (!rv) continue;
457
+ var taSubject = _extractSubjectDer(certChain[i]);
458
+ var childIssuer = _extractIssuerDer(certChain[i - 1]);
459
+ if (!taSubject || !childIssuer) {
460
+ // ASN.1 extraction failed (non-DER buffer or malformed).
461
+ // Accept the match but flag it — real-world peerCertificate
462
+ // chains are always DER, so this branch is reached only for
463
+ // synthetic / test-fixture inputs.
464
+ matches.push({ tlsaIndex: t, certIndex: i, usage: "DANE-TA",
465
+ mtype: rv.mtype, chainOrderUnverified: true });
466
+ break;
467
+ }
468
+ if (taSubject.equals(childIssuer)) {
469
+ matches.push({ tlsaIndex: t, certIndex: i, usage: "DANE-TA", mtype: rv.mtype });
470
+ break;
471
+ }
472
+ // Match found at this index but the chain isn't ordered — keep
473
+ // looking up the chain in case a later cert is the genuine
474
+ // trust anchor and the matching cert was a misconfiguration.
475
+ errors.push({ tlsaIndex: t, certIndex: i,
476
+ reason: "dane-ta-chain-order-mismatch",
477
+ note: "TLSA record matched cert[" + i + "] but its Subject does not equal the Issuer of cert[" + (i - 1) + "] (RFC 7672 §3.1.1 chain-order check)" });
383
478
  }
384
479
  } else if (usage === 3) { // DANE-EE — match against the leaf cert only
385
480
  var rvEe = _matchTlsaAgainstCert(rec, certChain[0]);
@@ -512,6 +607,11 @@ async function tlsRptFetchPolicy(domain, opts) {
512
607
  }
513
608
  }
514
609
  }
610
+ // RFC 8460 §3 — `rua=` is REQUIRED. A v=TLSRPTv1 record without `rua=`
611
+ // is malformed and MUST be ignored. Pre-v0.8.17 the framework
612
+ // returned `{ rua: [] }` which operators (incorrectly) treated as a
613
+ // valid record with no destinations.
614
+ if (rua.length === 0) return null;
515
615
  return { version: "TLSRPTv1", rua: rua };
516
616
  }
517
617
 
package/lib/safe-sql.js CHANGED
@@ -58,6 +58,12 @@ var BANNED_IDENTIFIERS = new Set([
58
58
  "where", "from", "join", "into", "values", "table", "database",
59
59
  "schema", "index", "view", "trigger", "procedure", "function",
60
60
  "begin", "commit", "rollback", "savepoint",
61
+ // SQLite-specific commands that escape the parameterized-query
62
+ // model. attach/detach mount external databases; pragma changes
63
+ // PRAGMAs (foreign_keys / cell_size_check / trusted_schema /
64
+ // journal_mode etc.) which can disable security-relevant
65
+ // protections; analyze / vacuum drop or rewrite indexes.
66
+ "pragma", "attach", "detach", "analyze", "vacuum", "reindex",
61
67
  ]);
62
68
 
63
69
  // Default identifier shape — Postgres NAMEDATALEN (63 chars) is the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.8.16",
3
+ "version": "0.8.18",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -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:9d91dfe3-4449-4315-ac6c-d78f5dd92d0c",
5
+ "serialNumber": "urn:uuid:1751cf74-fcc9-495e-a159-bac5eb2565b3",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-07T07:18:43.056Z",
8
+ "timestamp": "2026-05-07T07:40:58.458Z",
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.16",
22
+ "bom-ref": "@blamejs/core@0.8.18",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.8.16",
25
+ "version": "0.8.18",
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.16",
29
+ "purl": "pkg:npm/%40blamejs/core@0.8.18",
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.16",
57
+ "ref": "@blamejs/core@0.8.18",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]