@blamejs/blamejs-shop 0.2.31 → 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 +4 -0
- package/lib/admin.js +56 -1
- package/lib/asset-manifest.json +7 -7
- 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
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,10 @@ Pre-1.0 the surface is intentionally evolving — every release may
|
|
|
6
6
|
change something operators depend on. Read each entry before
|
|
7
7
|
upgrading across more than a few patches at a time.
|
|
8
8
|
|
|
9
|
+
## v0.3.x
|
|
10
|
+
|
|
11
|
+
- v0.3.0 (2026-05-30) — **Token-based CSRF protection on the account and admin forms.** Every state-changing form on the customer account surface — subscriptions, addresses, wishlist, returns, reviews, loyalty, referrals, passkey management, profile, checkout — and the entire admin console now carries a per-request double-submit CSRF token that is validated on submission. This is a defense-in-depth layer on top of the existing SameSite session cookies and the fetch-metadata cross-site gate. It lands with an update of the vendored blamejs runtime to v0.13.46, whose createApp now wires CSRF into the request lifecycle by default (closing the long-standing gap where it was documented but not enforced). The edge-rendered, cached storefront forms (add-to-cart, cookie consent, newsletter, currency switch) keep their SameSite + fetch-metadata defense — a per-session token cannot be embedded in HTML that is cached and identical for every visitor, and forcing one would break no-JS submission. No operator action or configuration change is required. **Security:** *Per-form CSRF tokens on account and admin* — The container-rendered account and admin forms now embed a hidden `_csrf` field carrying a per-request double-submit token; the server validates it (constant-time) on every state-changing POST. The passkey enrolment / sign-in islands send the matching token in the `X-CSRF-Token` header. This is layered on top of — not a replacement for — the SameSite session cookies and the fetch-metadata cross-site refusal already in place. · *Vendored blamejs runtime updated to v0.13.46* — createApp now wires CSRF (double-submit cookie), plus the cookie / CSP-nonce / fetch-metadata / body-parser layers, into the request lifecycle by default. The shop adopts the CSRF default scoped so the edge-cached, dual-rendered storefront endpoints (cart, consent, newsletter, currency) remain on their SameSite + fetch-metadata defense, while the authenticated account and admin surfaces gain the token check. A lint rule now flags any new edge-rendered POST form that would need that exemption.
|
|
12
|
+
|
|
9
13
|
## v0.2.x
|
|
10
14
|
|
|
11
15
|
- v0.2.31 (2026-05-29) — **Abandoned-cart recovery.** A scheduled pass now finds carts left idle with items and no order and emails the shopper a link back — composed from the cart-abandonment scanner and a multi-step recovery sequence, gated on marketing consent (anyone who has withdrawn marketing consent is skipped) and idempotent (one email per abandonment, no double-sends across ticks). It stays completely inert until an SMTP mailer and an email resolver are configured, so a store without them is unaffected; the existing once-a-minute cron drives it. **Added:** *Abandoned-cart recovery* — A scheduled pass detects carts that have items, no paid order, and have been idle past a configurable threshold (`shop.cart_recovery_after_hours`, default 4h), then emails a recovery link to shoppers whose address is known. Enrolment is gated on the marketing-consent ledger and the send path honors the suppression list, so a withdrawn or unsubscribed shopper is never contacted; sends are idempotent. The pass no-ops cleanly until an SMTP mailer (`SMTP_HOST` / `MAIL_FROM`) and an email resolver are wired, so deploying it changes nothing until you opt in.
|
package/lib/admin.js
CHANGED
|
@@ -32,11 +32,43 @@
|
|
|
32
32
|
|
|
33
33
|
var pricing = require("./pricing");
|
|
34
34
|
var collectionsModule = require("./collections");
|
|
35
|
+
var { AsyncLocalStorage } = require("node:async_hooks"); // allow:non-shop-require — Node-core per-request context (no npm dep); the framework itself composes it in db-role-context / log. No b.* request-context primitive exists to wrap it.
|
|
35
36
|
|
|
36
37
|
var b = require("./vendor/blamejs");
|
|
37
38
|
|
|
38
39
|
var AUDIT_NAMESPACE = "shop_admin";
|
|
39
40
|
|
|
41
|
+
// Per-request store for the double-submit CSRF token. The admin console is
|
|
42
|
+
// container-only and has no locale ALS (unlike the storefront), so it gets
|
|
43
|
+
// its own: a sync middleware in `mount()` seeds the request's `req.csrfToken`
|
|
44
|
+
// here, and `_renderAdminShell` (the single funnel every authenticated admin
|
|
45
|
+
// page flows through) reads it back to token every `<form method="post">`.
|
|
46
|
+
// `enterWith` scopes the value to the request's async execution context, so
|
|
47
|
+
// concurrent admin requests never see each other's token. A render reached
|
|
48
|
+
// outside a request finds no store and injects no field.
|
|
49
|
+
var _csrfAls = new AsyncLocalStorage();
|
|
50
|
+
|
|
51
|
+
// Inject a hidden `_csrf` field into every POST form in `html`, value matched
|
|
52
|
+
// to the request's double-submit `__Host-csrf` / `csrf` cookie so a real
|
|
53
|
+
// browser (and the e2e harness) submits a token `csrfGuard`
|
|
54
|
+
// (lib/security-middleware) accepts. Admin is container-only — none of its
|
|
55
|
+
// actions are EDGE_POST_PATHS — so every POST form is tokened (no edge-parity
|
|
56
|
+
// carve-out, unlike the storefront). GET forms are left untouched. The field
|
|
57
|
+
// is spliced immediately after each form's open tag; existing markup is
|
|
58
|
+
// preserved byte-for-byte. Absent a token (render outside a request) the html
|
|
59
|
+
// is returned unchanged.
|
|
60
|
+
function _injectAdminCsrfFields(html) {
|
|
61
|
+
var store = _csrfAls.getStore();
|
|
62
|
+
var token = store && store.csrf_token;
|
|
63
|
+
if (!token || typeof html !== "string" || html.indexOf("<form") === -1) return html;
|
|
64
|
+
var field = "<input type=\"hidden\" name=\"_csrf\" value=\"" + _htmlEscape(token) + "\">";
|
|
65
|
+
return html.replace(/<form\b[^>]*>/gi, function (openTag) {
|
|
66
|
+
var method = openTag.match(/\bmethod\s*=\s*["']?([a-z]+)/i);
|
|
67
|
+
if (!method || method[1].toLowerCase() !== "post") return openTag;
|
|
68
|
+
return openTag + field;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
40
72
|
// The console stylesheet ships as an external /assets file — an inline
|
|
41
73
|
// <style> block (or inline style="" attributes) is refused by the strict
|
|
42
74
|
// `style-src 'self'` CSP that governs container-served routes, which is
|
|
@@ -416,6 +448,24 @@ function mount(router, deps) {
|
|
|
416
448
|
|
|
417
449
|
try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
418
450
|
|
|
451
|
+
// Seed the per-request double-submit CSRF token onto the ALS so
|
|
452
|
+
// `_renderAdminShell` can token every admin POST form. SYNCHRONOUS, like
|
|
453
|
+
// the storefront's locale middleware: `enterWith` only propagates to the
|
|
454
|
+
// request's downstream handlers when it runs in the same synchronous tick
|
|
455
|
+
// the router awaits. The guard (lib/security-middleware) issues the token on
|
|
456
|
+
// GET and exposes it as `req.csrfToken`; absent one it rides as "" and no
|
|
457
|
+
// field is injected. Mounted ahead of every admin route. Drop-silent — a
|
|
458
|
+
// failure here must never 500 the console; the form just renders token-less
|
|
459
|
+
// (and a stateless GET wasn't being CSRF-checked anyway).
|
|
460
|
+
if (typeof router.use === "function") {
|
|
461
|
+
router.use(function adminCsrfTokenMiddleware(req, _res, next) {
|
|
462
|
+
try {
|
|
463
|
+
_csrfAls.enterWith({ csrf_token: req.csrfToken || "" });
|
|
464
|
+
} catch (_e) { /* drop-silent — form renders token-less */ }
|
|
465
|
+
next();
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
419
469
|
var W = function (auditAction, h) {
|
|
420
470
|
return _wrap(h, { expectedToken: expectedToken, audit: auditAction });
|
|
421
471
|
};
|
|
@@ -5366,7 +5416,7 @@ function _adminNav(active, available) {
|
|
|
5366
5416
|
}
|
|
5367
5417
|
|
|
5368
5418
|
function _renderAdminShell(shopName, subtitle, bodyHtml, active, available) {
|
|
5369
|
-
|
|
5419
|
+
var html = _renderTemplate(DASHBOARD_LAYOUT, {
|
|
5370
5420
|
shop_name: shopName || "blamejs.shop",
|
|
5371
5421
|
page_title: subtitle || "Admin",
|
|
5372
5422
|
window_label: subtitle || "",
|
|
@@ -5375,6 +5425,11 @@ function _renderAdminShell(shopName, subtitle, bodyHtml, active, available) {
|
|
|
5375
5425
|
}).replace("RAW_ADMIN_CSS", _adminStylesheetLink())
|
|
5376
5426
|
.replace("RAW_NAV", _adminNav(active, available))
|
|
5377
5427
|
.replace("RAW_BODY", bodyHtml);
|
|
5428
|
+
// Token every admin POST form with the per-request double-submit CSRF value
|
|
5429
|
+
// (seeded on the ALS by mount()'s sync middleware). Single funnel — every
|
|
5430
|
+
// authenticated admin page assembles here — so this is the one place the
|
|
5431
|
+
// field needs injecting.
|
|
5432
|
+
return _injectAdminCsrfFields(html);
|
|
5378
5433
|
}
|
|
5379
5434
|
|
|
5380
5435
|
// Server-rendered confirmation interstitial for irreversible /
|
package/lib/asset-manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
2
|
+
"version": "0.3.0",
|
|
3
3
|
"assets": {
|
|
4
4
|
"css/admin.css": {
|
|
5
5
|
"integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
|
|
@@ -22,16 +22,16 @@
|
|
|
22
22
|
"fingerprinted": "js/consent.59060d7723d050b2.js"
|
|
23
23
|
},
|
|
24
24
|
"js/passkey-add.js": {
|
|
25
|
-
"integrity": "sha384-
|
|
26
|
-
"fingerprinted": "js/passkey-add.
|
|
25
|
+
"integrity": "sha384-97y8Ej4xZfwgYzbpLg3qtO2rrrvPccje4ZiNjw99JfN0gUqBtiwdw2YDabSYxbG1",
|
|
26
|
+
"fingerprinted": "js/passkey-add.b535e6a3eef4514e.js"
|
|
27
27
|
},
|
|
28
28
|
"js/passkey-login.js": {
|
|
29
|
-
"integrity": "sha384-
|
|
30
|
-
"fingerprinted": "js/passkey-login.
|
|
29
|
+
"integrity": "sha384-iJ5mKqQ15N2dSCqvQRsez9SRgESaCX8CJD0mTZSYoQVhtxQz+DGdZPYe2+df/0VF",
|
|
30
|
+
"fingerprinted": "js/passkey-login.4482cca420479033.js"
|
|
31
31
|
},
|
|
32
32
|
"js/passkey-register.js": {
|
|
33
|
-
"integrity": "sha384-
|
|
34
|
-
"fingerprinted": "js/passkey-register.
|
|
33
|
+
"integrity": "sha384-NB0gCF+Bpo6cJYkqfTdcvCMYBzpTJfzz/pYCx6iTykznRWVcBHKqidj+mMIv5qKV",
|
|
34
|
+
"fingerprinted": "js/passkey-register.537c8a0a0b70ddec.js"
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
}
|
|
@@ -85,6 +85,29 @@ var TIGHT_PREFIXES = [
|
|
|
85
85
|
"/survey/",
|
|
86
86
|
];
|
|
87
87
|
|
|
88
|
+
// Edge-served state-changing POST endpoints. These forms are rendered at
|
|
89
|
+
// the Cloudflare edge (worker/render/*) into cached, cookie-less HTML that
|
|
90
|
+
// is byte-identical for every visitor, and their container twins must stay
|
|
91
|
+
// byte-identical (the render-parity gate). A per-session double-submit CSRF
|
|
92
|
+
// token therefore cannot be baked into the shared markup without either
|
|
93
|
+
// breaking parity or breaking no-JS form submission. They are exempt from
|
|
94
|
+
// the token check and keep their established defense: the SameSite=Lax
|
|
95
|
+
// session cookie + the fetch-metadata cross-site gate. Token CSRF applies to
|
|
96
|
+
// the container-only authenticated surface (account / subscriptions /
|
|
97
|
+
// checkout / admin), where a session exists and the server renders the
|
|
98
|
+
// token directly. Matched as path PREFIXES (covers the `:slug` / `:id`
|
|
99
|
+
// segments on dismiss + cart-line update/remove).
|
|
100
|
+
var EDGE_POST_PATHS = [
|
|
101
|
+
"/cart/lines",
|
|
102
|
+
"/cart/bundle",
|
|
103
|
+
"/wishlist/toggle",
|
|
104
|
+
"/compare/toggle",
|
|
105
|
+
"/consent",
|
|
106
|
+
"/currency",
|
|
107
|
+
"/newsletter",
|
|
108
|
+
"/announcements/",
|
|
109
|
+
];
|
|
110
|
+
|
|
88
111
|
/**
|
|
89
112
|
* Resolve the real client IP for rate-limit keying. Reads the
|
|
90
113
|
* Cloudflare-injected `cf-connecting-ip` first (the canonical single
|
|
@@ -147,6 +170,28 @@ function _hasPrefix(pathname, prefixes) {
|
|
|
147
170
|
* @param r the blamejs Router passed to createApp's routes(r) callback.
|
|
148
171
|
*/
|
|
149
172
|
function mountRouteGuards(r) {
|
|
173
|
+
// --- CSRF: double-submit token on authenticated state-changing POSTs ---
|
|
174
|
+
//
|
|
175
|
+
// The vendored createApp enforces CSRF by default; the entry points pass
|
|
176
|
+
// `middleware: { csrf: false }` to disable that APP-level auto-mount and
|
|
177
|
+
// we mount it here, INSIDE the route chain, so the EDGE_POST_PATHS are
|
|
178
|
+
// exempt. Those are the edge-cached, cookie-less, dual-rendered forms
|
|
179
|
+
// (cart-add, consent, newsletter, currency, dismiss, wishlist/compare)
|
|
180
|
+
// that cannot carry a per-session token without breaking render-parity or
|
|
181
|
+
// no-JS submission — they keep their SameSite + fetch-metadata defense.
|
|
182
|
+
// Every other state-changing request is token-validated; the token is
|
|
183
|
+
// issued on GET (exposed as `req.csrfToken`) and the storefront/admin
|
|
184
|
+
// shells render it into a hidden `_csrf` field. `skipStateless` passes
|
|
185
|
+
// cookie-less and bearer-token (Authorization-header) requests through —
|
|
186
|
+
// they cannot be CSRF-ed (no ambient cookie credential to abuse).
|
|
187
|
+
var csrfGate = b.middleware.csrfProtect({ cookie: true, skipStateless: true });
|
|
188
|
+
r.use(function csrfGuard(req, res, next) {
|
|
189
|
+
var pathname = req.pathname || req.url || "/";
|
|
190
|
+
if (_hasPrefix(pathname, WEBHOOK_PATHS) || pathname === HEALTH_PATH) return next();
|
|
191
|
+
if (_hasPrefix(pathname, EDGE_POST_PATHS)) return next();
|
|
192
|
+
return csrfGate(req, res, next);
|
|
193
|
+
});
|
|
194
|
+
|
|
150
195
|
// --- fetch-metadata: cross-site state-change isolation -------------
|
|
151
196
|
//
|
|
152
197
|
// Refuses cross-site POST / PUT / DELETE / PATCH (the CSRF vector)
|
|
@@ -214,4 +259,5 @@ module.exports = {
|
|
|
214
259
|
WEBHOOK_PATHS: WEBHOOK_PATHS,
|
|
215
260
|
HEALTH_PATH: HEALTH_PATH,
|
|
216
261
|
TIGHT_PREFIXES: TIGHT_PREFIXES,
|
|
262
|
+
EDGE_POST_PATHS: EDGE_POST_PATHS,
|
|
217
263
|
};
|
package/lib/storefront.js
CHANGED
|
@@ -29,6 +29,7 @@ var emailModule = require("./email");
|
|
|
29
29
|
var pricing = require("./pricing");
|
|
30
30
|
var currencyDisplayModule = require("./currency-display");
|
|
31
31
|
var translationsModule = require("./translations");
|
|
32
|
+
var securityMiddleware = require("./security-middleware");
|
|
32
33
|
var { AsyncLocalStorage } = require("node:async_hooks"); // allow:non-shop-require — Node-core per-request context (no npm dep); the framework itself composes it in db-role-context / log. No b.* request-context primitive exists to wrap it.
|
|
33
34
|
|
|
34
35
|
var b = require("./vendor/blamejs");
|
|
@@ -105,6 +106,73 @@ function webhookRawBodyCapture(paths) {
|
|
|
105
106
|
// same XSS guard, same unknown / unused refusal).
|
|
106
107
|
var _render = emailModule._render;
|
|
107
108
|
|
|
109
|
+
// ---- double-submit CSRF token injection ---------------------------------
|
|
110
|
+
|
|
111
|
+
// The container's authenticated state-changing forms (account /
|
|
112
|
+
// subscriptions / checkout / address / passkey-revoke / survey / …) carry a
|
|
113
|
+
// hidden `_csrf` field whose value matches the `__Host-csrf` / `csrf`
|
|
114
|
+
// double-submit cookie, so a real browser (and the e2e harness) submits a
|
|
115
|
+
// token the `csrfGuard` (lib/security-middleware) accepts. The token is
|
|
116
|
+
// resolved per request and stashed on the locale ALS by `localeMiddleware`,
|
|
117
|
+
// then read back here when the page HTML is assembled in `_wrap`.
|
|
118
|
+
//
|
|
119
|
+
// EDGE_POST_PATHS forms are skipped: those are the edge-cached, cookie-less,
|
|
120
|
+
// dual-rendered forms (cart-add, consent, currency, newsletter, wishlist /
|
|
121
|
+
// compare toggle, announcement dismiss) whose container twins must stay
|
|
122
|
+
// byte-identical to the edge copy (the render-parity gate). They keep their
|
|
123
|
+
// SameSite + fetch-metadata defense and are exempt from the token check, so
|
|
124
|
+
// tokening only the container copy would both break parity and 403 a no-JS
|
|
125
|
+
// edge submission. The exempt list is the SAME prefix set the guard exempts,
|
|
126
|
+
// imported from lib/security-middleware so the two never drift.
|
|
127
|
+
var _EDGE_POST_PATHS = securityMiddleware.EDGE_POST_PATHS;
|
|
128
|
+
|
|
129
|
+
// The escaper the storefront uses for every attribute interpolation
|
|
130
|
+
// (b.template.escapeHtml — attribute-safe; escapes & < > " '). Aliased so the
|
|
131
|
+
// rewrite reads as "escape this attribute value".
|
|
132
|
+
var _escAttr = b.template.escapeHtml;
|
|
133
|
+
|
|
134
|
+
function _actionIsEdgeExempt(action) {
|
|
135
|
+
for (var i = 0; i < _EDGE_POST_PATHS.length; i += 1) {
|
|
136
|
+
if (action.indexOf(_EDGE_POST_PATHS[i]) === 0) return true;
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Inject a hidden `_csrf` field into every container POST form in `html`
|
|
142
|
+
// whose action is NOT an EDGE_POST_PATHS prefix. Operates on the fully
|
|
143
|
+
// assembled page body: each `<form …>` open tag is parsed for its `method`
|
|
144
|
+
// (POST only — GET forms carry no ambient-credential CSRF risk) and `action`
|
|
145
|
+
// (an action covered by an edge prefix is left untouched for render-parity; a
|
|
146
|
+
// form with no action posts to the current container page, which is never
|
|
147
|
+
// edge-exempt, so it IS tokened). The token comes from the per-request locale
|
|
148
|
+
// ALS; absent a token (a renderer reached outside a request, or a request
|
|
149
|
+
// where the guard issued none) the field is omitted — there is nothing to
|
|
150
|
+
// submit and the guard would not be checking this request anyway.
|
|
151
|
+
//
|
|
152
|
+
// The hidden input is spliced immediately after the form's open tag so it is
|
|
153
|
+
// the first child (valid HTML; never lands between a `<select>`/`<option>`).
|
|
154
|
+
// The form's existing markup is preserved byte-for-byte.
|
|
155
|
+
function _injectCsrfFields(html, token) {
|
|
156
|
+
if (!token || typeof html !== "string" || html.indexOf("<form") === -1) return html;
|
|
157
|
+
var field = "<input type=\"hidden\" name=\"_csrf\" value=\"" + _escAttr(token) + "\">";
|
|
158
|
+
// Match each form open tag: `<form` … `>` (non-greedy, no nested `>` —
|
|
159
|
+
// attribute values here never contain a literal `>`). The replacer decides
|
|
160
|
+
// per-tag whether to splice the field in.
|
|
161
|
+
return html.replace(/<form\b[^>]*>/gi, function (openTag) {
|
|
162
|
+
var method = openTag.match(/\bmethod\s*=\s*["']?([a-z]+)/i);
|
|
163
|
+
// No method, or a non-POST method (GET / dialog) → leave untouched.
|
|
164
|
+
if (!method || method[1].toLowerCase() !== "post") return openTag;
|
|
165
|
+
var actionMatch = openTag.match(/\baction\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i);
|
|
166
|
+
var action = actionMatch ? (actionMatch[2] != null ? actionMatch[2]
|
|
167
|
+
: actionMatch[3] != null ? actionMatch[3]
|
|
168
|
+
: actionMatch[4]) : "";
|
|
169
|
+
// Edge-exempt action (cart-add / consent / currency / newsletter / …) →
|
|
170
|
+
// leave untouched so the container copy stays byte-identical to the edge.
|
|
171
|
+
if (action && _actionIsEdgeExempt(action)) return openTag;
|
|
172
|
+
return openTag + field;
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
108
176
|
// ---- shared layout ------------------------------------------------------
|
|
109
177
|
|
|
110
178
|
// Visual identity reference: the framework ships with two
|
|
@@ -687,7 +755,7 @@ function _wrap(opts) {
|
|
|
687
755
|
vars[ck] = chrome[ck];
|
|
688
756
|
}
|
|
689
757
|
|
|
690
|
-
|
|
758
|
+
var assembled = _render(LAYOUT, vars)
|
|
691
759
|
.replace("RAW_CSS_INTEGRITY", themeCssIntegrity)
|
|
692
760
|
.replace("RAW_ANNOUNCEMENT_BAR", announcementBarHtml)
|
|
693
761
|
.replace("RAW_CONSENT_SCRIPT", _islandScript("consent.js", { id: "consent-island", policy: _activeConsentPolicy }))
|
|
@@ -702,6 +770,12 @@ function _wrap(opts) {
|
|
|
702
770
|
// markup. `search_q` is HTML-escaped by the renderer like any
|
|
703
771
|
// other placeholder, so a customer-supplied query like
|
|
704
772
|
// `"><script>` lands as escaped text inside the input's `value`.
|
|
773
|
+
//
|
|
774
|
+
// Final pass: token the container's authenticated POST forms with the
|
|
775
|
+
// per-request double-submit CSRF value (stashed on the locale ALS by
|
|
776
|
+
// `localeMiddleware`). EDGE_POST_PATHS forms are skipped to preserve
|
|
777
|
+
// render-parity with the edge copy — see `_injectCsrfFields`.
|
|
778
|
+
return _injectCsrfFields(assembled, storeCtx && storeCtx.csrf_token);
|
|
705
779
|
}
|
|
706
780
|
|
|
707
781
|
// ---- home --------------------------------------------------------------
|
|
@@ -6465,7 +6539,13 @@ function mount(router, deps) {
|
|
|
6465
6539
|
router.use(function localeMiddleware(req, _res, next) {
|
|
6466
6540
|
try {
|
|
6467
6541
|
var ctx = _localeCtx(req);
|
|
6468
|
-
|
|
6542
|
+
// Stash the per-request double-submit CSRF token alongside the locale
|
|
6543
|
+
// context so `_wrap` can inject it into the container's authenticated
|
|
6544
|
+
// POST forms (see `_injectCsrfFields`). The guard issues the token on
|
|
6545
|
+
// GET and exposes it as `req.csrfToken`; absent one (cookie-less /
|
|
6546
|
+
// bearer request the guard skips) it rides as "" and no field is
|
|
6547
|
+
// injected — there is nothing to submit and the guard isn't checking.
|
|
6548
|
+
_localeAls.enterWith(Object.assign({}, ctx, { csrf_token: req.csrfToken || "" }));
|
|
6469
6549
|
_logResolution(req, ctx.locale);
|
|
6470
6550
|
} catch (_e) { /* drop-silent — baseline applies */ }
|
|
6471
6551
|
next();
|
package/lib/vendor/MANIFEST.json
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
"_about": "blamejs.shop vendors a single framework — blamejs — which itself bundles every server-side crypto/identity dependency. The transitive packages blamejs ships are surfaced in its own MANIFEST.json at lib/vendor/blamejs/lib/vendor/MANIFEST.json — Trivy / Grype rely on that nested data for CVE attribution.",
|
|
4
4
|
"packages": {
|
|
5
5
|
"blamejs": {
|
|
6
|
-
"version": "0.13.
|
|
7
|
-
"tag": "v0.13.
|
|
6
|
+
"version": "0.13.46",
|
|
7
|
+
"tag": "v0.13.46",
|
|
8
8
|
"license": "Apache-2.0",
|
|
9
9
|
"author": "blamejs contributors",
|
|
10
10
|
"source": "https://github.com/blamejs/blamejs",
|
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.13.x
|
|
10
10
|
|
|
11
|
+
- v0.13.46 (2026-05-29) — **`createApp` now wires the documented security middleware ON by default — CSRF, CSP nonce, cookie parser, fetch-metadata, and body parser.** 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. **Added:** *Idempotent security middleware* — 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. **Changed:** *createApp wires CSRF, CSP nonce, cookie parser, fetch-metadata, and body parser by default (breaking)* — 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). · *Default CSRF auto-skips token-authenticated and cookieless requests* — 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. · *Disabling a default security middleware is audited* — 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.
|
|
12
|
+
|
|
13
|
+
- v0.13.45 (2026-05-29) — **`b.cert` now fetches and staples a validated OCSP response per certificate, and validates declared compliance postures at create().** 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. **Added:** *b.network.tls.ocsp.fetch — fetch and validate an OCSP response* — 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. · *b.cert staples a validated OCSP response per certificate* — 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. **Changed:** *opts.compliance posture names are validated at create()* — 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.
|
|
14
|
+
|
|
11
15
|
- v0.13.44 (2026-05-29) — **Error codes on the consent, compliance, and protocol namespaces now follow the namespace/kebab-case contract.** The framework's error contract is `err.code = "namespace/kebab-case"`, and the vast majority of namespaces already followed it. This release normalizes the holdouts: fifteen namespaces that threw bare UPPER_SNAKE codes with no namespace, and nine that used a camelCase namespace prefix. After this release every error these namespaces throw carries a `namespace/kebab-case` code, so an operator switching on `err.code` no longer has to special-case them. This is a breaking change for code that matches the old strings — pre-1.0, there is no compatibility shim, so update any `err.code` comparisons against the listed namespaces. A codebase check now enforces the convention so it cannot regress. A small set of older codes (the cluster, scheduler, circuit-breaker, object-store, and upload subsystems) is intentionally left for the 1.0 release, where it will carry a deprecation cycle. **Changed:** *Bare UPPER_SNAKE error codes are now namespaced (breaking)* — Fifteen namespaces threw bare UPPER_SNAKE error codes with no namespace prefix (for example `mcp` threw `BAD_JSON`, `BAD_ENVELOPE`, `BAD_METHOD`). Their `err.code` values are now `namespace/kebab-case` — `mcp/bad-json`, `mcp/bad-envelope`, and so on. The affected namespaces are `b.a2a`, `b.aiInput`, `b.aiPref`, `b.budr`, `b.contentCredentials`, `b.darkPatterns`, `b.fapi2`, `b.fdx`, `b.graphqlFederation`, `b.iabTcf`, `b.iabMspa`, `b.mcp`, `b.secCyber`, `b.sse`, and `b.tcpa10dlc`. Operators matching the old bare codes on `err.code` must update those comparisons; the error message text is unchanged. · *camelCase error-code namespaces are now kebab-case (breaking)* — Nine namespaces emitted error codes whose namespace segment was camelCase (for example `aiDp/bad-bound`, `argParser/flag-duplicate`). The namespace segment is now kebab-case to match every other code: `ai-dp/`, `ai-capability/`, `ai-quota/`, `arg-parser/`, `audit-sign/`, `auth-step-up/`, `ddl-change-control/`, `dr-runbook/`, `tenant-quota/`, and `boot-gates/`. The `b.*` API namespace keys themselves are unchanged (those remain camelCase, e.g. `b.argParser`); only the `err.code` string changed. Operators matching these `err.code` strings must update them. **Detectors:** *Error-code shape is enforced* — A codebase check now flags any error code constructed via `new XError(...)` or the per-class `factory(...)` whose value is a bare UPPER_SNAKE string or carries a camelCase namespace segment, so the `namespace/kebab-case` contract cannot silently regress. It correctly ignores native error constructors (whose first argument is the message, not a code).
|
|
12
16
|
|
|
13
17
|
- v0.13.43 (2026-05-29) — **LTS window stated consistently as 24 months, experimental primitives declared semver-exempt, and stale version references cleaned up.** Documentation and operator-facing string hygiene ahead of the 1.0 stability contract. The LTS support window is now stated as 24 months everywhere (GOVERNANCE.md and the LTS calendar previously disagreed — 24 vs 18). The LTS calendar gains an explicit clause that primitives marked experimental are exempt from the stability/LTS contract, so operators can tell at a glance which surfaces may change between minors. Several error messages and doc blocks that pinned to long-past version numbers ("lands in v0.10.9", "not supported in v0.12.7", "ships in v0.6.45+") are restated version-agnostically with their escape hatch, and the S/MIME module now points operators at the live PGP encrypt/decrypt path for confidentiality today. No API or behavior changes. **Changed:** *LTS support window is consistently 24 months* — `GOVERNANCE.md` promised a 24-month LTS window while `LTS-CALENDAR.md` and `SECURITY.md` stated 18 — a six-month contradiction in the single most load-bearing number of the support contract. All three now state 24 months of security-only patches per major. The calendar table and the supported-versions prose are aligned. · *Experimental primitives are declared exempt from the stability contract* — `LTS-CALENDAR.md` now states explicitly that primitives documented as experimental (shown as "experimental" on their wiki page, and via the `experimental` segment in namespaces like `b.jose.jwe.experimental`) are not covered by the stability contract or the LTS window — they may change signature, behavior, or wire format, or be removed, in any minor without a deprecation cycle. This lets the framework ship primitives that track in-flight standards without freezing an unsettled format, and tells operators precisely which surfaces are not yet frozen. **Fixed:** *Stale version references removed from operator-facing errors and docs* — Error messages and documentation that pinned to long-past versions are restated version-agnostically with the relevant escape hatch: ZIP64 and unsupported-compression errors in archive reading, the CMS AuthEnvelopedData / fielded-decoder notes, the mTLS CRL-engine error, the safe-archive format-detection summary (which also now correctly lists the supported zip / tar / tar.gz set rather than claiming only zip), and the AI-content IPTC-reader note. None changed behavior; they no longer read as broken promises against the published version history. · *S/MIME confidentiality deferral points to the working PGP path* — `b.mail.crypto.smime` ships sign + verify; encrypt/decrypt is deferred. The deferral note previously cited an open-ended internal condition; it now names the escape hatch directly — use `b.mail.crypto.pgp.encrypt` / `decrypt` for mail confidentiality today — and states the concrete trigger that would re-open S/MIME-specific (X.509-recipient) encryption. · *Governance doc no longer references an internal file operators cannot see* — `GOVERNANCE.md` cited a rule number in a contributor-only file that does not ship in the repository. The deprecation-policy statement is now self-contained.
|
|
@@ -114,25 +114,22 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
114
114
|
- **CMS codec** — RFC 5652 Cryptographic Message Syntax encoder + decoder with PQC signers (ML-DSA-65 / ML-DSA-87 / SLH-DSA-SHAKE-256f; RFC 9909 + 9881) and KEMRecipientInfo recipients (ML-KEM-1024; RFC 9629 + 9936); ChaCha20-Poly1305 content encryption (RFC 8103) so Efail-class malleability cannot apply (`b.cms`)
|
|
115
115
|
- **Stream throttle** — shared token-bucket bandwidth limiter (RFC 2697 srTCM shape); N concurrent `node:stream` pipelines draw from one operator-configured `bytesPerSec` budget (`b.streamThrottle`)
|
|
116
116
|
- **TLS-RPT receiver** — RFC 8460 inbound aggregate-report ingest; HTTPS POST handler + §4.4 schema parser with gzip-bomb / ratio-bomb / depth-bomb defenses (`b.mail.deploy.parseTlsRptReport` / `b.mail.deploy.tlsRptIngestHttp`)
|
|
117
|
-
- **TLS / channel binding** — RFC 9266 TLS-Exporter token-to-session pinning (`b.tlsExporter`); RFC 9162 CT v2 inclusion-proof verification (`b.network.tls.ct.verifyInclusion`); RFC 8555 ACME + RFC 9773 ARI for 47-day certs with `{ jitter: true }` fleet-scheduling (`b.acme.renewIfDue`); draft-aaron-acme-profiles (`acme.listProfiles()` + `newOrder({ profile })`); draft-ietf-acme-dns-account-label (`acme.dnsAccount01ChallengeRecord(token, { identifier })`); RFC 8470 0-RTT inbound posture refuse / replay-cache (`b.router.create({tls0Rtt})`); RFC 9794 SecP256r1MLKEM768 in preferred-group order (`b.network.tls.preferredGroups`)
|
|
117
|
+
- **TLS / channel binding** — RFC 9266 TLS-Exporter token-to-session pinning (`b.tlsExporter`); RFC 9162 CT v2 inclusion-proof verification (`b.network.tls.ct.verifyInclusion`); RFC 8555 ACME + RFC 9773 ARI for 47-day certs with `{ jitter: true }` fleet-scheduling (`b.acme.renewIfDue`); draft-aaron-acme-profiles (`acme.listProfiles()` + `newOrder({ profile })`); draft-ietf-acme-dns-account-label (`acme.dnsAccount01ChallengeRecord(token, { identifier })`); RFC 8470 0-RTT inbound posture refuse / replay-cache (`b.router.create({tls0Rtt})`); RFC 9794 SecP256r1MLKEM768 in preferred-group order (`b.network.tls.preferredGroups`); RFC 6960 OCSP stapling — the cert manager (`b.cert`) fetches + validates each managed certificate's OCSP response (`b.network.tls.ocsp.fetch`) on a refresh cadence and exposes it on the served context for a TLS server's `OCSPRequest` handler to staple
|
|
118
118
|
- **mTLS CA** — pure-JS, issues clientAuth / serverAuth / dual-EKU certs with SAN; auto-detects highest-PQC signature alg (today ECDSA-P384-SHA384; self-upgrades to SLH-DSA / ML-DSA when X.509 ecosystem catches up); PQC TLS gates inbound + outbound (`b.mtlsCa`, `b.pqcGate`, `b.pqcAgent`)
|
|
119
119
|
### HTTP
|
|
120
120
|
|
|
121
121
|
- **Router + API specs** — schema-validated routes; OpenAPI publication (`b.openapi`) + AsyncAPI publication for event/streaming (`b.asyncapi`)
|
|
122
|
-
- **Middleware stack (wired by `
|
|
123
|
-
-
|
|
124
|
-
- CORS with W3C Private Network Access preflight refusal default + `allowPrivateNetwork` opt
|
|
125
|
-
- Rate-limit
|
|
122
|
+
- **Middleware stack (`createApp`)** — security layers wired ON by default (Core Rule §3); each is configurable via `middleware.<name>` (operator cookie / field names flow straight through — nothing static is baked in) or opt-out with `false` (disabling a default is audited via `app.middleware.disabled`). Ordered so each layer has what it needs (cookies + CSP nonce + fetch-metadata, then body parser, then CSRF last):
|
|
123
|
+
- Request-ID tagging and bot-guard
|
|
126
124
|
- Security headers with `Permissions-Policy` defaults denying storage-access / browsing-topics / private-aggregation / controlled-frame
|
|
127
|
-
- CSP nonce
|
|
128
|
-
- Body parser — JSON / urlencoded / text / multipart; multipart file parts stream to a tmp dir or buffer in memory (`storage: "memory"`) for read-only / serverless filesystems
|
|
129
|
-
- Compression
|
|
130
|
-
- SSE
|
|
131
|
-
- Request log
|
|
132
125
|
- Threat-aware cookie parser (`b.middleware.cookies`)
|
|
133
|
-
-
|
|
134
|
-
-
|
|
126
|
+
- CSP nonce — generated per request, merged into the CSP (`b.middleware.cspNonce`)
|
|
127
|
+
- Fetch-metadata resource-isolation guard (`b.middleware.fetchMetadata`)
|
|
128
|
+
- Body parser — JSON / urlencoded / text / multipart; multipart file parts stream to a tmp dir or buffer in memory (`storage: "memory"`) for read-only / serverless filesystems
|
|
129
|
+
- CSRF protection — double-submit cookie + Origin/Referer cross-check; auto-skips Authorization-header / cookieless requests, which are not CSRF-able (`b.middleware.csrfProtect`)
|
|
130
|
+
- CORS (W3C Private Network Access preflight refusal default + `allowPrivateNetwork` opt) and rate-limit are wired when configured via `middleware.cors` / `middleware.rateLimit`
|
|
135
131
|
- `Cache-Control: no-store` on every 401 from `requireAuth` / `requireAal` / `requireStepUp` per RFC 9111 §5.2.2.5
|
|
132
|
+
- **Additional middleware** to mount in your `routes` callback: compression, SSE, request logging, request-time DB role binding (`b.middleware.dbRoleFor`), in-process CIDR fence (`b.middleware.networkAllowlist`)
|
|
136
133
|
- **Outbound HTTP client** — HTTP/1.1 + HTTP/2 with SSRF gate (cloud-metadata IPs hard-denied; private / loopback / link-local overridable per call); scheme + userinfo + per-host destination allowlist; redirects, multipart, interceptors, progress, encrypted cookie jar (`b.httpClient`, `b.ssrfGuard`, `b.safeUrl`)
|
|
137
134
|
- **Network configurability (`b.network`)** — env-driven NTP / NTS (RFC 8915), IPv4/IPv6 NTP, DNS with IPv6 / DoH / DoT (private-CA pinning) / cache / lookup timeout; local DNSSEC signature verification (RFC 4035 — `b.network.dns.dnssec.verifyRrset` over a canonicalised RRset against RSA / ECDSA P-256·P-384 / Ed25519 DNSKEYs, plus DS-digest + key-tag, plus `verifyDenial` for NSEC / NSEC3 (RFC 5155) NXDOMAIN / NODATA proofs with iteration caps + Opt-Out handling, plus `verifyChain` to validate a full root→TLD→zone delegation chain against the pinned IANA root anchors) so a resolver client can verify both positive and negative answers instead of trusting the upstream AD bit; DANE / TLSA certificate matching (RFC 6698/7671 — `b.network.dns.dane.matchCertificate`) to pin a service's key through DNSSEC instead of a public CA; TSIG transaction signatures (RFC 8945 — `b.network.dns.tsig.sign` / `verify`) for shared-key HMAC authentication of zone transfers, dynamic updates, and query/response pairs, with constant-time MAC compare + fudge-window check (verified against dnspython); outbound HTTP proxy (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`); runtime DPI trust-store CA additions; application-level heartbeats; TCP socket defaults
|
|
138
135
|
- **Error pages** — operator-rendered, no app-frame leakage (`b.errorPage`)
|
|
@@ -341,6 +341,7 @@ This is the minimum-viable security posture for a production deployment. The fra
|
|
|
341
341
|
- [ ] For routes that accept SVG (avatar uploads, illustration / icon assets, mail attachments, file-upload widgets that allow image/svg+xml): wire `b.guardSvg.gate({ profile: "strict" })` — strict profile rejects every dangerous tag (script / foreignObject / animation family), denies cross-origin `<use>` references (defends server-side rasterization SSRF), refuses every DOCTYPE (defends billion-laughs entity expansion + XXE per CVE-2026-29074 class), refuses SVGZ payloads (operator must ungzip first), and enforces the SMIL animation attributeName allowlist (defends the recent CVE class where `<animate attributeName="href" to="javascript:..."/>` retroactively hijacks an element's href). For uploaded SVGs that need to be rendered, additionally serve under a strict CSP and consider rasterizing server-side to PNG before display
|
|
342
342
|
- [ ] For routes that emit or accept HTML (rich-text editors, comment systems, mail-rendered HTML bodies, server-rendered fragments composed from user data): wire `b.guardHtml.gate({ profile: "strict" })` into the relevant content-safety opt — strict profile applies the dangerous-tag denylist, the entire `on*` event-handler family, form-override attributes (CWE-1021), `srcdoc` + `is`, the URL-scheme allowlist (entity-decode pre-pass catches `javascript:` and decimal-entity bypasses), CSS injection in `style="..."`, DOM-clobbering id/name detection on form/input/button/a/img/iframe, mXSS hint flagging, IE conditional comments, and bidi / control / null / zero-width detection. For display surfaces that need to render the sanitized output, additionally serve under a strict Content-Security-Policy (`default-src 'none'` plus tight allowlist for what you actually need; or render inside a sandboxed iframe) — non-DOM HTML sanitizers cannot claim immunity from mXSS bypasses, so CSP is the second layer of defense
|
|
343
343
|
- [ ] **Default-on (v0.7.18+) — transport-layer smuggling defenses:** `b.middleware.bodyParser` rejects requests carrying both `Content-Length` and `Transfer-Encoding` (CL.TE / TE.CL smuggling — CVE-2022-31394 / CVE-2024-27316 class), multiple Content-Length values, Transfer-Encoding whose final coding is not `chunked`, and duplicate `chunked` tokens (TE.TE smuggling) — each rejected with HTTP 400 + `Connection: close`. `b.staticServe` resolves every requested path through `fs.realpathSync` (defeats symlink escape) AND validates the basename through `b.guardFilename` at the balanced profile (rejects path traversal / null-byte / NTFS alternate data streams / UNC / RTLO bidi / overlong UTF-8 / Windows reserved device names). `b.mail` SMTP transport runs `b.guardEmail.validateMessage` at strict profile on the produced RFC 822 wire BEFORE opening the socket — refuses outbound SMTP smuggling (CVE-2023-51764 Postfix / CVE-2023-51765 Sendmail / CVE-2023-51766 Exim / CVE-2026-32178 .NET class) even when operator-supplied subject / body / headers contain bare CR / bare LF + smuggled SMTP verbs. `b.mail.dkim.create` throws on `opts.bodyLength` (M³AAWG / Gmail / Microsoft 365 guidance — `l=` enables append-after-signature attacks). All four protections require zero operator wiring
|
|
344
|
+
- [ ] **Default-on (v0.13.46+) — createApp security middleware:** `b.createApp` wires the threat-aware cookie parser, CSP nonce, fetch-metadata resource-isolation guard, body parser, and CSRF protection (double-submit cookie + `Origin`/`Referer` cross-check) ON by default, ordered so CSRF validates after the body parser. State-changing requests carrying a session cookie are CSRF-validated with no operator wiring; `Authorization`-header (token-authenticated) and cookieless requests are auto-skipped as not-CSRF-able so API clients are not rejected. Configure cookie / field names through `middleware: { csrf: { cookie: { name }, fieldName } }` (operator names flow straight through — nothing is hardcoded) or opt out with `middleware: { csrf: false }` / `{ cspNonce: false }` etc.; disabling any of these security defaults emits an `app.middleware.disabled` audit row so a weakened posture is reconstructable in review
|
|
344
345
|
- [ ] **Default-on (v0.7.12+):** `b.fileUpload` and `b.staticServe` wire `b.guardAll.byExtension({ profile: "strict" })` automatically; `b.fileUpload` additionally wires `b.guardFilename.gate({ profile: "strict" })` as `filenameSafety`. No explicit operator action required for the baseline defense-in-depth. To opt up to a broader content vocabulary (e.g. you serve operator-built HTML with links + images), pass `contentSafety: b.guardAll.byExtension({ profile: "balanced" })` explicitly. To opt out entirely (test fixtures, raw-bytes uploads), pass `contentSafety: null` / `filenameSafety: null` with `contentSafetyDisabledReason` / `filenameSafetyDisabledReason` strings — both fire audit rows at create() time so a security review can reconstruct which deploys disabled the default-on protection. To skip a single guard while keeping the rest, pass `contentSafety: b.guardAll.byExtension({ exceptFor: { name: { reason: "..." } } })` — the reason lands in the `guardAll.gate.created` audit row. Future guards added to the family auto-extend the deploy without re-wiring
|
|
345
346
|
- [ ] For OAuth / OIDC RP callbacks: call `b.auth.oauth.parseCallback(query, opts?)` BEFORE consuming `code` — validates RFC 9207 AS Issuer Identifier (refuses iss-mismatch + OP `error=` redirects + state-mismatch CSRF). For FAPI 2.0 deployments add `b.fapi2.assertCallback(query)` (refuses missing iss; refuses bare-param under `fapi-2.0-message-signing` posture, requiring JARM `response`) and `b.fapi2.assertAuthzRequest(authzParams)` BEFORE issuing the redirect (refuses non-JAR authorization requests). For refresh-token flows, pass `seen({ jti, iss })` to `b.auth.oauth.refreshAccessToken` so reuse of an already-rotated token refuses BEFORE the HTTP exchange (RFC 9700 §4.13 / OAuth 2.1 §6.1)
|
|
346
347
|
- [ ] For OAuth authorization servers accepting RFC 9101 request objects (JAR): verify them with `b.auth.jar.parse(jar, { clientId, audience, algorithms, jwks })` BEFORE honoring the authorization request — it delegates the signature check to the alg-allowlisted `verifyExternal` (refuses `alg: "none"` / HMAC-vs-RSA confusion / JWE-on-JWS), pins `iss` + the `client_id` claim to the expected client and `aud` to your issuer, and refuses a nested `request` / `request_uri` (RFC 9101 §6.3); never accept an unsigned `request` parameter or trust query-string authorization parameters when a signed request object is available
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 1,
|
|
3
|
-
"frameworkVersion": "0.13.
|
|
4
|
-
"createdAt": "2026-05-
|
|
3
|
+
"frameworkVersion": "0.13.46",
|
|
4
|
+
"createdAt": "2026-05-30T02:15:44.237Z",
|
|
5
5
|
"exports": {
|
|
6
6
|
"a2a": {
|
|
7
7
|
"type": "object",
|
|
@@ -42813,6 +42813,10 @@
|
|
|
42813
42813
|
"type": "function",
|
|
42814
42814
|
"arity": 2
|
|
42815
42815
|
},
|
|
42816
|
+
"fetch": {
|
|
42817
|
+
"type": "function",
|
|
42818
|
+
"arity": 1
|
|
42819
|
+
},
|
|
42816
42820
|
"inspectMustStaple": {
|
|
42817
42821
|
"type": "function",
|
|
42818
42822
|
"arity": 1
|
|
@@ -376,6 +376,17 @@ async function buildApp(opts) {
|
|
|
376
376
|
refillPerSecond: 2,
|
|
377
377
|
skipPaths: ["/healthz", "/readyz"],
|
|
378
378
|
},
|
|
379
|
+
// cookies + cspNonce + fetchMetadata ride createApp's secure
|
|
380
|
+
// defaults. bodyParser + CSRF are configured here so the wiki's
|
|
381
|
+
// own cookie + field names flow through the default wiring rather
|
|
382
|
+
// than being mounted separately.
|
|
383
|
+
bodyParser: { urlencoded: true, json: true },
|
|
384
|
+
// CSRF double-submit cookie. Integration mode (WIKI_INTEGRATION_TEST=1,
|
|
385
|
+
// never set in production) disables CSRF so test POSTs against
|
|
386
|
+
// /test/* don't have to round-trip a token cookie.
|
|
387
|
+
csrf: integrationMode
|
|
388
|
+
? false
|
|
389
|
+
: { cookie: { name: "wiki_csrf" }, fieldName: "csrf" },
|
|
379
390
|
},
|
|
380
391
|
routes: function (router) {
|
|
381
392
|
router.use(healthChecks.middleware());
|
|
@@ -393,9 +404,12 @@ async function buildApp(opts) {
|
|
|
393
404
|
audit: b.audit,
|
|
394
405
|
}));
|
|
395
406
|
}
|
|
396
|
-
|
|
407
|
+
// bodyParser + cspNonce are wired by createApp (see the middleware
|
|
408
|
+
// block above). This cspNonce instance is kept ONLY for its
|
|
409
|
+
// PLACEHOLDER + substitute() cacheable-render helpers — they read
|
|
410
|
+
// the per-request req.cspNonce that createApp's wiring already set,
|
|
411
|
+
// so the instance is never mounted again (that would be a no-op).
|
|
397
412
|
var nonceMw = b.middleware.cspNonce();
|
|
398
|
-
router.use(nonceMw);
|
|
399
413
|
|
|
400
414
|
// Integration-test routes are mounted BEFORE attachUser /
|
|
401
415
|
// csrfProtect / staticServe so the test suite doesn't have to
|
|
@@ -427,17 +441,9 @@ async function buildApp(opts) {
|
|
|
427
441
|
return { userId: row.id, email: row.email, scopes: scopes };
|
|
428
442
|
},
|
|
429
443
|
}));
|
|
430
|
-
// CSRF
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
// round-trip a token cookie. Production deployments always run
|
|
434
|
-
// with the full middleware stack.
|
|
435
|
-
if (!integrationMode) {
|
|
436
|
-
router.use(b.middleware.csrfProtect({
|
|
437
|
-
cookie: { name: "wiki_csrf" },
|
|
438
|
-
fieldName: "csrf", // wiki templates use <input name="csrf">
|
|
439
|
-
}));
|
|
440
|
-
}
|
|
444
|
+
// CSRF is wired by createApp (see middleware.csrf above) — the
|
|
445
|
+
// wiki's wiki_csrf cookie + csrf field name flow through that
|
|
446
|
+
// default, so it is not mounted again here.
|
|
441
447
|
router.use(b.staticServe.create({
|
|
442
448
|
root: path.join(__dirname, "..", "public"),
|
|
443
449
|
}));
|
|
@@ -92,6 +92,7 @@
|
|
|
92
92
|
var nodeFs = require("node:fs");
|
|
93
93
|
var nodePath = require("node:path");
|
|
94
94
|
var appShutdown = require("./app-shutdown");
|
|
95
|
+
var audit = require("./audit");
|
|
95
96
|
var C = require("./constants");
|
|
96
97
|
var cluster = require("./cluster");
|
|
97
98
|
var db = require("./db");
|
|
@@ -103,13 +104,28 @@ var queue = require("./queue");
|
|
|
103
104
|
var routerMod = require("./router");
|
|
104
105
|
var vault = require("./vault");
|
|
105
106
|
|
|
106
|
-
function _resolveMiddlewareOpt(value, allowDefault) {
|
|
107
|
+
function _resolveMiddlewareOpt(value, allowDefault, name) {
|
|
107
108
|
// value can be:
|
|
108
109
|
// false — operator opted out
|
|
109
110
|
// undefined — fall back to allowDefault (mount with empty opts)
|
|
110
111
|
// true — explicit opt-in with default opts
|
|
111
112
|
// object — explicit opts
|
|
112
|
-
if (value === false)
|
|
113
|
+
if (value === false) {
|
|
114
|
+
// Operator explicitly disabled this middleware. When it's one of the
|
|
115
|
+
// security-on-by-default layers (allowDefault), leave an audit trace
|
|
116
|
+
// so the weakened posture is visible — Core Rule §3 security defaults
|
|
117
|
+
// shouldn't be silently opt-out-able. Drop-silent observability sink.
|
|
118
|
+
if (allowDefault && name) {
|
|
119
|
+
try {
|
|
120
|
+
audit.safeEmit({
|
|
121
|
+
action: "app.middleware.disabled",
|
|
122
|
+
outcome: "success",
|
|
123
|
+
metadata: { middleware: name },
|
|
124
|
+
});
|
|
125
|
+
} catch (_e) { /* drop-silent — by design */ }
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
113
129
|
if (value === undefined) return allowDefault ? {} : null;
|
|
114
130
|
if (value === true) return {};
|
|
115
131
|
if (value && typeof value === "object") return value;
|
|
@@ -196,21 +212,55 @@ async function createApp(opts) {
|
|
|
196
212
|
});
|
|
197
213
|
router.use(orchestrator.middleware());
|
|
198
214
|
|
|
199
|
-
var requestIdOpts = _resolveMiddlewareOpt(mwConfig.requestId, true);
|
|
215
|
+
var requestIdOpts = _resolveMiddlewareOpt(mwConfig.requestId, true, "requestId");
|
|
200
216
|
if (requestIdOpts) router.use(middleware.requestId(requestIdOpts));
|
|
201
217
|
|
|
202
|
-
var securityHeadersOpts = _resolveMiddlewareOpt(mwConfig.securityHeaders, true);
|
|
218
|
+
var securityHeadersOpts = _resolveMiddlewareOpt(mwConfig.securityHeaders, true, "securityHeaders");
|
|
203
219
|
if (securityHeadersOpts) router.use(middleware.securityHeaders(securityHeadersOpts));
|
|
204
220
|
|
|
205
|
-
var corsOpts = _resolveMiddlewareOpt(mwConfig.cors, false);
|
|
221
|
+
var corsOpts = _resolveMiddlewareOpt(mwConfig.cors, false, "cors");
|
|
206
222
|
if (corsOpts) router.use(middleware.cors(corsOpts));
|
|
207
223
|
|
|
208
|
-
var botGuardOpts = _resolveMiddlewareOpt(mwConfig.botGuard, true);
|
|
224
|
+
var botGuardOpts = _resolveMiddlewareOpt(mwConfig.botGuard, true, "botGuard");
|
|
209
225
|
if (botGuardOpts) router.use(middleware.botGuard(botGuardOpts));
|
|
210
226
|
|
|
211
|
-
var rateLimitOpts = _resolveMiddlewareOpt(mwConfig.rateLimit, false);
|
|
227
|
+
var rateLimitOpts = _resolveMiddlewareOpt(mwConfig.rateLimit, false, "rateLimit");
|
|
212
228
|
if (rateLimitOpts) router.use(middleware.rateLimit(rateLimitOpts));
|
|
213
229
|
|
|
230
|
+
// Security middleware wired ON by default (Core Rule §3). Each reads its
|
|
231
|
+
// config from opts.middleware.<name>: pass `false` to opt out (audited
|
|
232
|
+
// via _resolveMiddlewareOpt), or an object to customize — operator cookie
|
|
233
|
+
// / field names flow straight through, nothing static is baked in.
|
|
234
|
+
// Ordered so each layer has what it needs: cookies + cspNonce +
|
|
235
|
+
// fetchMetadata first, then bodyParser (so csrf can read a body-field
|
|
236
|
+
// token), then csrfProtect last. Every layer is idempotent — if an
|
|
237
|
+
// operator also mounts one of these inside opts.routes, the second mount
|
|
238
|
+
// is a no-op rather than a double-apply.
|
|
239
|
+
var cookiesOpts = _resolveMiddlewareOpt(mwConfig.cookies, true, "cookies");
|
|
240
|
+
if (cookiesOpts) router.use(middleware.cookies(cookiesOpts));
|
|
241
|
+
|
|
242
|
+
var cspNonceOpts = _resolveMiddlewareOpt(mwConfig.cspNonce, true, "cspNonce");
|
|
243
|
+
if (cspNonceOpts) router.use(middleware.cspNonce(cspNonceOpts));
|
|
244
|
+
|
|
245
|
+
var fetchMetadataOpts = _resolveMiddlewareOpt(mwConfig.fetchMetadata, true, "fetchMetadata");
|
|
246
|
+
if (fetchMetadataOpts) router.use(middleware.fetchMetadata(fetchMetadataOpts));
|
|
247
|
+
|
|
248
|
+
var bodyParserOpts = _resolveMiddlewareOpt(mwConfig.bodyParser, true, "bodyParser");
|
|
249
|
+
if (bodyParserOpts) router.use(middleware.bodyParser(bodyParserOpts));
|
|
250
|
+
|
|
251
|
+
var csrfOpts = _resolveMiddlewareOpt(mwConfig.csrf, true, "csrf");
|
|
252
|
+
if (csrfOpts) {
|
|
253
|
+
// Defaults: double-submit cookie (unless the operator chose a token
|
|
254
|
+
// lookup or their own cookie config) + skip validation for stateless
|
|
255
|
+
// token-API / cookieless requests. Operator config overrides both.
|
|
256
|
+
var csrfDefaults = { skipStateless: true };
|
|
257
|
+
if (csrfOpts.tokenLookup === undefined && csrfOpts.cookie === undefined) {
|
|
258
|
+
csrfDefaults.cookie = true;
|
|
259
|
+
}
|
|
260
|
+
csrfOpts = Object.assign(csrfDefaults, csrfOpts);
|
|
261
|
+
router.use(middleware.csrfProtect(csrfOpts));
|
|
262
|
+
}
|
|
263
|
+
|
|
214
264
|
// ---- 6. Operator routes ----
|
|
215
265
|
if (typeof opts.routes === "function") {
|
|
216
266
|
opts.routes(router);
|
|
@@ -243,6 +243,7 @@ var FRAMEWORK_NAMESPACES = [
|
|
|
243
243
|
"auth", "system", "audit", "consent", "subject",
|
|
244
244
|
// Per-primitive namespaces — keep alphabetical
|
|
245
245
|
"apikey", // b.apiKey
|
|
246
|
+
"app", // b.createApp (app.middleware.disabled — a security-default middleware was opted out at construction)
|
|
246
247
|
"backup", // b.backup
|
|
247
248
|
"breakglass", // b.breakGlass — column-policy / row-enforcement step-up auth (audit namespace lowercased per the validator's `namespace.verb` rule, same convention as b.apiKey → apikey.*)
|
|
248
249
|
"cache", // b.cache
|