@blamejs/core 0.8.0 → 0.8.4

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 (63) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/lib/audit-sign.js +1 -1
  3. package/lib/audit.js +62 -2
  4. package/lib/auth/jwt.js +13 -0
  5. package/lib/auth/lockout.js +16 -3
  6. package/lib/auth/oauth.js +15 -1
  7. package/lib/auth/password.js +22 -2
  8. package/lib/auth/sd-jwt-vc-issuer.js +2 -2
  9. package/lib/auth/sd-jwt-vc.js +7 -2
  10. package/lib/break-glass.js +53 -14
  11. package/lib/cache-redis.js +1 -1
  12. package/lib/cache.js +6 -1
  13. package/lib/cli.js +3 -3
  14. package/lib/cluster.js +24 -1
  15. package/lib/compliance-ai-act-logging.js +7 -3
  16. package/lib/compliance.js +10 -2
  17. package/lib/config-drift.js +2 -2
  18. package/lib/crypto-field.js +21 -1
  19. package/lib/crypto.js +82 -1
  20. package/lib/db.js +35 -4
  21. package/lib/dev.js +30 -3
  22. package/lib/dual-control.js +19 -1
  23. package/lib/external-db.js +10 -0
  24. package/lib/file-upload.js +30 -3
  25. package/lib/flag.js +1 -1
  26. package/lib/guard-all.js +33 -16
  27. package/lib/guard-csv.js +16 -2
  28. package/lib/guard-html.js +35 -0
  29. package/lib/guard-svg.js +20 -0
  30. package/lib/http-client.js +57 -11
  31. package/lib/inbox.js +34 -10
  32. package/lib/log-stream-syslog.js +8 -0
  33. package/lib/log-stream.js +1 -1
  34. package/lib/mail.js +40 -0
  35. package/lib/middleware/attach-user.js +25 -2
  36. package/lib/middleware/bearer-auth.js +71 -6
  37. package/lib/middleware/body-parser.js +13 -0
  38. package/lib/middleware/cors.js +10 -0
  39. package/lib/middleware/csrf-protect.js +34 -3
  40. package/lib/middleware/dpop.js +3 -3
  41. package/lib/middleware/host-allowlist.js +1 -1
  42. package/lib/middleware/require-aal.js +2 -2
  43. package/lib/middleware/trace-propagate.js +1 -1
  44. package/lib/mtls-ca.js +23 -29
  45. package/lib/mtls-engine-default.js +21 -1
  46. package/lib/network-tls.js +21 -6
  47. package/lib/object-store/sigv4-bucket-ops.js +41 -0
  48. package/lib/observability-otlp-exporter.js +35 -2
  49. package/lib/outbox.js +3 -3
  50. package/lib/permissions.js +10 -1
  51. package/lib/pqc-agent.js +22 -1
  52. package/lib/pubsub.js +8 -4
  53. package/lib/redact.js +26 -1
  54. package/lib/retention.js +26 -0
  55. package/lib/router.js +1 -0
  56. package/lib/scheduler.js +57 -1
  57. package/lib/session.js +3 -3
  58. package/lib/ssrf-guard.js +19 -4
  59. package/lib/static.js +12 -0
  60. package/lib/totp.js +16 -0
  61. package/lib/ws-client.js +158 -9
  62. package/package.json +1 -1
  63. package/sbom.cyclonedx.json +6 -6
@@ -16,7 +16,7 @@
16
16
  * body, // Buffer | string | Readable | undefined
17
17
  * timeoutMs, // wall-clock cap (caller-chosen, no default)
18
18
  * idleTimeoutMs, // zero-progress idle cap (default 30s)
19
- * responseMode, // "buffer" (default) | "stream"
19
+ * responseMode, // "buffer" (default) | "stream" | "always-resolve"
20
20
  * maxResponseBytes, // for buffer mode (default 16 MiB control,
21
21
  * // 1 GiB GET — operators with > 1 GiB
22
22
  * // stored objects must use stream mode)
@@ -618,6 +618,11 @@ function _requestWithRedirects(opts, hopsLeft) {
618
618
  var u0 = safeUrl.parse(opts.url, { allowedProtocols: safeUrl.ALLOW_HTTP_ALL });
619
619
  originalOrigin = u0.protocol + "//" + u0.host;
620
620
  } catch (_e) { /* request() will reject on next hop's parse */ }
621
+ // onRedirect: function ({ from, to, hop, headersStripped, statusCode }) — called
622
+ // BEFORE each follow. Operator can mutate the next-hop URL or abort
623
+ // the redirect by throwing. Async hooks are awaited.
624
+ var onRedirect = typeof opts.onRedirect === "function" ? opts.onRedirect : null;
625
+ var hopCount = 0;
621
626
 
622
627
  var current = Object.assign({}, opts, { _resolveOnRedirect: true });
623
628
  function _follow() {
@@ -628,6 +633,7 @@ function _requestWithRedirects(opts, hopsLeft) {
628
633
  var loc = res.headers && (res.headers.location || res.headers.Location);
629
634
  if (!loc) return { finalOpts: current, res: res }; // 3xx with no Location — operator handles
630
635
  hopsLeft -= 1;
636
+ hopCount += 1;
631
637
 
632
638
  // Resolve relative Location against the just-fetched URL (the URL
633
639
  // of the request that produced the redirect, which may itself be a
@@ -651,8 +657,10 @@ function _requestWithRedirects(opts, hopsLeft) {
651
657
  var nu = safeUrl.parse(nextUrl, { allowedProtocols: safeUrl.ALLOW_HTTP_ALL });
652
658
  nextOrigin = nu.protocol + "//" + nu.host;
653
659
  } catch (_e) { /* request() will reject when it tries to parse */ }
660
+ var headersStripped = false;
654
661
  if (originalOrigin && nextOrigin && nextOrigin !== originalOrigin) {
655
662
  nextHeaders = _stripCrossOriginAuth(nextHeaders);
663
+ headersStripped = true;
656
664
  }
657
665
 
658
666
  // 303 → always GET; body dropped. 301/302 → historical clients
@@ -667,14 +675,42 @@ function _requestWithRedirects(opts, hopsLeft) {
667
675
  nextBody = undefined;
668
676
  }
669
677
 
670
- current = Object.assign({}, current, {
671
- url: nextUrl,
672
- method: nextMethod,
673
- body: nextBody,
674
- headers: nextHeaders,
675
- _resolveOnRedirect: true,
676
- });
677
- return _follow();
678
+ function _continueFollow() {
679
+ current = Object.assign({}, current, {
680
+ url: nextUrl,
681
+ method: nextMethod,
682
+ body: nextBody,
683
+ headers: nextHeaders,
684
+ _resolveOnRedirect: true,
685
+ });
686
+ return _follow();
687
+ }
688
+
689
+ // Caller-supplied redirect hook fires here. The hook can throw
690
+ // (sync) or reject (async) to abort the follow with a custom
691
+ // error; otherwise we proceed to the next hop. We pre-bind the
692
+ // values the hook gets and pass them in a frozen object so a
693
+ // caller can't mutate the in-flight pipeline by side-effect.
694
+ if (onRedirect) {
695
+ var hookEvent = Object.freeze({
696
+ from: current.url,
697
+ to: nextUrl,
698
+ hop: hopCount,
699
+ statusCode: res.statusCode,
700
+ headersStripped: headersStripped,
701
+ method: nextMethod,
702
+ });
703
+ try {
704
+ var hookResult = onRedirect(hookEvent);
705
+ if (hookResult && typeof hookResult.then === "function") {
706
+ return hookResult.then(function () { return _continueFollow(); });
707
+ }
708
+ } catch (e) {
709
+ return Promise.reject(_makeError(opts.errorClass, "REDIRECT_ABORTED",
710
+ "onRedirect hook refused redirect: " + ((e && e.message) || String(e)), true));
711
+ }
712
+ }
713
+ return _continueFollow();
678
714
  });
679
715
  }
680
716
  void originalUrl;
@@ -885,7 +921,7 @@ function _requestH1(transport, u, opts) {
885
921
  }
886
922
 
887
923
  if (responseMode === "stream") {
888
- if (res.statusCode >= 400) {
924
+ if (res.statusCode >= 400 && responseMode !== "always-resolve") {
889
925
  res.resume();
890
926
  return _reject(_makeError(opts.errorClass, "HTTP_ERROR",
891
927
  "HTTP " + res.statusCode + " " + (res.statusMessage || ""),
@@ -936,6 +972,14 @@ function _requestH1(transport, u, opts) {
936
972
  // request() never sets _resolveOnRedirect — operator code that
937
973
  // didn't ask for redirect-following keeps seeing 3xx as errors.
938
974
  _resolve({ statusCode: res.statusCode, headers: res.headers, body: buf });
975
+ } else if (responseMode === "always-resolve") {
976
+ // Operator opted in to "give me the full response object
977
+ // regardless of status." Caller branches on statusCode in
978
+ // their own code path — useful for proxies / forwarders /
979
+ // health-checkers / probe libraries that want to surface
980
+ // the upstream response structurally instead of via an
981
+ // error message string.
982
+ _resolve({ statusCode: res.statusCode, headers: res.headers, body: buf });
939
983
  } else {
940
984
  var msg = "HTTP " + res.statusCode + ": " + buf.toString("utf8").slice(0, 500);
941
985
  _reject(_makeError(opts.errorClass, "HTTP_ERROR", msg,
@@ -1079,7 +1123,7 @@ function _requestH2(transport, u, opts) {
1079
1123
  }
1080
1124
 
1081
1125
  if (responseMode === "stream") {
1082
- if (statusCode >= 400) {
1126
+ if (statusCode >= 400 && responseMode !== "always-resolve") {
1083
1127
  stream.resume();
1084
1128
  return _reject(_makeError(opts.errorClass, "HTTP_ERROR",
1085
1129
  "HTTP " + statusCode, _isPermanentStatus(statusCode), statusCode));
@@ -1110,6 +1154,8 @@ function _requestH2(transport, u, opts) {
1110
1154
  });
1111
1155
  if (statusCode >= 200 && statusCode < 300) {
1112
1156
  _resolve({ statusCode: statusCode, headers: responseHeaders, body: buf });
1157
+ } else if (responseMode === "always-resolve") {
1158
+ _resolve({ statusCode: statusCode, headers: responseHeaders, body: buf });
1113
1159
  } else {
1114
1160
  var msg = "HTTP " + statusCode + ": " + buf.toString("utf8").slice(0, 500);
1115
1161
  _reject(_makeError(opts.errorClass, "HTTP_ERROR", msg,
package/lib/inbox.js CHANGED
@@ -145,6 +145,26 @@ function create(opts) {
145
145
  throw new InboxError("inbox/bad-receive",
146
146
  label + ": source exceeds " + sourceMaxLen + " chars");
147
147
  }
148
+ // Reject NUL + C0 control characters in messageId / source. Both
149
+ // values flow into the (source, message_id) PRIMARY KEY and into
150
+ // audit metadata. Postgres TEXT may reject `\0` mid-statement, OR
151
+ // (depending on driver) silently truncate at the null byte —
152
+ // opening a dedupe-collision attack where "abc\0attacker" and
153
+ // "abc" collide as the same key. Refusing at the gate also keeps
154
+ // operator audit metadata sane.
155
+ _rejectControlChars(receiveOpts.messageId, label, "messageId");
156
+ _rejectControlChars(receiveOpts.source, label, "source");
157
+ }
158
+
159
+ function _rejectControlChars(value, label, field) {
160
+ for (var i = 0; i < value.length; i += 1) {
161
+ var code = value.charCodeAt(i);
162
+ if (code === 0 || (code < 32 && code !== 9) || code === 127) { // allow:raw-byte-literal — ASCII control codepoints (NUL + C0 + DEL); allow tab
163
+ throw new InboxError("inbox/bad-receive",
164
+ label + ": " + field + " contains control character at index " + i +
165
+ " (codepoint " + code + ")");
166
+ }
167
+ }
148
168
  }
149
169
 
150
170
  async function recordReceive(receiveOpts, txn) {
@@ -175,23 +195,27 @@ function create(opts) {
175
195
  " RETURNING message_id",
176
196
  [receiveOpts.messageId, receiveOpts.source, metaJson]);
177
197
  var fresh = rs && rs.rows && rs.rows.length === 1;
178
- _emitAudit("inbox.received", fresh ? "success" : "duplicate", {
198
+ _emitAudit("inbox.received", "success", {
179
199
  source: receiveOpts.source, messageId: receiveOpts.messageId,
180
200
  fresh: fresh,
181
201
  });
182
202
  return fresh;
183
203
  }
184
204
 
185
- // SQLite path — INSERT OR IGNORE + check changes()
186
- await txn.query(
205
+ // SQLite path — INSERT OR IGNORE ... RETURNING 1 (SQLite 3.35+,
206
+ // March 2021). The previous two-statement INSERT + SELECT
207
+ // changes() pattern raced when callers issued an intervening
208
+ // statement on the same txn handle (e.g. trace logging) — a
209
+ // legitimate use case on the public recordReceive(opts, txn) API
210
+ // that the framework can't prevent. RETURNING 1 collapses both
211
+ // round-trips into one and removes the changes() dependency.
212
+ var sqlInsert = await txn.query(
187
213
  "INSERT OR IGNORE INTO " + table +
188
214
  " (message_id, source, received_at, metadata_json) " +
189
- " VALUES (?, ?, " + nowExpr + ", ?)",
215
+ " VALUES (?, ?, " + nowExpr + ", ?) RETURNING 1",
190
216
  [receiveOpts.messageId, receiveOpts.source, metaJson]);
191
- var changedResult = await txn.query("SELECT changes() AS c");
192
- var changedRow = changedResult.rows && changedResult.rows[0];
193
- var sqlFresh = !!(changedRow && Number(changedRow.c) === 1);
194
- _emitAudit("inbox.received", sqlFresh ? "success" : "duplicate", {
217
+ var sqlFresh = !!(sqlInsert && sqlInsert.rows && sqlInsert.rows.length === 1);
218
+ _emitAudit("inbox.received", "success", {
195
219
  source: receiveOpts.source, messageId: receiveOpts.messageId,
196
220
  fresh: sqlFresh,
197
221
  });
@@ -232,13 +256,13 @@ function create(opts) {
232
256
  });
233
257
  } catch (e) {
234
258
  handlerErr = e;
235
- _emitAudit("inbox.handle_failed", "fail", {
259
+ _emitAudit("inbox.handle_failed", "failure", {
236
260
  source: receiveOpts.source, messageId: receiveOpts.messageId,
237
261
  message: e && e.message || String(e),
238
262
  });
239
263
  throw e;
240
264
  }
241
- _emitAudit("inbox.handled", fresh ? "success" : "duplicate", {
265
+ _emitAudit("inbox.handled", "success", {
242
266
  source: receiveOpts.source, messageId: receiveOpts.messageId,
243
267
  fresh: fresh, elapsedMs: Date.now() - startMs,
244
268
  });
@@ -34,6 +34,7 @@ var tls = require("tls");
34
34
  var C = require("./constants");
35
35
  var { boot } = require("./log");
36
36
  var safeAsync = require("./safe-async");
37
+ var safeBuffer = require("./safe-buffer");
37
38
  var safeUrl = require("./safe-url");
38
39
  var { LogStreamError } = require("./framework-error");
39
40
 
@@ -82,6 +83,13 @@ function _formatRfc5424(record, cfg) {
82
83
  try { body += " " + JSON.stringify(record.meta); }
83
84
  catch (_e) { /* best-effort */ }
84
85
  }
86
+ // Strip CR / LF from MSG content. RFC 5424 §6.4 requires PRINTUSASCII
87
+ // / UTF-8 with no embedded control chars in MSG. Without this, an
88
+ // operator-controlled record.message containing `\n<14>1 2026-...`
89
+ // produces a fake separate-priority record on a SIEM that splits on
90
+ // newlines (rsyslog with omfile does). Replace with U+2424 (SYMBOL
91
+ // FOR NEWLINE) so the operator can still see the intent.
92
+ body = safeBuffer.stripCrlf(String(body), "␤");
85
93
  return "<" + pri + ">1 " + ts + " " + cfg.hostname + " " +
86
94
  cfg.appName + " " + cfg.procId + " " + cfg.msgId + " " +
87
95
  (cfg.structuredData || "-") + " " + body;
package/lib/log-stream.js CHANGED
@@ -153,7 +153,7 @@ function emit(level, message, meta) {
153
153
  .then(function () { return sink.raw.emit(record); })
154
154
  .catch(function (e) {
155
155
  audit().safeEmit({
156
- action: "system.log.sink-failure",
156
+ action: "system.log.sink_failure",
157
157
  outcome: "failure",
158
158
  reason: (e && e.message) || String(e),
159
159
  metadata: { sink: name, level: level },
package/lib/mail.js CHANGED
@@ -69,6 +69,8 @@ var safeBuffer = require("./safe-buffer");
69
69
  var audit = lazyRequire(function () { return require("./audit"); });
70
70
  var httpClient = lazyRequire(function () { return require("./http-client"); });
71
71
  var guardEmail = lazyRequire(function () { return require("./guard-email"); });
72
+ var guardFilename = lazyRequire(function () { return require("./guard-filename"); });
73
+ var fileType = lazyRequire(function () { return require("./file-type"); });
72
74
  var mailDkim = require("./mail-dkim");
73
75
  var mailAuth = require("./mail-auth");
74
76
  var mailBimi = require("./mail-bimi");
@@ -285,6 +287,44 @@ function _validateMessage(message) {
285
287
  throw new MailError("mail/invalid-attachment",
286
288
  "attachments[" + i + "].filename contains forbidden control characters", true);
287
289
  }
290
+ // Filename safety gate — path traversal / null-byte / NTFS ADS /
291
+ // RTLO bidi / Windows-reserved / overlong UTF-8 / shell-exec
292
+ // / double-extension. Without this, an operator forwarding a
293
+ // user-uploaded attachment passes attacker-controlled filenames
294
+ // straight to mail clients (which use the filename for "save
295
+ // as" prompts) where Excel + macOS Finder + Outlook honor the
296
+ // RTLO + reserved-name + Windows-strip semantics.
297
+ if (att.skipFilenameSafety !== true) {
298
+ var fnResult = guardFilename().validate(att.filename, { profile: "strict" });
299
+ if (!fnResult.ok) {
300
+ throw new MailError("mail/invalid-attachment",
301
+ "attachments[" + i + "].filename rejected by guardFilename: " +
302
+ (fnResult.issues && fnResult.issues[0] && fnResult.issues[0].kind || "filename-safety-fail"),
303
+ true);
304
+ }
305
+ }
306
+ // Magic-byte gate — refuse claimed/detected MIME mismatch when
307
+ // both are present. Operator can opt out per-attachment with
308
+ // `skipMagicByteCheck: true` and audited reason (e.g. encrypted
309
+ // payloads where the magic bytes intentionally don't match the
310
+ // claimed type).
311
+ if (att.skipMagicByteCheck !== true && att.contentType &&
312
+ Buffer.isBuffer(att.content)) {
313
+ try {
314
+ var detected = fileType().detect(att.content);
315
+ if (detected && detected.mime &&
316
+ detected.mime.split("/")[0] !==
317
+ att.contentType.split(";")[0].trim().toLowerCase().split("/")[0]) {
318
+ throw new MailError("mail/invalid-attachment",
319
+ "attachments[" + i + "].contentType '" + att.contentType +
320
+ "' disagrees with detected magic-byte MIME '" + detected.mime +
321
+ "' — refusing to send mis-typed attachment", true);
322
+ }
323
+ } catch (e) {
324
+ if (e && e.code === "mail/invalid-attachment") throw e;
325
+ // file-type detection error: drop-silent, treat as no-detection
326
+ }
327
+ }
288
328
  if (att.content === undefined || att.content === null) {
289
329
  throw new MailError("mail/invalid-attachment",
290
330
  "attachments[" + i + "].content is required (Buffer or string)", true);
@@ -67,6 +67,7 @@ function create(opts) {
67
67
  opts = opts || {};
68
68
  validateOpts(opts, [
69
69
  "cookieName", "tokenFrom", "sealed", "vault", "userLoader", "audit",
70
+ "requireFingerprintMatch", "maxAnomalyScore", "scorer",
70
71
  ], "middleware.attachUser");
71
72
  if (typeof opts.userLoader !== "function") {
72
73
  throw new Error("middleware.attachUser: opts.userLoader is required " +
@@ -76,6 +77,17 @@ function create(opts) {
76
77
  var tokenFrom = opts.tokenFrom || "both";
77
78
  var auditOn = opts.audit !== false;
78
79
  var sealed = !!opts.sealed;
80
+ // Fingerprint-drift / IP-UA pin / anomaly-score opts thread through
81
+ // session.verify so the documented session.create({ req,
82
+ // fingerprintFields }) defenses actually engage on every verify
83
+ // through the standard middleware path. Without this they were inert
84
+ // — an operator who set them at session.create only got the signal,
85
+ // not enforcement, when the session was checked through attachUser.
86
+ var verifyOpts = {
87
+ requireFingerprintMatch: opts.requireFingerprintMatch === true,
88
+ maxAnomalyScore: (typeof opts.maxAnomalyScore === "number") ? opts.maxAnomalyScore : null,
89
+ scorer: (typeof opts.scorer === "function") ? opts.scorer : null,
90
+ };
79
91
  if (sealed && (!opts.vault || typeof opts.vault.unseal !== "function")) {
80
92
  throw new Error("middleware.attachUser: opts.sealed requires opts.vault " +
81
93
  "with a .unseal method (typically b.vault)");
@@ -94,14 +106,25 @@ function create(opts) {
94
106
  ? cookieJar.readSealed(req, cookieName)
95
107
  : _readCookie(req.headers && req.headers.cookie, cookieName);
96
108
  }
97
- if (!token && (tokenFrom === "header" || tokenFrom === "both")) {
109
+ if (!token && (tokenFrom === "header" || tokenFrom === "both") &&
110
+ !req._bearerAuthHandled) {
111
+ // bearer-auth (when mounted upstream) sets req._bearerAuthHandled
112
+ // after consuming + verifying the Authorization header. Skipping
113
+ // the header re-read here avoids the duplicate verify and the
114
+ // confusing "session.verify failed" audit row that would land
115
+ // when the bearer token is a JWT or API key, not a session ID.
98
116
  token = _readBearer(req.headers && req.headers.authorization);
99
117
  }
100
118
  if (!token) return next();
101
119
 
102
120
  var verified;
103
121
  try {
104
- verified = await session().verify(token);
122
+ verified = await session().verify(token, {
123
+ req: req,
124
+ requireFingerprintMatch: verifyOpts.requireFingerprintMatch,
125
+ maxAnomalyScore: verifyOpts.maxAnomalyScore,
126
+ scorer: verifyOpts.scorer,
127
+ });
105
128
  } catch (_e) {
106
129
  // session.verify is tolerant — shouldn't normally throw, but if it
107
130
  // does (DB hiccup), don't propagate; treat as "no user" and let
@@ -54,14 +54,28 @@ function _writeUnauthorized(res, scheme, message, realm) {
54
54
  res.end(body);
55
55
  }
56
56
 
57
+ // Three-state extractor: { state: "absent" } when no Authorization
58
+ // header was sent, { state: "malformed" } when one is present but
59
+ // doesn't parse against this middleware's scheme, or { state: "ok",
60
+ // token } on success. The "malformed" case must NOT fall through to
61
+ // downstream auth (cookie-session) — operators relying on bearer-auth
62
+ // expect a 401 when a client deliberately sends `Authorization: ...`
63
+ // even if the value is unparseable.
57
64
  function _extractToken(req, scheme) {
58
65
  var h = req.headers && req.headers.authorization;
59
- if (typeof h !== "string" || h.length === 0) return null;
66
+ if (typeof h !== "string" || h.length === 0) return { state: "absent" };
60
67
  var prefix = scheme + " ";
61
- if (h.length <= prefix.length) return null;
62
- if (h.slice(0, prefix.length).toLowerCase() !== prefix.toLowerCase()) return null;
68
+ if (h.length <= prefix.length) return { state: "malformed" };
69
+ if (h.slice(0, prefix.length).toLowerCase() !== prefix.toLowerCase()) {
70
+ // Authorization header is for a different scheme (Basic, Digest,
71
+ // Negotiate, etc.) — leave the request for the next middleware
72
+ // that handles that scheme. From this middleware's perspective,
73
+ // it's effectively "absent."
74
+ return { state: "absent" };
75
+ }
63
76
  var token = h.slice(prefix.length).trim();
64
- return token.length > 0 ? token : null;
77
+ if (token.length === 0) return { state: "malformed" };
78
+ return { state: "ok", token: token };
65
79
  }
66
80
 
67
81
  function create(opts) {
@@ -80,6 +94,29 @@ function create(opts) {
80
94
  var scheme = opts.scheme || "Bearer";
81
95
  var errorMessage = opts.errorMessage || "Bearer token required.";
82
96
  var realm = opts.realm || null;
97
+ // CRLF-injection defense on operator-supplied realm — without this,
98
+ // a config-fed realm like `api\r\nX-Inject: 1` lands in the
99
+ // WWW-Authenticate response header verbatim. RFC 7235 §2.2 quoted-
100
+ // string excludes CTLs (codepoints < 0x20 and 0x7F) and the literal
101
+ // `"` / `\` characters.
102
+ if (realm !== null) {
103
+ if (typeof realm !== "string") {
104
+ throw new AuthError("auth-bearer/bad-realm",
105
+ "middleware.bearerAuth: realm must be a string");
106
+ }
107
+ for (var ri = 0; ri < realm.length; ri += 1) {
108
+ var rcode = realm.charCodeAt(ri);
109
+ if (rcode < 32 || rcode === 127) { // allow:raw-byte-literal — ASCII control codepoints
110
+ throw new AuthError("auth-bearer/bad-realm",
111
+ "realm contains control character at index " + ri);
112
+ }
113
+ var rchar = realm.charAt(ri);
114
+ if (rchar === '"' || rchar === "\\") {
115
+ throw new AuthError("auth-bearer/bad-realm",
116
+ "realm contains illegal character " + JSON.stringify(rchar) + " at index " + ri);
117
+ }
118
+ }
119
+ }
83
120
  var tokenAttach = opts.tokenAttachKey || "bearerToken";
84
121
  var userAttach = opts.userAttachKey || "user";
85
122
 
@@ -104,12 +141,33 @@ function create(opts) {
104
141
  }
105
142
 
106
143
  return async function bearerAuth(req, res, next) {
107
- var token = _extractToken(req, scheme);
108
- if (!token) {
144
+ var extracted = _extractToken(req, scheme);
145
+ if (extracted.state === "absent") {
109
146
  // No Bearer header — fall through. Cookie-based session middleware
110
147
  // running after this can attach a user via the cookie path.
111
148
  return next();
112
149
  }
150
+ if (extracted.state === "malformed") {
151
+ // Authorization header present but does not parse against this
152
+ // scheme. Refuse with 401 — the request is unambiguously trying
153
+ // to authenticate via bearer, and falling through to cookie-auth
154
+ // would mask the operator's malformed-input bug.
155
+ _emitAudit("auth.bearer.failure", "failure", req, "malformed-authorization");
156
+ _emitObs("auth.bearer.rejected", 1, { reason: "malformed-authorization" });
157
+ if (!res.headersSent) {
158
+ var malformedChallenge = scheme + ' error="invalid_request"' +
159
+ (realm ? ', realm="' + realm + '"' : "");
160
+ var malformedBody = JSON.stringify({ error: errorMessage });
161
+ res.writeHead(401, { // allow:raw-byte-literal — HTTP 401 status
162
+ "Content-Type": "application/json; charset=utf-8",
163
+ "Content-Length": Buffer.byteLength(malformedBody),
164
+ "WWW-Authenticate": malformedChallenge,
165
+ });
166
+ res.end(malformedBody);
167
+ }
168
+ return;
169
+ }
170
+ var token = extracted.token;
113
171
 
114
172
  var user;
115
173
  try {
@@ -143,6 +201,13 @@ function create(opts) {
143
201
 
144
202
  req[tokenAttach] = token;
145
203
  req[userAttach] = user;
204
+ // Signal to attach-user (and any other downstream auth middleware)
205
+ // that this Authorization header has already been consumed and
206
+ // verified — without this flag, attach-user would re-read the
207
+ // header and try to parse it as a session token, producing a
208
+ // confusing "session.verify-tried-and-failed" audit row alongside
209
+ // the successful "auth.bearer.success" we just emitted.
210
+ req._bearerAuthHandled = true;
146
211
  _emitAudit("auth.bearer.success", "success", req, null);
147
212
  _emitObs("auth.bearer.accepted", 1, {});
148
213
  next();
@@ -715,6 +715,19 @@ async function _parseMultipart(req, opts, ctParams) {
715
715
  }
716
716
  return;
717
717
  }
718
+ // Count the per-part header bytes toward totalSize so a
719
+ // burst of small parts can't slip past the request-level
720
+ // cap. Without this, fileCount: 20 + fieldCount: 100
721
+ // gives an attacker ~120 × 16 KiB = ~1.9 MiB of pending
722
+ // header state per request, multiplied across concurrent
723
+ // requests.
724
+ totalRead += headEnd + 4;
725
+ if (totalRead > totalSize) {
726
+ done(new BodyParserError("body-parser/multipart-too-large",
727
+ "multipart total request size exceeds totalSize (" + totalSize + ")",
728
+ true, HTTP_STATUS.PAYLOAD_TOO_LARGE));
729
+ return;
730
+ }
718
731
  currentHeaders = _parseMultipartHeaders(pending.slice(0, headEnd).toString("utf8"));
719
732
  pending = pending.slice(headEnd + 4);
720
733
  // Decode Content-Disposition.
@@ -241,6 +241,16 @@ function create(opts) {
241
241
 
242
242
  var matched = _matchOrigin(origin, origins);
243
243
  if (!matched) {
244
+ // Always append Vary: Origin when the request carried an Origin
245
+ // header — otherwise downstream caches that previously cached a
246
+ // matched-origin response (with ACAO + Vary: Origin set) may
247
+ // serve the wrong cached entry to this unmatched-origin
248
+ // request, OR cache the no-CORS response and replay it for a
249
+ // future matched-origin request. Cheap; matches Fetch-spec
250
+ // discipline.
251
+ if (typeof res.setHeader === "function") {
252
+ try { requestHelpers.appendVary(res, "Origin"); } catch (_e) { /* best-effort */ }
253
+ }
244
254
  if (refuseUnknown) {
245
255
  try {
246
256
  audit().emit({
@@ -165,7 +165,7 @@ function _appendSetCookie(res, value) {
165
165
  // throws on file:// / data:// schemes which would crash the middleware
166
166
  // instead of refusing the request. URL constructor + try/catch is the
167
167
  // right shape for "is this URL well-formed and what's its origin?".
168
- function _checkOriginAllowed(req, allowedOrigins, isHttpsFn) {
168
+ function _checkOriginAllowed(req, allowedOrigins, isHttpsFn, requireOrigin) {
169
169
  var headers = req.headers || {};
170
170
  var origin = headers.origin;
171
171
  var referer = headers.referer;
@@ -175,6 +175,9 @@ function _checkOriginAllowed(req, allowedOrigins, isHttpsFn) {
175
175
  // gate doesn't add to it. Defense-in-depth against a stolen
176
176
  // cookie via a browser-rendered cross-origin fetch IS the value;
177
177
  // headless clients carry their own auth threat model.
178
+ if (requireOrigin === true) {
179
+ return { allowed: false, reason: "missing-origin-and-referer" };
180
+ }
178
181
  return null;
179
182
  }
180
183
 
@@ -229,6 +232,7 @@ function create(opts) {
229
232
  validateOpts(opts, [
230
233
  "cookie", "tokenLookup", "fieldName", "headerName", "methods", "audit",
231
234
  "trustProxy", "checkOrigin", "allowedOrigins", "requireJsonContentType",
235
+ "requireOrigin",
232
236
  ], "middleware.csrfProtect");
233
237
  var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
234
238
  ? opts.trustProxy : false;
@@ -277,6 +281,14 @@ function create(opts) {
277
281
  // SPA + classic form pages) leave this opt-out (default).
278
282
  var requireJsonCt = opts.requireJsonContentType === true;
279
283
 
284
+ // requireOrigin — when true, refuse state-changing requests that
285
+ // carry NO Origin/Referer at all. Default false (back-compat for
286
+ // server-to-server / curl callers). Operators on a browser-only
287
+ // route mount the middleware with `requireOrigin: true` so the
288
+ // documented "no headers = bypass for non-browser" pass-through
289
+ // is opt-in rather than silent.
290
+ var requireOriginOpt = opts.requireOrigin === true;
291
+
280
292
  // Cookie issuance config (only when opts.cookie is set).
281
293
  var cookieCfg = null;
282
294
  if (hasCookie) {
@@ -341,10 +353,29 @@ function create(opts) {
341
353
  var cookieName = _resolveCookieName(req);
342
354
  var cookies = _parseCookieHeader(req.headers && req.headers.cookie);
343
355
  var existing = cookies[cookieName];
344
- if (existing && /^[a-f0-9]{2,}$/.test(existing)) {
356
+ // Strict 64-hex-char check matches the byte-length of every token
357
+ // forms.generateCsrfToken() produces (CSRF_TOKEN_BYTES = 32 bytes
358
+ // → 64 hex chars). The previous {2,} floor accepted any 2-char
359
+ // hex string a sibling-subdomain XSS could plant on plain HTTP
360
+ // (cookie name falls back to `csrf` when the request isn't HTTPS,
361
+ // so the `__Host-` prefix safety doesn't apply). Attacker plants
362
+ // `csrf=ab` then submits matching X-CSRF-Token to bypass the
363
+ // double-submit gate.
364
+ if (existing && /^[a-f0-9]{64}$/.test(existing)) {
345
365
  req.csrfToken = existing;
346
366
  return existing;
347
367
  }
368
+ if (existing && !/^[a-f0-9]{64}$/.test(existing)) {
369
+ // Audit-emit so operators see when a planted/short cookie is
370
+ // refused — surfaces the attack class in compliance logs.
371
+ try {
372
+ audit().safeEmit({
373
+ action: "csrf.bad_cookie_value",
374
+ outcome: "denied",
375
+ metadata: { cookieName: cookieName, length: existing.length },
376
+ });
377
+ } catch (_e) { /* drop-silent */ }
378
+ }
348
379
  var fresh = forms.generateCsrfToken();
349
380
  var setCookie = _formatSetCookie(cookieName, fresh, {
350
381
  path: cookieCfg.path,
@@ -381,7 +412,7 @@ function create(opts) {
381
412
  // requests even when the token is valid (e.g. operator-mistaken
382
413
  // CORS configuration that exposes the cookie).
383
414
  if (checkOrigin) {
384
- var originReason = _checkOriginAllowed(req, allowedOrigins, _isHttps);
415
+ var originReason = _checkOriginAllowed(req, allowedOrigins, _isHttps, requireOriginOpt);
385
416
  if (originReason !== null) {
386
417
  _emitDenied(req, "origin/referer: " + originReason);
387
418
  return _writeReject(res, "CSRF cross-origin request refused.");
@@ -215,7 +215,7 @@ function create(opts) {
215
215
  audit().safeEmit({
216
216
  action: "auth.bearer.failure",
217
217
  actor: { clientIp: requestHelpers.clientIp(req) },
218
- outcome: "fail",
218
+ outcome: "failure",
219
219
  metadata: {
220
220
  method: "dpop",
221
221
  reason: (e && e.code) || "verify-failed",
@@ -245,7 +245,7 @@ function create(opts) {
245
245
  audit().safeEmit({
246
246
  action: "auth.bearer.failure",
247
247
  actor: { clientIp: requestHelpers.clientIp(req) },
248
- outcome: "fail",
248
+ outcome: "failure",
249
249
  metadata: { method: "dpop", reason: "stale-nonce", route: req.url },
250
250
  });
251
251
  } catch (_ignored) { /* drop-silent */ }
@@ -268,7 +268,7 @@ function create(opts) {
268
268
  audit().safeEmit({
269
269
  action: "auth.bearer.success",
270
270
  actor: { clientIp: requestHelpers.clientIp(req) },
271
- outcome: "ok",
271
+ outcome: "success",
272
272
  metadata: { method: "dpop", jkt: result.jkt, route: req.url },
273
273
  });
274
274
  } catch (_ignored) { /* drop-silent */ }
@@ -142,7 +142,7 @@ function create(opts) {
142
142
  try {
143
143
  audit().safeEmit({
144
144
  action: "network.host_allowlist.denied",
145
- outcome: "fail",
145
+ outcome: "denied",
146
146
  actor: { clientIp: requestHelpers.clientIp(req) },
147
147
  metadata: {
148
148
  reason: reason,
@@ -76,7 +76,7 @@ function create(opts) {
76
76
  audit().safeEmit({
77
77
  action: "auth.aal.denied",
78
78
  actor: { clientIp: requestHelpers.clientIp(req), userId: req.user && req.user.id },
79
- outcome: "fail",
79
+ outcome: "denied",
80
80
  metadata: {
81
81
  required: minimum,
82
82
  actual: actual || null,
@@ -93,7 +93,7 @@ function create(opts) {
93
93
  audit().safeEmit({
94
94
  action: "auth.aal.granted",
95
95
  actor: { clientIp: requestHelpers.clientIp(req), userId: req.user && req.user.id },
96
- outcome: "ok",
96
+ outcome: "success",
97
97
  metadata: { aal: actual, required: minimum, route: req.url },
98
98
  });
99
99
  } catch (_ignored) { /* drop-silent */ }
@@ -85,7 +85,7 @@ function create(opts) {
85
85
  try {
86
86
  audit().safeEmit({
87
87
  action: "system.trace.synthesised",
88
- outcome: "ok",
88
+ outcome: "success",
89
89
  metadata: { route: req.url || "/", traceId: req.trace.traceId },
90
90
  });
91
91
  } catch (_e) { /* drop-silent — observability sink */ }