@blamejs/blamejs-shop 0.2.30 → 0.3.0
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 +6 -0
- package/lib/admin.js +56 -1
- package/lib/asset-manifest.json +7 -7
- package/lib/cart-recovery-pass.js +482 -0
- package/lib/index.js +1 -0
- package/lib/security-middleware.js +46 -0
- package/lib/storefront.js +82 -2
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +4 -0
- package/lib/vendor/blamejs/README.md +9 -12
- package/lib/vendor/blamejs/SECURITY.md +1 -0
- package/lib/vendor/blamejs/api-snapshot.json +6 -2
- package/lib/vendor/blamejs/examples/wiki/lib/build-app.js +19 -13
- package/lib/vendor/blamejs/lib/app.js +57 -7
- package/lib/vendor/blamejs/lib/audit.js +1 -0
- package/lib/vendor/blamejs/lib/cert.js +71 -9
- package/lib/vendor/blamejs/lib/middleware/cookies.js +4 -0
- package/lib/vendor/blamejs/lib/middleware/csp-nonce.js +4 -0
- package/lib/vendor/blamejs/lib/middleware/csrf-protect.js +29 -1
- package/lib/vendor/blamejs/lib/middleware/fetch-metadata.js +3 -0
- package/lib/vendor/blamejs/lib/network-tls.js +81 -5
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.13.45.json +31 -0
- package/lib/vendor/blamejs/release-notes/v0.13.46.json +35 -0
- package/lib/vendor/blamejs/test/50-integration.js +202 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cert.test.js +44 -0
- package/package.json +1 -1
|
@@ -89,6 +89,10 @@ function create(opts) {
|
|
|
89
89
|
var audit = opts.audit || null;
|
|
90
90
|
|
|
91
91
|
return function cookiesMiddleware(req, res, next) {
|
|
92
|
+
// Idempotent: if an earlier cookies middleware already parsed the jar
|
|
93
|
+
// this request (e.g. createApp wired it AND an operator mounted it
|
|
94
|
+
// again), don't re-parse — keep the first jar.
|
|
95
|
+
if (req.cookieJar !== undefined) return next();
|
|
92
96
|
var header = req && req.headers ? req.headers.cookie : "";
|
|
93
97
|
var rv = cookies.parseSafe(header || "", {
|
|
94
98
|
maxHeaderBytes: maxHeaderBytes,
|
|
@@ -333,6 +333,10 @@ function create(opts) {
|
|
|
333
333
|
}
|
|
334
334
|
|
|
335
335
|
function cspNonce(req, res, next) {
|
|
336
|
+
// Idempotent: if an earlier cspNonce middleware already set a nonce on
|
|
337
|
+
// this request, keep it — re-generating would desync the header nonce
|
|
338
|
+
// from the one templates already rendered against.
|
|
339
|
+
if (req[property] !== undefined) return next();
|
|
336
340
|
// Generate the nonce. Cheap (16 bytes from getrandom → SHAKE256 →
|
|
337
341
|
// base64 encode); do it always for consistency unless `always:
|
|
338
342
|
// false` was set explicitly.
|
|
@@ -261,6 +261,7 @@ function _writeReject(res, message) {
|
|
|
261
261
|
* requireJsonContentType: boolean,
|
|
262
262
|
* trustProxy: boolean|number,
|
|
263
263
|
* audit: boolean,
|
|
264
|
+
* skipStateless: boolean, // default false — skip validation for Authorization-header / cookieless (not-CSRF-able) requests
|
|
264
265
|
* }
|
|
265
266
|
*
|
|
266
267
|
* @example
|
|
@@ -278,7 +279,7 @@ function create(opts) {
|
|
|
278
279
|
validateOpts(opts, [
|
|
279
280
|
"cookie", "tokenLookup", "fieldName", "headerName", "methods", "audit",
|
|
280
281
|
"trustProxy", "checkOrigin", "allowedOrigins", "requireJsonContentType",
|
|
281
|
-
"requireOrigin",
|
|
282
|
+
"requireOrigin", "skipStateless",
|
|
282
283
|
], "middleware.csrfProtect");
|
|
283
284
|
var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
|
|
284
285
|
? opts.trustProxy : false;
|
|
@@ -335,6 +336,19 @@ function create(opts) {
|
|
|
335
336
|
// is opt-in rather than silent.
|
|
336
337
|
var requireOriginOpt = opts.requireOrigin === true;
|
|
337
338
|
|
|
339
|
+
// skipStateless — skip token VALIDATION for requests that carry an
|
|
340
|
+
// Authorization header (bearer / token auth) or no Cookie header at
|
|
341
|
+
// all. Such requests are not CSRF-able: CSRF abuses a victim's ambient
|
|
342
|
+
// cookie credential, and a token-authenticated or cookieless request
|
|
343
|
+
// has none to abuse. The token is still ISSUED on safe methods so a
|
|
344
|
+
// later cookie-authenticated browser flow on the same app works. Default
|
|
345
|
+
// false (strict — every state-changing request is validated). createApp
|
|
346
|
+
// wires its default csrf with this on so mixed browser-form + token-API
|
|
347
|
+
// surfaces don't reject legitimate API clients. Cross-site form CSRF is
|
|
348
|
+
// unaffected: the browser auto-sends the victim's cookies, so the attack
|
|
349
|
+
// request always carries a Cookie header and is validated.
|
|
350
|
+
var skipStateless = opts.skipStateless === true;
|
|
351
|
+
|
|
338
352
|
// Cookie issuance config (only when opts.cookie is set).
|
|
339
353
|
var cookieCfg = null;
|
|
340
354
|
if (hasCookie) {
|
|
@@ -436,6 +450,12 @@ function create(opts) {
|
|
|
436
450
|
}
|
|
437
451
|
|
|
438
452
|
return function csrfProtect(req, res, next) {
|
|
453
|
+
// Idempotent: a second csrf mount this request (e.g. createApp wired
|
|
454
|
+
// it AND an operator mounted it again) is a no-op — the first instance
|
|
455
|
+
// already issued + validated.
|
|
456
|
+
if (req._csrfApplied) return next();
|
|
457
|
+
req._csrfApplied = true;
|
|
458
|
+
|
|
439
459
|
// Issue/refresh the token on EVERY request (safe + state-changing)
|
|
440
460
|
// when running in cookie mode — templates rendered after a POST
|
|
441
461
|
// (e.g. error response) still need req.csrfToken populated.
|
|
@@ -443,6 +463,14 @@ function create(opts) {
|
|
|
443
463
|
|
|
444
464
|
if (methods.indexOf(req.method) === -1) return next();
|
|
445
465
|
|
|
466
|
+
// Stateless / token-authenticated requests are not CSRF-able — the
|
|
467
|
+
// token was still issued above for any later browser flow.
|
|
468
|
+
if (skipStateless) {
|
|
469
|
+
var hasAuthHeader = !!(req.headers && req.headers.authorization);
|
|
470
|
+
var hasCookieHeader = !!(req.headers && req.headers.cookie);
|
|
471
|
+
if (hasAuthHeader || !hasCookieHeader) return next();
|
|
472
|
+
}
|
|
473
|
+
|
|
446
474
|
// requireJsonContentType — refuse before the token check.
|
|
447
475
|
if (requireJsonCt) {
|
|
448
476
|
var ct = req.headers && req.headers["content-type"];
|
|
@@ -120,6 +120,9 @@ function create(opts) {
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
return function fetchMetadata(req, res, next) {
|
|
123
|
+
// Idempotent: a second fetch-metadata mount this request is a no-op.
|
|
124
|
+
if (req._fetchMetadataChecked) return next();
|
|
125
|
+
req._fetchMetadataChecked = true;
|
|
123
126
|
if (methods.indexOf(req.method) === -1) return next();
|
|
124
127
|
|
|
125
128
|
var headers = req.headers || {};
|
|
@@ -20,6 +20,7 @@ var NetworkTlsError = defineClass("NetworkTlsError", { alwaysPermanent: true });
|
|
|
20
20
|
var observability = lazyRequire(function () { return require("./observability"); });
|
|
21
21
|
var audit = lazyRequire(function () { return require("./audit"); });
|
|
22
22
|
var networkDns = lazyRequire(function () { return require("./network-dns"); });
|
|
23
|
+
var httpClient = lazyRequire(function () { return require("./http-client"); });
|
|
23
24
|
var asn1 = require("./asn1-der");
|
|
24
25
|
|
|
25
26
|
// STATE.tlsKeyShares is initialized to the default PQC group list at
|
|
@@ -1194,9 +1195,10 @@ function evaluateOcspResponse(ocspDer, opts) {
|
|
|
1194
1195
|
//
|
|
1195
1196
|
// Constructs a DER-encoded OCSPRequest for a single (leafCertDer,
|
|
1196
1197
|
// issuerCertDer) pair, optionally with an RFC 8954 nonce extension.
|
|
1197
|
-
//
|
|
1198
|
-
//
|
|
1199
|
-
//
|
|
1198
|
+
// `ocsp.fetch` composes this with `b.httpClient` to POST the request to
|
|
1199
|
+
// the cert's responder and return a validated response; operators who
|
|
1200
|
+
// need the raw request (custom transport, batched requests) call this
|
|
1201
|
+
// directly and pass `nonce` to `ocsp.evaluate(responseDer, { expectedNonce })`
|
|
1200
1202
|
// to defend against replay attacks.
|
|
1201
1203
|
//
|
|
1202
1204
|
// Nonce DEFAULT ON — defense in depth. RFC 6960 §4.4.1 marks nonce
|
|
@@ -1291,8 +1293,10 @@ function buildOcspRequest(opts) {
|
|
|
1291
1293
|
// framework that touches SHA-1" need a signal. Emit an audit row
|
|
1292
1294
|
// on every OCSP request build so the algorithm choice is visible
|
|
1293
1295
|
// in the chain.
|
|
1294
|
-
|
|
1295
|
-
var
|
|
1296
|
+
// lgtm[js/weak-cryptographic-algorithm] — RFC 6960 §4.1.1 CertID lookup hash over the PUBLIC issuer name; a name/key lookup, not an integrity or secrecy operation. SHA-256 CertIDs are §4.3-optional and rejected by most responders.
|
|
1297
|
+
var nameHash = nodeCrypto.createHash("sha1").update(iss.issuerNameDer).digest(); // lgtm[js/weak-cryptographic-algorithm]
|
|
1298
|
+
// lgtm[js/weak-cryptographic-algorithm] — RFC 6960 §4.1.1 CertID lookup hash over the PUBLIC issuer key; a name/key lookup, not an integrity or secrecy operation.
|
|
1299
|
+
var keyHash = nodeCrypto.createHash("sha1").update(iss.issuerKey).digest(); // lgtm[js/weak-cryptographic-algorithm]
|
|
1296
1300
|
setImmediate(function () {
|
|
1297
1301
|
try {
|
|
1298
1302
|
var auditMod = require("./audit"); // allow:inline-require — circular-load defense (audit imports network-tls)
|
|
@@ -1339,6 +1343,77 @@ function buildOcspRequest(opts) {
|
|
|
1339
1343
|
return { requestDer: requestDer, nonce: nonceBytes };
|
|
1340
1344
|
}
|
|
1341
1345
|
|
|
1346
|
+
// _ocspResponderUrl — pull the OCSP responder URL out of a cert's
|
|
1347
|
+
// Authority Information Access extension. node:crypto exposes it as a
|
|
1348
|
+
// multi-line string ("OCSP - URI:http://...\nCA Issuers - URI:...\n").
|
|
1349
|
+
function _ocspResponderUrl(x509) {
|
|
1350
|
+
var ia = x509 && x509.infoAccess;
|
|
1351
|
+
if (typeof ia !== "string") return null;
|
|
1352
|
+
var m = ia.match(/OCSP\s*-\s*URI:(\S+)/i);
|
|
1353
|
+
return m ? m[1].trim() : null;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// fetch — POST a freshly-built OCSPRequest to the cert's responder and
|
|
1357
|
+
// return the validated, known-good response bytes. Composes buildRequest +
|
|
1358
|
+
// b.httpClient + evaluate, completing the server-side-stapling fetch path
|
|
1359
|
+
// (the response is what a TLS server staples via its 'OCSPRequest' handler).
|
|
1360
|
+
// The responder URL is taken from the leaf cert's AIA extension unless
|
|
1361
|
+
// opts.responderUrl overrides it. Throws TlsTrustError on any failure
|
|
1362
|
+
// (no responder, transport error, non-good certStatus, signature mismatch);
|
|
1363
|
+
// callers that staple should treat a throw as "no staple this cycle".
|
|
1364
|
+
async function fetchOcspResponse(opts) {
|
|
1365
|
+
opts = opts || {};
|
|
1366
|
+
if (typeof opts.leafPem !== "string" || typeof opts.issuerPem !== "string") {
|
|
1367
|
+
throw new TlsTrustError("tls/ocsp-bad-input",
|
|
1368
|
+
"ocsp.fetch: opts.leafPem and opts.issuerPem (PEM strings) are required");
|
|
1369
|
+
}
|
|
1370
|
+
var leafX, issuerX;
|
|
1371
|
+
try {
|
|
1372
|
+
leafX = new nodeCrypto.X509Certificate(opts.leafPem);
|
|
1373
|
+
issuerX = new nodeCrypto.X509Certificate(opts.issuerPem);
|
|
1374
|
+
} catch (e) {
|
|
1375
|
+
throw new TlsTrustError("tls/ocsp-bad-cert",
|
|
1376
|
+
"ocsp.fetch: could not parse leaf/issuer PEM: " + ((e && e.message) || String(e)));
|
|
1377
|
+
}
|
|
1378
|
+
var responderUrl = opts.responderUrl || _ocspResponderUrl(leafX);
|
|
1379
|
+
if (!responderUrl) {
|
|
1380
|
+
throw new TlsTrustError("tls/ocsp-no-responder",
|
|
1381
|
+
"ocsp.fetch: cert has no AIA OCSP responder URL; pass opts.responderUrl");
|
|
1382
|
+
}
|
|
1383
|
+
var built = buildOcspRequest({
|
|
1384
|
+
leafCertDer: leafX.raw, issuerCertDer: issuerX.raw,
|
|
1385
|
+
nonce: opts.nonce, nonceLen: opts.nonceLen,
|
|
1386
|
+
});
|
|
1387
|
+
var res;
|
|
1388
|
+
try {
|
|
1389
|
+
res = await httpClient().request({
|
|
1390
|
+
url: responderUrl,
|
|
1391
|
+
method: "POST",
|
|
1392
|
+
headers: { "content-type": "application/ocsp-request", "accept": "application/ocsp-response" },
|
|
1393
|
+
body: built.requestDer,
|
|
1394
|
+
responseMode: "buffer",
|
|
1395
|
+
timeoutMs: opts.timeoutMs || C.TIME.seconds(10),
|
|
1396
|
+
});
|
|
1397
|
+
} catch (e) {
|
|
1398
|
+
throw new TlsTrustError("tls/ocsp-fetch-failed",
|
|
1399
|
+
"ocsp.fetch: responder request to " + responderUrl + " failed: " + ((e && e.message) || String(e)));
|
|
1400
|
+
}
|
|
1401
|
+
if (res.status !== 200 || !Buffer.isBuffer(res.body) || res.body.length === 0) {
|
|
1402
|
+
throw new TlsTrustError("tls/ocsp-fetch-bad-status",
|
|
1403
|
+
"ocsp.fetch: responder returned status " + res.status + " with an empty/non-buffer body");
|
|
1404
|
+
}
|
|
1405
|
+
var evald = evaluateOcspResponse(res.body, {
|
|
1406
|
+
issuerPem: opts.issuerPem,
|
|
1407
|
+
serialHex: opts.serialHex || null,
|
|
1408
|
+
expectedNonce: opts.nonce === false ? null : built.nonce,
|
|
1409
|
+
});
|
|
1410
|
+
if (!evald.ok) {
|
|
1411
|
+
throw new TlsTrustError("tls/ocsp-not-good",
|
|
1412
|
+
"ocsp.fetch: response is not good: " + (evald.errors || []).join("; "));
|
|
1413
|
+
}
|
|
1414
|
+
return { ocspDer: res.body, evaluation: evald, responderUrl: responderUrl };
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1342
1417
|
var ocsp = Object.freeze({
|
|
1343
1418
|
// Connect with OCSP requested. Returns { authorized, ocspBytes,
|
|
1344
1419
|
// peerCert }. requireStapled: true makes empty / not-stapled responses
|
|
@@ -1379,6 +1454,7 @@ var ocsp = Object.freeze({
|
|
|
1379
1454
|
},
|
|
1380
1455
|
parseResponse: parseOcspResponse,
|
|
1381
1456
|
evaluate: evaluateOcspResponse,
|
|
1457
|
+
fetch: fetchOcspResponse,
|
|
1382
1458
|
// buildRequest — construct a DER-encoded OCSPRequest for a single
|
|
1383
1459
|
// (leafCertDer, issuerCertDer) pair. RFC 8954 nonce extension is ON
|
|
1384
1460
|
// by default (16 random bytes; opts.nonceLen overrides within RFC
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.13.45",
|
|
4
|
+
"date": "2026-05-29",
|
|
5
|
+
"headline": "`b.cert` now fetches and staples a validated OCSP response per certificate, and validates declared compliance postures at create()",
|
|
6
|
+
"summary": "Two capabilities that b.cert documented but did not act on are now wired through. OCSP stapling: the cert manager fetches the leaf's OCSP response from the responder named in its Authority Information Access extension, validates it against the issuer (status, nonce, serial) via b.network.tls.ocsp, caches the DER, and exposes it on getContext().ocspResponse so a TLS server's OCSPRequest handler can staple it. The fetch runs in the background on a refresh timer and never blocks cert.start() — a slow or unreachable responder produces an audited per-certificate failure, not a stalled boot. Compliance postures: opts.compliance names are now validated against b.compliance.KNOWN_POSTURES at create() (an unknown name throws cert/unknown-compliance-posture instead of being silently recorded) and are surfaced on getContext().compliance for an auditor. Storage-confidentiality postures hold by construction because cert keys and certificates are always sealed at rest. The supporting composition primitive b.network.tls.ocsp.fetch (build request, POST to the responder through b.httpClient, validate the response) is now part of the public OCSP surface.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Added",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "b.network.tls.ocsp.fetch — fetch and validate an OCSP response",
|
|
13
|
+
"body": "The OCSP helper set previously built requests and evaluated responses but had no way to actually retrieve one. b.network.tls.ocsp.fetch({ leafPem, issuerPem, nonce?, timeoutMs? }) reads the responder URL from the leaf certificate's Authority Information Access extension, builds the request, POSTs it through b.httpClient (so the SSRF guard and pinned DNS apply), and validates the response against the issuer — returning the validated DER plus the parsed evaluation. It rejects when the leaf carries no OCSP responder URL or the response fails validation."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"title": "b.cert staples a validated OCSP response per certificate",
|
|
17
|
+
"body": "With ocsp.stapling enabled (the default), the cert manager refreshes each certificate's OCSP response on a timer (ocsp.refreshMs, default 12h) and caches the validated DER. getContext(serverName).ocspResponse returns that DER for a TLS server to hand back from its OCSPRequest handler. The refresh runs in the background and is never on the path of cert.start(): an unreachable or slow responder is recorded as an audited cert.ocsp.refresh failure for that certificate and leaves the rest of the manager running."
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"heading": "Changed",
|
|
23
|
+
"items": [
|
|
24
|
+
{
|
|
25
|
+
"title": "opts.compliance posture names are validated at create()",
|
|
26
|
+
"body": "b.cert.create now checks each name in opts.compliance against b.compliance.KNOWN_POSTURES and throws cert/unknown-compliance-posture on an unrecognized name, so a typo is caught at construction rather than being silently recorded. The declared postures are surfaced on getContext().compliance. Cert keys and certificates are always sealed at rest, so storage-confidentiality postures are satisfied by construction."
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.13.46",
|
|
4
|
+
"date": "2026-05-29",
|
|
5
|
+
"headline": "`createApp` now wires the documented security middleware ON by default — CSRF, CSP nonce, cookie parser, fetch-metadata, and body parser",
|
|
6
|
+
"summary": "The README has long described a security middleware stack as \"wired by createApp\", but createApp only mounted request-ID, security-headers, and bot-guard by default — CSRF protection, the CSP nonce, the threat-aware cookie parser, the fetch-metadata guard, and the body parser were documented but not actually wired. This release closes that gap: createApp now mounts all of them by default, in dependency order (cookies, CSP nonce, fetch-metadata, then body parser, then CSRF last so it can read a body-field token). This is a behavior change — apps built with createApp now enforce CSRF on state-changing requests by default. Each layer is configurable via opts.middleware.<name> (operator cookie and field names flow straight through — nothing is hardcoded) or can be turned off with false, and disabling a security default now emits an app.middleware.disabled audit event. Every layer is idempotent: an operator who also mounts one of these inside opts.routes gets a no-op second mount rather than a double-apply. The default CSRF is a double-submit cookie that auto-skips requests carrying an Authorization header or no cookies at all, which are not CSRF-able, so token-authenticated API clients are not rejected. The README middleware list is now an accurate description of what createApp wires.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Changed",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "createApp wires CSRF, CSP nonce, cookie parser, fetch-metadata, and body parser by default (breaking)",
|
|
13
|
+
"body": "Applications constructed with b.createApp now mount, in order: the threat-aware cookie parser, the CSP nonce generator, the fetch-metadata resource-isolation guard, the body parser (JSON / urlencoded / text / multipart), and CSRF protection — in addition to the request-ID, security-headers, and bot-guard layers already wired. The ordering guarantees CSRF runs after the body parser so a body-field token is available. This is a behavior change: state-changing requests (POST / PUT / DELETE / PATCH) that carry a session cookie are now CSRF-validated by default. Each layer is configured through opts.middleware.<name> (an object passes operator options straight through; cookie and field names are not hardcoded) or disabled with false. Operators who were mounting these middleware themselves inside opts.routes do not need to change anything — the second mount is now a no-op (see idempotency below)."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"title": "Default CSRF auto-skips token-authenticated and cookieless requests",
|
|
17
|
+
"body": "The CSRF middleware gains a skipStateless option (default false; createApp's default wiring sets it true). When on, token validation is skipped for requests that carry an Authorization header or no Cookie header at all — such requests are not CSRF-able, because CSRF abuses a victim's ambient cookie credential and these have none. The token is still issued on safe methods so a later cookie-authenticated browser flow works. Cross-site form CSRF is unaffected: the browser auto-sends the victim's cookies, so an attack request always carries a Cookie header and is validated."
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"title": "Disabling a default security middleware is audited",
|
|
21
|
+
"body": "Passing false for one of the security-on-by-default middleware (for example middleware: { csrf: false }) now emits an app.middleware.disabled audit event naming the middleware, so a weakened posture leaves a trace in the audit chain rather than being silent."
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"heading": "Added",
|
|
27
|
+
"items": [
|
|
28
|
+
{
|
|
29
|
+
"title": "Idempotent security middleware",
|
|
30
|
+
"body": "The cookie parser, CSP nonce, fetch-metadata, and CSRF middleware are now idempotent within a request: if one has already run (because createApp wired it and an operator also mounted it), the second instance is a no-op rather than re-parsing, re-generating a nonce, or issuing a second CSRF cookie. This lets an application compose its own middleware order on top of createApp's defaults without double-applying. The body parser already had this behavior."
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
@@ -287,6 +287,200 @@ async function testCreateAppMiddlewareDisableable() {
|
|
|
287
287
|
}
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
+
// Extract a 64-hex csrf token for `name` from a response's Set-Cookie(s).
|
|
291
|
+
function _csrfCookieFrom(resp, name) {
|
|
292
|
+
var sc = resp.headers["set-cookie"];
|
|
293
|
+
if (!sc) return null;
|
|
294
|
+
var arr = Array.isArray(sc) ? sc : [sc];
|
|
295
|
+
var re = new RegExp("(?:^|;\\s*)" + name + "=([a-f0-9]{64})");
|
|
296
|
+
for (var i = 0; i < arr.length; i++) {
|
|
297
|
+
var m = re.exec(arr[i]);
|
|
298
|
+
if (m) return m[1];
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function testCreateAppSecurityDefaultsWired() {
|
|
304
|
+
// createApp wires cookies + cspNonce + fetchMetadata + bodyParser + csrf
|
|
305
|
+
// ON by default (Core Rule §3). Verify cspNonce patches the CSP, csrf
|
|
306
|
+
// issues a double-submit cookie + enforces it, and the stateless-skip
|
|
307
|
+
// lets token-API / cookieless callers through.
|
|
308
|
+
process.env.BLAMEJS_SKIP_NTP_CHECK = "1";
|
|
309
|
+
process.env.BLAMEJS_AUDIT_SIGNING_MODE = "plaintext";
|
|
310
|
+
b.cluster._resetForTest();
|
|
311
|
+
b.audit._resetForTest();
|
|
312
|
+
b.vault._resetForTest();
|
|
313
|
+
b.db._resetForTest();
|
|
314
|
+
var dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-app-sec-"));
|
|
315
|
+
var app = await b.createApp({
|
|
316
|
+
dataDir: dataDir,
|
|
317
|
+
vault: { mode: "plaintext" },
|
|
318
|
+
db: { atRest: "plain", auditSigning: { mode: "plaintext" } },
|
|
319
|
+
schema: [],
|
|
320
|
+
middleware: { botGuard: false }, // bot-guard refuses non-browser-y test clients
|
|
321
|
+
routes: function (r) {
|
|
322
|
+
r.get("/", function (req, res) { b.render.text(res, "OK"); });
|
|
323
|
+
r.post("/act", function (req, res) { b.render.text(res, "DID"); });
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
var addr = await app.listen({ port: 0, host: "127.0.0.1" });
|
|
327
|
+
try {
|
|
328
|
+
var g = await _appGet(addr.port, "/");
|
|
329
|
+
check("security defaults: cspNonce patched the CSP header",
|
|
330
|
+
typeof g.headers["content-security-policy"] === "string" &&
|
|
331
|
+
g.headers["content-security-policy"].indexOf("nonce-") !== -1);
|
|
332
|
+
var token = _csrfCookieFrom(g, "csrf");
|
|
333
|
+
check("security defaults: csrf double-submit cookie issued",
|
|
334
|
+
typeof token === "string" && token.length === 64);
|
|
335
|
+
|
|
336
|
+
// Cookie-bearing POST without a token → enforced (403).
|
|
337
|
+
var noTok = await b.httpClient.request({
|
|
338
|
+
method: "POST", url: "http://127.0.0.1:" + addr.port + "/act",
|
|
339
|
+
headers: { "Cookie": "csrf=" + token }, body: Buffer.from(""),
|
|
340
|
+
responseMode: "always-resolve",
|
|
341
|
+
allowedProtocols: b.safeUrl.ALLOW_HTTP_ALL, allowInternal: true,
|
|
342
|
+
});
|
|
343
|
+
check("security defaults: cookie'd POST without token → 403", noTok.statusCode === 403);
|
|
344
|
+
|
|
345
|
+
// Cookie + matching header token → validates (200).
|
|
346
|
+
var withTok = await b.httpClient.request({
|
|
347
|
+
method: "POST", url: "http://127.0.0.1:" + addr.port + "/act",
|
|
348
|
+
headers: { "Cookie": "csrf=" + token, "X-CSRF-Token": token }, body: Buffer.from(""),
|
|
349
|
+
responseMode: "always-resolve",
|
|
350
|
+
allowedProtocols: b.safeUrl.ALLOW_HTTP_ALL, allowInternal: true,
|
|
351
|
+
});
|
|
352
|
+
check("security defaults: cookie'd POST with valid token → 200", withTok.statusCode === 200);
|
|
353
|
+
|
|
354
|
+
// Cookieless POST → not CSRF-able → skipStateless skip (200).
|
|
355
|
+
var cookieless = await b.httpClient.request({
|
|
356
|
+
method: "POST", url: "http://127.0.0.1:" + addr.port + "/act",
|
|
357
|
+
body: Buffer.from(""), responseMode: "always-resolve",
|
|
358
|
+
allowedProtocols: b.safeUrl.ALLOW_HTTP_ALL, allowInternal: true,
|
|
359
|
+
});
|
|
360
|
+
check("security defaults: cookieless POST skipped → 200", cookieless.statusCode === 200);
|
|
361
|
+
|
|
362
|
+
// Bearer-authenticated POST (even WITH a cookie) → token-auth, not
|
|
363
|
+
// CSRF-able → skipStateless skip (200).
|
|
364
|
+
var bearer = await b.httpClient.request({
|
|
365
|
+
method: "POST", url: "http://127.0.0.1:" + addr.port + "/act",
|
|
366
|
+
headers: { "Cookie": "csrf=" + token, "Authorization": "Bearer test.token.value" },
|
|
367
|
+
body: Buffer.from(""), responseMode: "always-resolve",
|
|
368
|
+
allowedProtocols: b.safeUrl.ALLOW_HTTP_ALL, allowInternal: true,
|
|
369
|
+
});
|
|
370
|
+
check("security defaults: bearer-auth POST skipped → 200", bearer.statusCode === 200);
|
|
371
|
+
} finally {
|
|
372
|
+
await app.shutdown();
|
|
373
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function testCreateAppCsrfCustomNaming() {
|
|
378
|
+
// The default csrf wiring is configurable — operator cookie/field names
|
|
379
|
+
// flow straight through opts.middleware.csrf, nothing static is baked in.
|
|
380
|
+
process.env.BLAMEJS_SKIP_NTP_CHECK = "1";
|
|
381
|
+
process.env.BLAMEJS_AUDIT_SIGNING_MODE = "plaintext";
|
|
382
|
+
b.cluster._resetForTest();
|
|
383
|
+
b.audit._resetForTest();
|
|
384
|
+
b.vault._resetForTest();
|
|
385
|
+
b.db._resetForTest();
|
|
386
|
+
var dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-app-csrfname-"));
|
|
387
|
+
var app = await b.createApp({
|
|
388
|
+
dataDir: dataDir,
|
|
389
|
+
vault: { mode: "plaintext" },
|
|
390
|
+
db: { atRest: "plain", auditSigning: { mode: "plaintext" } },
|
|
391
|
+
schema: [],
|
|
392
|
+
middleware: { botGuard: false, csrf: { cookie: { name: "app_csrf" }, fieldName: "tok" } },
|
|
393
|
+
routes: function (r) { r.get("/", function (req, res) { b.render.text(res, "OK"); }); },
|
|
394
|
+
});
|
|
395
|
+
var addr = await app.listen({ port: 0, host: "127.0.0.1" });
|
|
396
|
+
try {
|
|
397
|
+
var g = await _appGet(addr.port, "/");
|
|
398
|
+
check("csrf custom naming: operator cookie name issued through the default wiring",
|
|
399
|
+
typeof _csrfCookieFrom(g, "app_csrf") === "string");
|
|
400
|
+
check("csrf custom naming: default 'csrf' cookie name not used",
|
|
401
|
+
_csrfCookieFrom(g, "csrf") === null);
|
|
402
|
+
} finally {
|
|
403
|
+
await app.shutdown();
|
|
404
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function testCreateAppSecurityDisableAudits() {
|
|
409
|
+
// Disabling a security default leaves an audit trace (app.middleware.
|
|
410
|
+
// disabled) and actually drops the middleware.
|
|
411
|
+
process.env.BLAMEJS_SKIP_NTP_CHECK = "1";
|
|
412
|
+
process.env.BLAMEJS_AUDIT_SIGNING_MODE = "plaintext";
|
|
413
|
+
b.cluster._resetForTest();
|
|
414
|
+
b.audit._resetForTest();
|
|
415
|
+
b.vault._resetForTest();
|
|
416
|
+
b.db._resetForTest();
|
|
417
|
+
var dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-app-disable-"));
|
|
418
|
+
var emitted = [];
|
|
419
|
+
var realSafeEmit = b.audit.safeEmit;
|
|
420
|
+
b.audit.safeEmit = function (ev) { emitted.push(ev); return realSafeEmit.call(b.audit, ev); };
|
|
421
|
+
var app;
|
|
422
|
+
try {
|
|
423
|
+
app = await b.createApp({
|
|
424
|
+
dataDir: dataDir,
|
|
425
|
+
vault: { mode: "plaintext" },
|
|
426
|
+
db: { atRest: "plain", auditSigning: { mode: "plaintext" } },
|
|
427
|
+
schema: [],
|
|
428
|
+
middleware: { botGuard: false, csrf: false },
|
|
429
|
+
routes: function (r) { r.get("/", function (req, res) { b.render.text(res, "OK"); }); },
|
|
430
|
+
});
|
|
431
|
+
var disabled = emitted
|
|
432
|
+
.filter(function (e) { return e && e.action === "app.middleware.disabled"; })
|
|
433
|
+
.map(function (e) { return e.metadata && e.metadata.middleware; });
|
|
434
|
+
check("disable audit: csrf disable emits app.middleware.disabled",
|
|
435
|
+
disabled.indexOf("csrf") !== -1);
|
|
436
|
+
check("disable audit: botGuard disable emits app.middleware.disabled",
|
|
437
|
+
disabled.indexOf("botGuard") !== -1);
|
|
438
|
+
var addr = await app.listen({ port: 0, host: "127.0.0.1" });
|
|
439
|
+
var g = await _appGet(addr.port, "/");
|
|
440
|
+
check("disable audit: csrf off → no csrf cookie issued",
|
|
441
|
+
_csrfCookieFrom(g, "csrf") === null);
|
|
442
|
+
} finally {
|
|
443
|
+
b.audit.safeEmit = realSafeEmit;
|
|
444
|
+
if (app) await app.shutdown();
|
|
445
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function testCreateAppCsrfIdempotent() {
|
|
450
|
+
// An operator who ALSO mounts csrf inside opts.routes must not double-
|
|
451
|
+
// apply — the second mount is a no-op (single cookie issued).
|
|
452
|
+
process.env.BLAMEJS_SKIP_NTP_CHECK = "1";
|
|
453
|
+
process.env.BLAMEJS_AUDIT_SIGNING_MODE = "plaintext";
|
|
454
|
+
b.cluster._resetForTest();
|
|
455
|
+
b.audit._resetForTest();
|
|
456
|
+
b.vault._resetForTest();
|
|
457
|
+
b.db._resetForTest();
|
|
458
|
+
var dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-app-idem-"));
|
|
459
|
+
var app = await b.createApp({
|
|
460
|
+
dataDir: dataDir,
|
|
461
|
+
vault: { mode: "plaintext" },
|
|
462
|
+
db: { atRest: "plain", auditSigning: { mode: "plaintext" } },
|
|
463
|
+
schema: [],
|
|
464
|
+
middleware: { botGuard: false },
|
|
465
|
+
routes: function (r) {
|
|
466
|
+
r.use(b.middleware.csrfProtect({ cookie: true })); // redundant — createApp already wired csrf
|
|
467
|
+
r.get("/", function (req, res) { b.render.text(res, "OK"); });
|
|
468
|
+
},
|
|
469
|
+
});
|
|
470
|
+
var addr = await app.listen({ port: 0, host: "127.0.0.1" });
|
|
471
|
+
try {
|
|
472
|
+
var g = await _appGet(addr.port, "/");
|
|
473
|
+
var sc = g.headers["set-cookie"];
|
|
474
|
+
var arr = Array.isArray(sc) ? sc : (sc ? [sc] : []);
|
|
475
|
+
var csrfCookies = arr.filter(function (c) { return /^csrf=/.test(c); });
|
|
476
|
+
check("csrf idempotent: redundant route-level mount issues a single csrf cookie",
|
|
477
|
+
csrfCookies.length === 1);
|
|
478
|
+
} finally {
|
|
479
|
+
await app.shutdown();
|
|
480
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
290
484
|
async function testCreateAppRoutesCallback() {
|
|
291
485
|
process.env.BLAMEJS_SKIP_NTP_CHECK = "1";
|
|
292
486
|
process.env.BLAMEJS_AUDIT_SIGNING_MODE = "plaintext";
|
|
@@ -462,6 +656,10 @@ async function run() {
|
|
|
462
656
|
await testCreateAppMinimalBoot();
|
|
463
657
|
await testCreateAppDefaultMiddleware();
|
|
464
658
|
await testCreateAppMiddlewareDisableable();
|
|
659
|
+
await testCreateAppSecurityDefaultsWired();
|
|
660
|
+
await testCreateAppCsrfCustomNaming();
|
|
661
|
+
await testCreateAppSecurityDisableAudits();
|
|
662
|
+
await testCreateAppCsrfIdempotent();
|
|
465
663
|
await testCreateAppRoutesCallback();
|
|
466
664
|
await testCreateAppWithJobs();
|
|
467
665
|
await testCreateAppShutdown();
|
|
@@ -480,6 +678,10 @@ module.exports = {
|
|
|
480
678
|
testCreateAppMinimalBoot: testCreateAppMinimalBoot,
|
|
481
679
|
testCreateAppDefaultMiddleware: testCreateAppDefaultMiddleware,
|
|
482
680
|
testCreateAppMiddlewareDisableable: testCreateAppMiddlewareDisableable,
|
|
681
|
+
testCreateAppSecurityDefaultsWired: testCreateAppSecurityDefaultsWired,
|
|
682
|
+
testCreateAppCsrfCustomNaming: testCreateAppCsrfCustomNaming,
|
|
683
|
+
testCreateAppSecurityDisableAudits: testCreateAppSecurityDisableAudits,
|
|
684
|
+
testCreateAppCsrfIdempotent: testCreateAppCsrfIdempotent,
|
|
483
685
|
testCreateAppRoutesCallback: testCreateAppRoutesCallback,
|
|
484
686
|
testCreateAppWithJobs: testCreateAppWithJobs,
|
|
485
687
|
testCreateAppShutdown: testCreateAppShutdown,
|
|
@@ -245,6 +245,49 @@ function testFactoryRefusesBadOpts() {
|
|
|
245
245
|
});
|
|
246
246
|
check("cert name '" + JSON.stringify(badName) + "' refused as path-segment", e && e.code === "cert/bad-cert-name");
|
|
247
247
|
});
|
|
248
|
+
|
|
249
|
+
// ---- compliance posture validation ----
|
|
250
|
+
// opts.compliance names are validated against b.compliance.KNOWN_POSTURES
|
|
251
|
+
// at create() so a typo is caught at boot rather than silently recorded.
|
|
252
|
+
var goodChallenge = { type: "http-01", provision: function () {}, cleanup: function () {} };
|
|
253
|
+
var eBadPosture = threw(function () {
|
|
254
|
+
b.cert.create({
|
|
255
|
+
storage: { type: "sealed-disk", rootDir: _tmpDir(), vault: _ephemeralVault() },
|
|
256
|
+
acme: { directory: "https://example/", accountKey: "auto" },
|
|
257
|
+
certs: [{ name: "m", domains: ["a.com"], challenge: goodChallenge }],
|
|
258
|
+
compliance: ["not-a-real-posture"],
|
|
259
|
+
audit: false,
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
check("unknown compliance posture → cert/unknown-compliance-posture",
|
|
263
|
+
eBadPosture && eBadPosture.code === "cert/unknown-compliance-posture");
|
|
264
|
+
|
|
265
|
+
var eGoodPosture = threw(function () {
|
|
266
|
+
b.cert.create({
|
|
267
|
+
storage: { type: "sealed-disk", rootDir: _tmpDir(), vault: _ephemeralVault() },
|
|
268
|
+
acme: { directory: "https://example/", accountKey: "auto" },
|
|
269
|
+
certs: [{ name: "m", domains: ["a.com"], challenge: goodChallenge }],
|
|
270
|
+
compliance: ["hipaa", "pci-dss"],
|
|
271
|
+
audit: false,
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
check("known compliance postures accepted", !eGoodPosture);
|
|
275
|
+
|
|
276
|
+
// ---- b.network.tls.ocsp.fetch composition surface ----
|
|
277
|
+
check("b.network.tls.ocsp.fetch is a function",
|
|
278
|
+
typeof b.network.tls.ocsp.fetch === "function");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// b.network.tls.ocsp.fetch rejects on missing leafPem/issuerPem rather than
|
|
282
|
+
// issuing an outbound request with undefined inputs.
|
|
283
|
+
async function testOcspFetchRejectsBadInput() {
|
|
284
|
+
var rejected = false;
|
|
285
|
+
try {
|
|
286
|
+
await b.network.tls.ocsp.fetch({});
|
|
287
|
+
} catch (_e) {
|
|
288
|
+
rejected = true;
|
|
289
|
+
}
|
|
290
|
+
check("ocsp.fetch({}) rejects (no leafPem/issuerPem)", rejected);
|
|
248
291
|
}
|
|
249
292
|
|
|
250
293
|
// ---- Sealed-disk storage roundtrip ----
|
|
@@ -699,6 +742,7 @@ async function testCorruptAccountKeyClearError() {
|
|
|
699
742
|
async function run() {
|
|
700
743
|
testSurface();
|
|
701
744
|
testFactoryRefusesBadOpts();
|
|
745
|
+
await testOcspFetchRejectsBadInput();
|
|
702
746
|
await testStorageRoundtrip();
|
|
703
747
|
await testSniCallback();
|
|
704
748
|
await testSniWildcardSingleLabel();
|
package/package.json
CHANGED