@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.
- package/CHANGELOG.md +4 -0
- package/index.js +5 -1
- package/lib/auth/jar.js +190 -28
- package/lib/auth/jwt-external.js +213 -0
- package/lib/auth/oauth.js +115 -101
- package/lib/auth/oid4vci.js +124 -5
- package/lib/auth/oid4vp.js +14 -4
- package/lib/break-glass.js +1 -2
- package/lib/config.js +28 -31
- package/lib/dora.js +8 -5
- package/lib/dsr.js +2 -2
- package/lib/flag-evaluation-context.js +7 -0
- package/lib/guard-html-wcag-aria.js +4 -2
- package/lib/guard-html-wcag-forms.js +4 -2
- package/lib/guard-html-wcag-tables.js +4 -2
- package/lib/guard-html-wcag-tagwalk.js +20 -0
- package/lib/guard-html-wcag.js +1 -1
- package/lib/honeytoken.js +27 -20
- package/lib/http-client.js +3 -4
- package/lib/lro.js +3 -4
- package/lib/mail-deploy.js +1 -1
- package/lib/mail-send-deliver.js +13 -4
- package/lib/middleware/api-encrypt.js +140 -13
- package/lib/middleware/asyncapi-serve.js +3 -0
- package/lib/middleware/csp-report.js +13 -9
- package/lib/middleware/deny-response.js +2 -10
- package/lib/middleware/health.js +1 -4
- package/lib/middleware/openapi-serve.js +3 -0
- package/lib/middleware/scim-server.js +297 -19
- package/lib/middleware/security-txt.js +1 -2
- package/lib/middleware/trace-log-correlation.js +4 -8
- package/lib/network-smtp-policy.js +4 -4
- package/lib/object-store/sigv4-bucket-ops.js +11 -2
- package/lib/observability-tracer.js +1 -1
- package/lib/problem-details.js +56 -11
- package/lib/pubsub-cluster.js +16 -3
- package/lib/queue-sqs.js +20 -2
- package/lib/redis-client.js +32 -4
- package/lib/safe-redirect.js +16 -2
- package/lib/validate-opts.js +34 -0
- package/package.json +1 -1
- 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 (
|
|
252
|
-
* best-effort
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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",
|
|
283
|
-
"verificationLevel",
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
};
|
package/lib/guard-html-wcag.js
CHANGED
|
@@ -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", "
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
package/lib/http-client.js
CHANGED
|
@@ -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)
|
|
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
|
|
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) === "_")
|
|
192
|
-
out[keys[i]] = op[keys[i]];
|
|
191
|
+
if (keys[i].charAt(0) === "_") priv.push(keys[i]);
|
|
193
192
|
}
|
|
194
|
-
return
|
|
193
|
+
return validateOpts.assignOwnEnumerable({}, op, priv);
|
|
195
194
|
}
|
|
196
195
|
|
|
197
196
|
module.exports = {
|
package/lib/mail-deploy.js
CHANGED
|
@@ -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"
|
|
922
|
+
"audit"],
|
|
923
923
|
"mail.deploy.tlsRptIngestHttp");
|
|
924
924
|
validateOpts.optionalFunction(opts.authenticate, "tlsRptIngestHttp: opts.authenticate",
|
|
925
925
|
MailDeployError, "mail-tlsrpt/bad-opts");
|
package/lib/mail-send-deliver.js
CHANGED
|
@@ -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
|
-
|
|
472
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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 =
|
|
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") {
|