@blamejs/core 0.14.20 → 0.14.22

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/index.js +5 -1
  3. package/lib/auth/jar.js +190 -28
  4. package/lib/auth/jwt-external.js +213 -0
  5. package/lib/auth/oauth.js +115 -101
  6. package/lib/auth/oid4vci.js +124 -5
  7. package/lib/auth/oid4vp.js +14 -4
  8. package/lib/break-glass.js +1 -2
  9. package/lib/config.js +28 -31
  10. package/lib/dora.js +8 -5
  11. package/lib/dsr.js +2 -2
  12. package/lib/flag-evaluation-context.js +7 -0
  13. package/lib/guard-html-wcag-aria.js +4 -2
  14. package/lib/guard-html-wcag-forms.js +4 -2
  15. package/lib/guard-html-wcag-tables.js +4 -2
  16. package/lib/guard-html-wcag-tagwalk.js +20 -0
  17. package/lib/guard-html-wcag.js +1 -1
  18. package/lib/honeytoken.js +27 -20
  19. package/lib/http-client.js +3 -4
  20. package/lib/lro.js +3 -4
  21. package/lib/mail-deploy.js +1 -1
  22. package/lib/mail-send-deliver.js +13 -4
  23. package/lib/middleware/api-encrypt.js +140 -13
  24. package/lib/middleware/asyncapi-serve.js +3 -0
  25. package/lib/middleware/csp-report.js +13 -9
  26. package/lib/middleware/deny-response.js +2 -10
  27. package/lib/middleware/health.js +1 -4
  28. package/lib/middleware/openapi-serve.js +3 -0
  29. package/lib/middleware/scim-server.js +297 -19
  30. package/lib/middleware/security-txt.js +1 -2
  31. package/lib/middleware/trace-log-correlation.js +4 -8
  32. package/lib/network-smtp-policy.js +4 -4
  33. package/lib/object-store/sigv4-bucket-ops.js +11 -2
  34. package/lib/observability-tracer.js +1 -1
  35. package/lib/problem-details.js +56 -11
  36. package/lib/pubsub-cluster.js +16 -3
  37. package/lib/queue-sqs.js +20 -2
  38. package/lib/redis-client.js +32 -4
  39. package/lib/safe-redirect.js +16 -2
  40. package/lib/validate-opts.js +34 -0
  41. package/package.json +1 -1
  42. package/sbom.cdx.json +6 -6
package/lib/dora.js CHANGED
@@ -248,8 +248,8 @@ function _validateReportInput(input) {
248
248
  *
249
249
  * @opts
250
250
  * audit: boolean (default true; set false to skip audit emits),
251
- * observability: boolean (reserved observability counter is always
252
- * best-effort and ignored on failure),
251
+ * observability: boolean (default true; set false to skip the
252
+ * best-effort observability counter on report),
253
253
  *
254
254
  * @example
255
255
  * var dora = b.dora.create({ audit: true });
@@ -276,6 +276,7 @@ function create(opts) {
276
276
  opts = opts || {};
277
277
  validateOpts(opts, ["audit", "observability"], "dora.create");
278
278
  var auditOn = opts.audit !== false;
279
+ var obsOn = opts.observability !== false;
279
280
 
280
281
  function _emit(action, info) {
281
282
  if (!auditOn) return;
@@ -354,9 +355,11 @@ function create(opts) {
354
355
  stage: record.stage,
355
356
  },
356
357
  });
357
- try { observability().count("dora.incident.reported", 1, {
358
- classification: record.classification, stage: record.stage,
359
- }); } catch (_e) { /* obs best-effort */ }
358
+ if (obsOn) {
359
+ observability().safeEvent("dora.incident.reported", 1, {
360
+ classification: record.classification, stage: record.stage,
361
+ });
362
+ }
360
363
  return record;
361
364
  }
362
365
 
package/lib/dsr.js CHANGED
@@ -279,8 +279,8 @@ function create(opts) {
279
279
  validateOpts(opts, [
280
280
  "ticketStore", "posture", "identityResolver",
281
281
  "sources", "audit", "retentionFloorMs",
282
- "deadlineMs", "observability",
283
- "verificationLevel", "verifyContext",
282
+ "deadlineMs",
283
+ "verificationLevel",
284
284
  "receiptSigner", "minVerificationByType",
285
285
  ], "dsr.create");
286
286
 
@@ -77,6 +77,13 @@ function fromRequest(req, opts) {
77
77
  if (typeof req.user.email === "string") ctx.email = req.user.email;
78
78
  if (req.user.tenantId != null) ctx.tenantId = req.user.tenantId;
79
79
  }
80
+ // Explicit tenantKey overrides the tenant id derived from req.user —
81
+ // the sibling of userKey for the tenant axis. Operators behind a
82
+ // gateway that resolves tenancy out-of-band (subdomain, mTLS SAN,
83
+ // signed header) pass it directly rather than depending on req.user.
84
+ if (typeof opts.tenantKey === "string" && opts.tenantKey.length > 0) {
85
+ ctx.tenantId = opts.tenantKey;
86
+ }
80
87
  var headers = req.headers || {};
81
88
  if (typeof headers["accept-language"] === "string") {
82
89
  ctx.locale = headers["accept-language"].split(",")[0].split(";")[0].trim();
@@ -58,8 +58,10 @@ function audit(html, opts) {
58
58
  ? KNOWN_ROLES.concat(opts.allowedRoles)
59
59
  : KNOWN_ROLES;
60
60
 
61
- var findings = [];
62
- function _add(f) { findings.push(f); }
61
+ // Per-finding scopeUrl stamping — shared collector in tagwalk.
62
+ var collector = tagwalk.makeScopedFindings(opts.scopeUrl);
63
+ var findings = collector.findings;
64
+ var _add = collector.add;
63
65
 
64
66
  var declaredIds = Object.create(null);
65
67
  var idRe = /\bid\s*=\s*["']([^"']+)["']/gi;
@@ -55,8 +55,10 @@ function audit(html, opts) {
55
55
  ? AUTOCOMPLETE_TOKENS.concat(opts.allowedAutocomplete)
56
56
  : AUTOCOMPLETE_TOKENS;
57
57
 
58
- var findings = [];
59
- function _add(f) { findings.push(f); }
58
+ // Per-finding scopeUrl stamping — shared collector in tagwalk.
59
+ var collector = tagwalk.makeScopedFindings(opts.scopeUrl);
60
+ var findings = collector.findings;
61
+ var _add = collector.add;
60
62
 
61
63
  // Pre-scan: is there a <legend> inside any <fieldset>?
62
64
  // We track fieldset → has-legend by forward-scanning each fieldset.
@@ -29,8 +29,10 @@ function audit(html, opts) {
29
29
  throw new TypeError("tables.audit: html must be a string");
30
30
  }
31
31
 
32
- var findings = [];
33
- function _add(f) { findings.push(f); }
32
+ // Per-finding scopeUrl stamping — shared collector in tagwalk.
33
+ var collector = tagwalk.makeScopedFindings(opts.scopeUrl);
34
+ var findings = collector.findings;
35
+ var _add = collector.add;
34
36
 
35
37
  // Walk the tag stream, tracking nesting state for tables + their
36
38
  // children. We don't build a full DOM; we track the open-tag stack
@@ -36,9 +36,29 @@ function lineColAt(html, offset) {
36
36
  return { line: line, column: offset - lastNl };
37
37
  }
38
38
 
39
+ // Shared findings collector for the sub-scanners' audit(html, opts)
40
+ // entry points. scopeUrl annotates every finding with the page it came
41
+ // from so a direct caller of a sub-scanner (aria/forms/tables) can
42
+ // correlate a finding back to its source document; the parent
43
+ // wcag.audit also records scopeUrl at report level, but stamping
44
+ // per-finding keeps the value useful when a sub-scanner is invoked on
45
+ // its own. Returns { findings, add } — push findings through add() so
46
+ // the stamp applies uniformly.
47
+ function makeScopedFindings(scopeUrlOpt) {
48
+ var scopeUrl = (typeof scopeUrlOpt === "string" && scopeUrlOpt.length > 0)
49
+ ? scopeUrlOpt : null;
50
+ var findings = [];
51
+ function add(f) {
52
+ if (scopeUrl !== null) f.scopeUrl = scopeUrl;
53
+ findings.push(f);
54
+ }
55
+ return { findings: findings, add: add };
56
+ }
57
+
39
58
  module.exports = {
40
59
  TAG_RE: TAG_RE,
41
60
  ATTR_RE: ATTR_RE,
42
61
  parseAttrs: parseAttrs,
43
62
  lineColAt: lineColAt,
63
+ makeScopedFindings: makeScopedFindings,
44
64
  };
@@ -345,7 +345,7 @@ function _checkAnchors(html, scheduled, report) {
345
345
  function audit(html, opts) {
346
346
  opts = opts || {};
347
347
  validateOpts(opts, [
348
- "level", "ignore", "checkAll", "scopeUrl",
348
+ "level", "ignore", "scopeUrl",
349
349
  "skipAria", "allowedRoles", "skipTables",
350
350
  "skipForms", "allowedAutocomplete",
351
351
  ], "guardHtml.wcag.audit");
package/lib/honeytoken.js CHANGED
@@ -89,6 +89,17 @@ function create(opts) {
89
89
  opts = opts || {};
90
90
  validateOpts(opts, ["audit"], "honeytoken.create");
91
91
 
92
+ // Honor the operator-supplied audit sink when present (the documented
93
+ // `audit: b.audit` injection); fall back to the module's lazyRequire so
94
+ // a caller that omits the sink still emits to the default audit log.
95
+ var auditSink = (opts.audit && typeof opts.audit.safeEmit === "function")
96
+ ? opts.audit : null;
97
+ function _emit(record) {
98
+ var sink = auditSink || audit();
99
+ try { sink.safeEmit(record); }
100
+ catch (_e) { /* audit best-effort */ }
101
+ }
102
+
92
103
  var registry = new Map(); // value → { id, kind, metadata, issuedAt }
93
104
 
94
105
  function issue(spec) {
@@ -110,13 +121,11 @@ function create(opts) {
110
121
  issuedAt: Date.now(),
111
122
  });
112
123
  registry.set(value, record);
113
- try {
114
- audit().safeEmit({
115
- action: "honeytoken.issued",
116
- outcome: "success",
117
- metadata: { id: id, kind: kind },
118
- });
119
- } catch (_e) { /* audit best-effort */ }
124
+ _emit({
125
+ action: "honeytoken.issued",
126
+ outcome: "success",
127
+ metadata: { id: id, kind: kind },
128
+ });
120
129
  return { id: id, value: value };
121
130
  }
122
131
 
@@ -124,19 +133,17 @@ function create(opts) {
124
133
  if (typeof value !== "string" || value.length === 0) return null;
125
134
  var record = registry.get(value);
126
135
  if (!record) return null;
127
- try {
128
- audit().safeEmit({
129
- action: "honeytoken.tripped",
130
- outcome: "failure",
131
- metadata: {
132
- id: record.id,
133
- kind: record.kind,
134
- metadata: record.metadata,
135
- observedAt: Date.now(),
136
- observedActor: observedActor || null,
137
- },
138
- });
139
- } catch (_e) { /* audit best-effort */ }
136
+ _emit({
137
+ action: "honeytoken.tripped",
138
+ outcome: "failure",
139
+ metadata: {
140
+ id: record.id,
141
+ kind: record.kind,
142
+ metadata: record.metadata,
143
+ observedAt: Date.now(),
144
+ observedActor: observedActor || null,
145
+ },
146
+ });
140
147
  return record;
141
148
  }
142
149
 
@@ -673,13 +673,12 @@ function _buildMultipartBody(spec) {
673
673
  var SENSITIVE_HEADERS_LC = ["authorization", "cookie", "proxy-authorization"];
674
674
 
675
675
  function _stripCrossOriginAuth(headers) {
676
- var out = {};
677
676
  var keys = Object.keys(headers);
677
+ var strip = [];
678
678
  for (var i = 0; i < keys.length; i++) {
679
- if (SENSITIVE_HEADERS_LC.indexOf(keys[i].toLowerCase()) !== -1) continue;
680
- out[keys[i]] = headers[keys[i]];
679
+ if (SENSITIVE_HEADERS_LC.indexOf(keys[i].toLowerCase()) !== -1) strip.push(keys[i]);
681
680
  }
682
- return out;
681
+ return validateOpts.assignOwnEnumerable({}, headers, strip);
683
682
  }
684
683
 
685
684
  /**
package/lib/lro.js CHANGED
@@ -185,13 +185,12 @@ function create(opts) {
185
185
  }
186
186
 
187
187
  function _stripPrivate(op) {
188
- var out = {};
189
188
  var keys = Object.keys(op);
189
+ var priv = [];
190
190
  for (var i = 0; i < keys.length; i += 1) {
191
- if (keys[i].charAt(0) === "_") continue;
192
- out[keys[i]] = op[keys[i]];
191
+ if (keys[i].charAt(0) === "_") priv.push(keys[i]);
193
192
  }
194
- return out;
193
+ return validateOpts.assignOwnEnumerable({}, op, priv);
195
194
  }
196
195
 
197
196
  module.exports = {
@@ -919,7 +919,7 @@ function tlsRptIngestHttp(opts) {
919
919
  opts = opts || {};
920
920
  validateOpts(opts, ["authenticate", "trustedReporters", "maxCompressedBytes",
921
921
  "maxDecompressedBytes", "maxRatio", "onAccept", "onRefuse",
922
- "audit", "compliance"],
922
+ "audit"],
923
923
  "mail.deploy.tlsRptIngestHttp");
924
924
  validateOpts.optionalFunction(opts.authenticate, "tlsRptIngestHttp: opts.authenticate",
925
925
  MailDeployError, "mail-tlsrpt/bad-opts");
@@ -468,16 +468,25 @@ function create(opts) {
468
468
 
469
469
  var retryOpts = opts.retry || {};
470
470
  validateOpts(retryOpts, ["maxAttempts", "backoffMs"], "mail.send.deliver.create.retry");
471
- var maxAttempts = typeof retryOpts.maxAttempts === "number" && retryOpts.maxAttempts > 0
472
- ? Math.floor(retryOpts.maxAttempts) : DEFAULT_RETRY_BACKOFF_MS.length;
471
+ // Config-time entry-point opts: a typo (maxAttempts:"5", mxLookupMs:-1)
472
+ // must fail at create(), not silently fall back to the default. Absent
473
+ // keeps the default; present-but-bad throws. Matches opts.port above.
474
+ validateOpts.optionalPositiveInt(retryOpts.maxAttempts,
475
+ "mail.send.deliver.create.retry.maxAttempts", DeliverError, "deliver/bad-retry-maxAttempts");
476
+ var maxAttempts = retryOpts.maxAttempts !== undefined
477
+ ? retryOpts.maxAttempts : DEFAULT_RETRY_BACKOFF_MS.length;
473
478
  var backoffMs = Array.isArray(retryOpts.backoffMs) && retryOpts.backoffMs.length > 0
474
479
  ? retryOpts.backoffMs.slice() : DEFAULT_RETRY_BACKOFF_MS.slice();
475
480
 
476
481
  var timeouts = opts.timeouts || {};
477
482
  validateOpts(timeouts, ["mxLookupMs", "perHostMs"], "mail.send.deliver.create.timeouts");
478
- var mxLookupTimeoutMs = typeof timeouts.mxLookupMs === "number" && timeouts.mxLookupMs > 0
483
+ validateOpts.optionalPositiveInt(timeouts.mxLookupMs,
484
+ "mail.send.deliver.create.timeouts.mxLookupMs", DeliverError, "deliver/bad-timeout-mxLookupMs");
485
+ validateOpts.optionalPositiveInt(timeouts.perHostMs,
486
+ "mail.send.deliver.create.timeouts.perHostMs", DeliverError, "deliver/bad-timeout-perHostMs");
487
+ var mxLookupTimeoutMs = timeouts.mxLookupMs !== undefined
479
488
  ? timeouts.mxLookupMs : DEFAULT_MX_LOOKUP_TIMEOUT_MS;
480
- var perHostTimeoutMs = typeof timeouts.perHostMs === "number" && timeouts.perHostMs > 0
489
+ var perHostTimeoutMs = timeouts.perHostMs !== undefined
481
490
  ? timeouts.perHostMs : DEFAULT_PER_HOST_TIMEOUT_MS;
482
491
 
483
492
  var dsnOpts = opts.dsn || null;
@@ -289,18 +289,28 @@ function create(opts) {
289
289
  ], "middleware.apiEncrypt");
290
290
  var keypairs = _resolveKeypairs(opts);
291
291
  var activeKeypair = keypairs[0];
292
+ // replayWindowMs gates the timestamp-staleness check (Math.abs(now - ts)
293
+ // > replayWindowMs). A non-numeric value would make that comparison
294
+ // always false and SILENTLY disable the staleness defense, so a typo
295
+ // throws at boot rather than shipping an open replay window.
296
+ validateOpts.optionalPositiveFinite(opts.replayWindowMs,
297
+ "apiEncrypt: replayWindowMs", ApiEncryptError, "BAD_OPT");
292
298
  var replayWindowMs = opts.replayWindowMs || DEFAULT_REPLAY_WINDOW_MS;
293
299
  // Cap on decrypted-payload size handed to safeJson.parse. Defaults
294
300
  // to 4 MiB (bodyParser's default 1 MiB plus headroom for crypto +
295
301
  // base64 round-trip). Operators with chunkier inbound payloads
296
302
  // raise this; the framework refuses to parse anything larger as a
297
303
  // parse-bomb defense.
304
+ validateOpts.optionalPositiveInt(opts.maxDecryptedBytes,
305
+ "apiEncrypt: maxDecryptedBytes", ApiEncryptError, "BAD_OPT");
298
306
  var maxDecryptedBytes = opts.maxDecryptedBytes != null
299
307
  ? opts.maxDecryptedBytes
300
308
  : C.BYTES.mib(4);
301
309
  // The spec calls for a sweep cadence of replayWindowMs/2 — short
302
310
  // enough that expired nonces don't pile up but not so frequent the
303
311
  // sweep query becomes a hot path. Operators can override.
312
+ validateOpts.optionalPositiveFinite(opts.pruneIntervalMs,
313
+ "apiEncrypt: pruneIntervalMs", ApiEncryptError, "BAD_OPT");
304
314
  var pruneIntervalMs = opts.pruneIntervalMs != null
305
315
  ? opts.pruneIntervalMs : Math.max(C.TIME.seconds(30), Math.floor(replayWindowMs / 2));
306
316
  var nonceStore = opts.nonceStore || nonceStoreLib.create({ backend: "memory" });
@@ -446,7 +456,11 @@ function create(opts) {
446
456
  // replayed responses with a monotonic counter check.
447
457
  function _encodeEnvelope(data, sessionKey, sessionCtx) {
448
458
  var ptBuf = Buffer.from(JSON.stringify(data), "utf8");
449
- var ctBuf = bCrypto.encryptPacked(ptBuf, sessionKey);
459
+ // Response AAD binds _sid/_ctr so a captured response cannot be
460
+ // replayed to the client under a rewritten counter.
461
+ var ctBuf = bCrypto.encryptPacked(ptBuf, sessionKey,
462
+ _responseAad(sessionCtx ? sessionCtx.sid : undefined,
463
+ sessionCtx ? sessionCtx.responseCtr : undefined));
450
464
  var encrypted = { _ct: ctBuf.toString("base64") };
451
465
  if (sessionCtx) {
452
466
  encrypted._sid = sessionCtx.sid;
@@ -527,6 +541,10 @@ function create(opts) {
527
541
 
528
542
  if (typeof ek === "string" && typeof nonce === "string") {
529
543
  // ---- Bootstrap path (per-request mode OR first request of session) ----
544
+ // The window-scoped claim TTL is sufficient HERE because _ts is
545
+ // AEAD-bound into _ct: a captured bootstrap envelope cannot have
546
+ // its timestamp rewritten, so past replayWindowMs the staleness
547
+ // gate above refuses it independently of this claim.
530
548
  var nonceHash = bCrypto.sha3Hash(nonce, "hex");
531
549
  var expireAt = now + replayWindowMs;
532
550
  var freshNonce;
@@ -553,11 +571,18 @@ function create(opts) {
553
571
  _emitFailure(req, "shape");
554
572
  return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
555
573
  }
556
- // Bootstrap a new session row keyed by sid.
574
+ // Bootstrap a new session row keyed by sid. responsesEmitted is
575
+ // set to 1 (this bootstrap emits one response) BEFORE the store
576
+ // write so a cluster store — which serialises a copy at set() time
577
+ // rather than holding a live reference — persists the same count
578
+ // the next subsequent request reads. Mutating the local object
579
+ // after set() would leave the stored row at 0, making the next
580
+ // response counter restart from 1 (a non-monotonic response _ctr
581
+ // that trips the client's strictly-increasing replay check).
557
582
  session = {
558
583
  sessionKey: sessionKey,
559
584
  lastReqCtr: ctr,
560
- responsesEmitted: 0,
585
+ responsesEmitted: 1,
561
586
  createdAt: now,
562
587
  lastUsedAt: now,
563
588
  expiresAt: now + sessionTtlMs,
@@ -574,7 +599,6 @@ function create(opts) {
574
599
  requestId: req.requestId || null,
575
600
  });
576
601
  sessionCtx = { sid: sid, responseCtr: 1 };
577
- session.responsesEmitted = 1;
578
602
  }
579
603
  } else if (keying === "per-session" &&
580
604
  typeof sid === "string" && typeof ctr === "number") {
@@ -633,6 +657,47 @@ function create(opts) {
633
657
  _emitFailure(req, "counter-replay");
634
658
  return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
635
659
  }
660
+ // Atomic replay gate (CWE-367). The monotonic counter check above is
661
+ // an ordering fast-path only: on a clustered session store, get(sid)
662
+ // returns a fresh deserialised copy per call, so two concurrent
663
+ // requests carrying the SAME valid ctr both observe the same
664
+ // lastReqCtr and both pass — double-execution replay. Claiming the
665
+ // (sid, ctr) tuple through the same atomic nonceStore the bootstrap
666
+ // path uses closes the window: exactly one concurrent request wins the
667
+ // insert; the loser is refused with the counter-replay shape. The
668
+ // "ctr:" prefix keeps this keyspace disjoint from the bootstrap
669
+ // nonceHash keyspace (a sha3 hex digest never starts with "ctr:").
670
+ // The claim must outlive the staleness window, not just span it:
671
+ // the post-handler sessionStore.set below is best-effort, so a
672
+ // failed write leaves lastReqCtr stale in the store, and _ts is
673
+ // plaintext envelope metadata (not bound into the AEAD) — were the
674
+ // claim to expire after replayWindowMs, the same captured
675
+ // (sid, ctr, _ct) could be replayed later with a fresh _ts, pass
676
+ // the stale monotonic check, re-claim the expired tuple, and
677
+ // execute twice. Claiming until session.expiresAt (the session is
678
+ // non-expired here, so that bound is in the future) keeps the
679
+ // tuple burned for as long as the session can accept requests;
680
+ // outstanding claims per session are bounded by
681
+ // sessionMaxResponses, and the memory nonce store fails closed at
682
+ // capacity.
683
+ var ctrKey = "ctr:" + sid + ":" + ctr;
684
+ var ctrFresh;
685
+ try { ctrFresh = await nonceStore.checkAndInsert(ctrKey, session.expiresAt); }
686
+ catch (_e) {
687
+ _emitFailure(req, "nonce-store-error");
688
+ return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "nonce-store-unavailable" });
689
+ }
690
+ if (!ctrFresh) {
691
+ _emitObs("apiEncrypt.session.replay_rejected", 1, { lane: "atomic" });
692
+ _emitSessionAudit("apiEncrypt.session.replay_rejected", {
693
+ outcome: "denied",
694
+ actor: requestHelpers.extractActorContext(req),
695
+ metadata: { sid: sid, receivedCtr: ctr, lane: "atomic" },
696
+ requestId: req.requestId || null,
697
+ });
698
+ _emitFailure(req, "counter-replay");
699
+ return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
700
+ }
636
701
  sessionKey = session.sessionKey;
637
702
  if (Buffer.isBuffer(sessionKey) === false) {
638
703
  // Operator-supplied store may have JSON-serialised the buffer.
@@ -660,11 +725,15 @@ function create(opts) {
660
725
  return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
661
726
  }
662
727
 
663
- // Decrypt _ct → cleartext payload bytes → JSON object.
728
+ // Decrypt _ct → cleartext payload bytes → JSON object. The request
729
+ // AAD authenticates the plaintext envelope fields exactly as the
730
+ // client bound them — a rewritten _ts/_nonce/_sid/_ctr fails the
731
+ // AEAD tag here, so the staleness gate above operates on a
732
+ // timestamp the sender cannot forge after capture.
664
733
  var clearObj;
665
734
  try {
666
735
  var ctBuf = Buffer.from(ct, "base64");
667
- var ptBuf = bCrypto.decryptPacked(ctBuf, sessionKey);
736
+ var ptBuf = bCrypto.decryptPacked(ctBuf, sessionKey, _requestAad(ts, nonce, sid, ctr));
668
737
  clearObj = safeJson.parse(ptBuf.toString("utf8"), { maxBytes: maxDecryptedBytes });
669
738
  } catch (_e) {
670
739
  _emitFailure(req, "tag");
@@ -740,6 +809,8 @@ function client(opts) {
740
809
  "apiEncrypt.client: pubkey.publicKey + ecPublicKey must be PEM strings", 500);
741
810
  }
742
811
  var pubkey = opts.pubkey;
812
+ validateOpts.optionalPositiveInt(opts.maxDecryptedBytes,
813
+ "apiEncrypt.client: maxDecryptedBytes", ApiEncryptError, "CLIENT_BAD_OPT");
743
814
  var maxDecryptedBytes = opts.maxDecryptedBytes != null
744
815
  ? opts.maxDecryptedBytes
745
816
  : C.BYTES.mib(4);
@@ -785,9 +856,23 @@ function client(opts) {
785
856
  "apiEncrypt.client: response counter is not strictly increasing " +
786
857
  "(got " + responseBody._ctr + ", lastSeen " + perSessionLastResCtr + ")");
787
858
  }
788
- perSessionLastResCtr = responseBody._ctr;
789
859
  var resCtBuf = Buffer.from(responseBody._ct, "base64");
790
- var resPtBuf = bCrypto.decryptPacked(resCtBuf, perSessionKey);
860
+ // Response AAD authenticates _sid/_ctr — the monotonic counter
861
+ // check above reads plaintext fields, so without this binding a
862
+ // captured response could be replayed under a bumped _ctr.
863
+ var resPtBuf;
864
+ try {
865
+ resPtBuf = bCrypto.decryptPacked(resCtBuf, perSessionKey,
866
+ _responseAad(responseBody._sid, responseBody._ctr));
867
+ } catch (_e) {
868
+ throw _err("CLIENT_RESPONSE_TAMPERED",
869
+ "apiEncrypt.client: response failed authenticated decryption (ciphertext or envelope metadata tampered)");
870
+ }
871
+ // Advance the counter only AFTER authenticated decryption — were it
872
+ // committed before, a forged high _ctr (which fails the AEAD above)
873
+ // would poison the monotonic check and refuse every subsequent
874
+ // genuine response for the rest of the session.
875
+ perSessionLastResCtr = responseBody._ctr;
791
876
  return safeJson.parse(resPtBuf.toString("utf8"), { maxBytes: maxDecryptedBytes });
792
877
  }
793
878
 
@@ -795,14 +880,18 @@ function client(opts) {
795
880
  if (payload === undefined) payload = null;
796
881
  if (!perSessionKey) _resetSession();
797
882
  var ts = Date.now();
798
- var ptBuf = Buffer.from(JSON.stringify(payload), "utf8");
799
- var ctBuf = bCrypto.encryptPacked(ptBuf, perSessionKey);
800
883
  perSessionReqCtr += 1;
884
+ var ptBuf = Buffer.from(JSON.stringify(payload), "utf8");
885
+ var ctBuf;
801
886
  var body;
802
887
  if (perSessionReqCtr === 1) {
803
888
  // Bootstrap envelope — full _ek + _nonce; server stores sid → sessionKey.
889
+ // The plaintext metadata is AEAD-bound so a captured envelope
890
+ // cannot be replayed under a rewritten _ts/_nonce/_sid/_ctr.
804
891
  var ek = bCrypto.encrypt(perSessionKey.toString("base64"), pubkey);
805
892
  var nonce = bCrypto.generateBytes(REQUEST_NONCE_BYTES).toString("hex");
893
+ ctBuf = bCrypto.encryptPacked(ptBuf, perSessionKey,
894
+ _requestAad(ts, nonce, perSessionSid, perSessionReqCtr));
806
895
  body = {
807
896
  _ek: ek,
808
897
  _ct: ctBuf.toString("base64"),
@@ -813,6 +902,8 @@ function client(opts) {
813
902
  };
814
903
  } else {
815
904
  // Subsequent — sid + ctr only. KEM material amortized across the session.
905
+ ctBuf = bCrypto.encryptPacked(ptBuf, perSessionKey,
906
+ _requestAad(ts, undefined, perSessionSid, perSessionReqCtr));
816
907
  body = {
817
908
  _ct: ctBuf.toString("base64"),
818
909
  _ts: ts,
@@ -827,10 +918,13 @@ function client(opts) {
827
918
  if (payload === undefined) payload = null;
828
919
  var sessionKey = bCrypto.generateBytes(SESSION_KEY_BYTES);
829
920
  var ek = bCrypto.encrypt(sessionKey.toString("base64"), pubkey);
830
- var ptBuf = Buffer.from(JSON.stringify(payload), "utf8");
831
- var ctBuf = bCrypto.encryptPacked(ptBuf, sessionKey);
832
921
  var requestNonce = bCrypto.generateBytes(REQUEST_NONCE_BYTES).toString("hex");
833
922
  var ts = Date.now();
923
+ var ptBuf = Buffer.from(JSON.stringify(payload), "utf8");
924
+ // AEAD-bind _ts/_nonce so a captured per-request envelope cannot
925
+ // be replayed past the staleness window with a rewritten _ts.
926
+ var ctBuf = bCrypto.encryptPacked(ptBuf, sessionKey,
927
+ _requestAad(ts, requestNonce, undefined, undefined));
834
928
  return {
835
929
  body: {
836
930
  _ek: ek,
@@ -845,7 +939,14 @@ function client(opts) {
845
939
  "apiEncrypt.client: response missing _ct field");
846
940
  }
847
941
  var resCtBuf = Buffer.from(responseBody._ct, "base64");
848
- var resPtBuf = bCrypto.decryptPacked(resCtBuf, sessionKey);
942
+ var resPtBuf;
943
+ try {
944
+ resPtBuf = bCrypto.decryptPacked(resCtBuf, sessionKey,
945
+ _responseAad(undefined, undefined));
946
+ } catch (_e) {
947
+ throw _err("CLIENT_RESPONSE_TAMPERED",
948
+ "apiEncrypt.client: response failed authenticated decryption (ciphertext or envelope metadata tampered)");
949
+ }
849
950
  return safeJson.parse(resPtBuf.toString("utf8"), { maxBytes: maxDecryptedBytes });
850
951
  },
851
952
  };
@@ -865,6 +966,30 @@ function client(opts) {
865
966
  };
866
967
  }
867
968
 
969
+ // AEAD associated-data builders — bind the envelope's PLAINTEXT
970
+ // metadata into the ciphertext so a captured envelope cannot be
971
+ // replayed with rewritten fields. `_ts` drives the staleness gate,
972
+ // `_nonce` the bootstrap replay claim, `_sid`/`_ctr` the session
973
+ // replay gates on requests and the client's monotonic counter check
974
+ // on responses; none are confidential, but every one is
975
+ // integrity-critical — rode plaintext, an attacker who captured an
976
+ // envelope could refresh `_ts` past the staleness window or replay a
977
+ // response under a bumped `_ctr`. Both halves of the protocol
978
+ // (middleware + client) live in this module and MUST build
979
+ // byte-identical strings; absent fields encode as the empty string so
980
+ // the per-request and per-session shapes stay unambiguous.
981
+ function _requestAad(ts, nonce, sid, ctr) {
982
+ return "blamejs-apienc/req/1|ts=" + String(ts) +
983
+ "|nonce=" + (typeof nonce === "string" ? nonce : "") +
984
+ "|sid=" + (typeof sid === "string" ? sid : "") +
985
+ "|ctr=" + (typeof ctr === "number" ? String(ctr) : "");
986
+ }
987
+
988
+ function _responseAad(sid, ctr) {
989
+ return "blamejs-apienc/res/1|sid=" + (typeof sid === "string" ? sid : "") +
990
+ "|ctr=" + (typeof ctr === "number" ? String(ctr) : "");
991
+ }
992
+
868
993
  // _generateUuidV4 — UUID v4 from 16 random bytes, formatted dash-separated.
869
994
  // Used for client-side session-id generation in per-session keying.
870
995
  // Slice offsets are RFC 4122 UUID hex-byte boundaries (`xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx`)
@@ -914,6 +1039,8 @@ function httpClientEncrypted(opts) {
914
1039
  throw _err("CLIENT_INVALID_PUBKEY",
915
1040
  "httpClient.encrypted: opts.pubkey is required (the callee's bootstrap doc)", 500);
916
1041
  }
1042
+ validateOpts.optionalPositiveInt(opts.maxDecryptedBytes,
1043
+ "httpClient.encrypted: maxDecryptedBytes", ApiEncryptError, "CLIENT_BAD_OPT");
917
1044
  var maxDecryptedBytes = opts.maxDecryptedBytes != null
918
1045
  ? opts.maxDecryptedBytes
919
1046
  : C.BYTES.mib(4);
@@ -173,6 +173,9 @@ function create(opts) {
173
173
  if (allowOriginHeader !== "*") headers["Vary"] = "Origin";
174
174
  }
175
175
  res.writeHead(200, headers); // HTTP 200
176
+ // HEAD carries the GET headers (incl. Content-Length) with no body
177
+ // (RFC 9110 §9.3.2).
178
+ if ((req.method || "GET").toUpperCase() === "HEAD") { res.end(); return; }
176
179
  res.end(body);
177
180
  }
178
181
 
@@ -135,8 +135,10 @@ function create(opts) {
135
135
  typeof opts.onReject !== "function") {
136
136
  throw new TypeError("middleware.cspReport: opts.onReject must be a function");
137
137
  }
138
- var maxBytes = (typeof opts.maxBytes === "number" && isFinite(opts.maxBytes) && opts.maxBytes > 0)
139
- ? opts.maxBytes : DEFAULT_MAX_BYTES;
138
+ validateOpts.optionalPositiveInt(opts.maxBytes, "middleware.cspReport: maxBytes");
139
+ var maxBytes = (opts.maxBytes === undefined || opts.maxBytes === null)
140
+ ? DEFAULT_MAX_BYTES : opts.maxBytes;
141
+ var auditOn = opts.audit !== false;
140
142
  var onReport = (typeof opts.onReport === "function") ? opts.onReport : null;
141
143
  var onReject = (typeof opts.onReject === "function") ? opts.onReject : null;
142
144
 
@@ -176,13 +178,15 @@ function create(opts) {
176
178
  for (var i = 0; i < reports.length; i++) {
177
179
  var normalized = _normalizeOne(reports[i]);
178
180
  if (!normalized) continue;
179
- try {
180
- audit().safeEmit({
181
- action: "csp.violation",
182
- outcome: "failure",
183
- metadata: Object.assign({ type: normalized.type, url: normalized.url }, normalized.body),
184
- });
185
- } catch (_e) { /* audit best-effort */ }
181
+ if (auditOn) {
182
+ try {
183
+ audit().safeEmit({
184
+ action: "csp.violation",
185
+ outcome: "failure",
186
+ metadata: Object.assign({ type: normalized.type, url: normalized.url }, normalized.body),
187
+ });
188
+ } catch (_e) { /* audit best-effort */ }
189
+ }
186
190
  if (onReport) {
187
191
  try { onReport(normalized); } catch (_e) { /* hook best-effort */ }
188
192
  }
@@ -34,18 +34,10 @@
34
34
  */
35
35
 
36
36
  var problemDetails = require("../problem-details");
37
+ var validateOpts = require("../validate-opts");
37
38
 
38
39
  function _isFn(x) { return typeof x === "function"; }
39
40
 
40
- function _mergeInto(target, extra) {
41
- if (!extra || typeof extra !== "object") return target;
42
- var keys = Object.keys(extra);
43
- for (var i = 0; i < keys.length; i += 1) {
44
- target[keys[i]] = extra[keys[i]];
45
- }
46
- return target;
47
- }
48
-
49
41
  /**
50
42
  * Resolve a deny-path refusal through the uniform hook / problem+json
51
43
  * / default chain. Returns whatever the `onDeny` hook returns when it
@@ -144,7 +136,7 @@ function denyResponse(req, res, ctx) {
144
136
  return undefined;
145
137
  }
146
138
 
147
- var head = _mergeInto({ "Content-Type": ctx.contentType }, extra);
139
+ var head = validateOpts.assignOwnEnumerable({ "Content-Type": ctx.contentType }, extra);
148
140
  var denyOut = (ctx.body === undefined || ctx.body === null) ? ""
149
141
  : (typeof ctx.body === "string" ? ctx.body : JSON.stringify(ctx.body));
150
142
  if (ctx.body !== undefined && ctx.body !== null && req && typeof req.apiEncryptEncode === "function") {