@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.
- package/CHANGELOG.md +24 -0
- package/lib/audit-sign.js +1 -1
- package/lib/audit.js +62 -2
- package/lib/auth/jwt.js +13 -0
- package/lib/auth/lockout.js +16 -3
- package/lib/auth/oauth.js +15 -1
- package/lib/auth/password.js +22 -2
- package/lib/auth/sd-jwt-vc-issuer.js +2 -2
- package/lib/auth/sd-jwt-vc.js +7 -2
- package/lib/break-glass.js +53 -14
- package/lib/cache-redis.js +1 -1
- package/lib/cache.js +6 -1
- package/lib/cli.js +3 -3
- package/lib/cluster.js +24 -1
- package/lib/compliance-ai-act-logging.js +7 -3
- package/lib/compliance.js +10 -2
- package/lib/config-drift.js +2 -2
- package/lib/crypto-field.js +21 -1
- package/lib/crypto.js +82 -1
- package/lib/db.js +35 -4
- package/lib/dev.js +30 -3
- package/lib/dual-control.js +19 -1
- package/lib/external-db.js +10 -0
- package/lib/file-upload.js +30 -3
- package/lib/flag.js +1 -1
- package/lib/guard-all.js +33 -16
- package/lib/guard-csv.js +16 -2
- package/lib/guard-html.js +35 -0
- package/lib/guard-svg.js +20 -0
- package/lib/http-client.js +57 -11
- package/lib/inbox.js +34 -10
- package/lib/log-stream-syslog.js +8 -0
- package/lib/log-stream.js +1 -1
- package/lib/mail.js +40 -0
- package/lib/middleware/attach-user.js +25 -2
- package/lib/middleware/bearer-auth.js +71 -6
- package/lib/middleware/body-parser.js +13 -0
- package/lib/middleware/cors.js +10 -0
- package/lib/middleware/csrf-protect.js +34 -3
- package/lib/middleware/dpop.js +3 -3
- package/lib/middleware/host-allowlist.js +1 -1
- package/lib/middleware/require-aal.js +2 -2
- package/lib/middleware/trace-propagate.js +1 -1
- package/lib/mtls-ca.js +23 -29
- package/lib/mtls-engine-default.js +21 -1
- package/lib/network-tls.js +21 -6
- package/lib/object-store/sigv4-bucket-ops.js +41 -0
- package/lib/observability-otlp-exporter.js +35 -2
- package/lib/outbox.js +3 -3
- package/lib/permissions.js +10 -1
- package/lib/pqc-agent.js +22 -1
- package/lib/pubsub.js +8 -4
- package/lib/redact.js +26 -1
- package/lib/retention.js +26 -0
- package/lib/router.js +1 -0
- package/lib/scheduler.js +57 -1
- package/lib/session.js +3 -3
- package/lib/ssrf-guard.js +19 -4
- package/lib/static.js +12 -0
- package/lib/totp.js +16 -0
- package/lib/ws-client.js +158 -9
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/lib/http-client.js
CHANGED
|
@@ -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
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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",
|
|
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
|
|
186
|
-
|
|
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
|
|
192
|
-
|
|
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", "
|
|
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",
|
|
265
|
+
_emitAudit("inbox.handled", "success", {
|
|
242
266
|
source: receiveOpts.source, messageId: receiveOpts.messageId,
|
|
243
267
|
fresh: fresh, elapsedMs: Date.now() - startMs,
|
|
244
268
|
});
|
package/lib/log-stream-syslog.js
CHANGED
|
@@ -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.
|
|
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
|
|
66
|
+
if (typeof h !== "string" || h.length === 0) return { state: "absent" };
|
|
60
67
|
var prefix = scheme + " ";
|
|
61
|
-
if (h.length <= prefix.length) return
|
|
62
|
-
if (h.slice(0, prefix.length).toLowerCase() !== prefix.toLowerCase())
|
|
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
|
-
|
|
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
|
|
108
|
-
if (
|
|
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.
|
package/lib/middleware/cors.js
CHANGED
|
@@ -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
|
-
|
|
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.");
|
package/lib/middleware/dpop.js
CHANGED
|
@@ -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: "
|
|
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: "
|
|
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: "
|
|
271
|
+
outcome: "success",
|
|
272
272
|
metadata: { method: "dpop", jkt: result.jkt, route: req.url },
|
|
273
273
|
});
|
|
274
274
|
} catch (_ignored) { /* drop-silent */ }
|
|
@@ -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: "
|
|
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: "
|
|
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: "
|
|
88
|
+
outcome: "success",
|
|
89
89
|
metadata: { route: req.url || "/", traceId: req.trace.traceId },
|
|
90
90
|
});
|
|
91
91
|
} catch (_e) { /* drop-silent — observability sink */ }
|