@blamejs/core 0.7.19 → 0.7.20

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 CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.7.x
10
10
 
11
+ - **0.7.20** (2026-05-05) — browser-hardening batch (5 fixes): CSRF Origin/Referer cross-check, default CSP gains Trusted Types, expanded Permissions-Policy, `__Host-` / `__Secure-` cookie prefix invariants, `b.middleware.fetchMetadata`. **CSRF Origin/Referer cross-check** (`b.middleware.csrfProtect`) — second-line defense alongside the double-submit token. State-changing requests whose Origin (or Referer when Origin is absent) doesn't resolve to the request's own origin are refused before the token check. Defaults ON; operator opt-out via `checkOrigin: false`; operator allowlist via `allowedOrigins: ["https://app.example.com"]`. Missing-Origin-AND-Missing-Referer (curl, server-to-server) defers to the token check. **Trusted Types in default CSP** (`b.middleware.securityHeaders`) — default CSP now includes `require-trusted-types-for 'script'; trusted-types 'allow-duplicates' default;`. Compatible browsers (Chrome 83+, Edge 83+) enforce typed-value DOM-sink writes (defends every untrusted-string-to-DOM XSS vector at runtime); Firefox + Safari ignore (no regression). Operators opt out by passing an explicit `csp:` value. **Expanded Permissions-Policy** — defaults now disable `browsing-topics=()`, `attribution-reporting=()`, `unload=()`, `interest-cohort=()`, `join-ad-interest-group=()`, `run-ad-auction=()`, `private-state-token-issuance=()`, `private-state-token-redemption=()`, `compute-pressure=()`, `hid=()`, `serial=()`, `idle-detection=()` — closes advertising / tracking / load-event-leak / device-API surfaces. Existing operator overrides via `permissionsPolicy: "..."` continue unchanged. **`__Host-` / `__Secure-` cookie prefix invariants** (`b.cookies.serialize`) — RFC 6265bis §4.1.3 enforced at serialize time. `__Secure-*` requires `secure: true`; `__Host-*` requires `secure: true` AND `path: "/"` AND no `domain:`. Each violation throws a typed `CookieError` with operator-actionable code (`cookies/prefix-secure-required`, `cookies/prefix-host-secure-required`, `cookies/prefix-host-path-required`, `cookies/prefix-host-no-domain`) instead of producing a malformed cookie that browsers silently reject. **`b.middleware.fetchMetadata`** — new fetch-metadata isolation primitive. Reads `Sec-Fetch-Site` / `Sec-Fetch-Mode` / `Sec-Fetch-Dest` and refuses cross-site state-changing requests by default. Operators opt in to specific destinations (e.g. `allowedDest: ["empty", "document"]`) or specific origins (`allowCrossSite: true`). Direct navigations (typed URL / bookmark — `Sec-Fetch-Site: none`) pass through; `same-origin` always passes; `same-site` configurable. Missing fetch-metadata (legacy browsers, server-to-server) deferred to other auth/CSRF layers per `allowMissing: true` default. Smoke 8481 / wiki e2e 178 / Linux container smoke 8481 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
12
+
11
13
  - **0.7.19** (2026-05-05) — auth-primitives batch (5 fixes): session idle/absolute timeout, JWT keyResolver for kid rotation, `b.middleware.bearerAuth`, `b.auth.jwt.verifyExternal` (classical-alg JWT verifier with JWKS support), and Argon2id parameter audit visibility. **Session idle + absolute timeouts** (`b.session.verify`) now enforce OWASP ASVS 5.0 §3.3 / NIST SP 800-63B-4 — defaults: 30 min idle, 12 hours absolute. Operators opt out per-call by passing `idleTimeoutMs: 0` / `absoluteTimeoutMs: 0`. Both surface `auth.session.expired_idle` / `auth.session.expired_absolute` audit events on enforcement. **JWT `keyResolver` opt** (`b.auth.jwt.verify`) — operator passes `keyResolver(decodedHeader)` to look up the public key per-token (typically by `kid`). Mutually exclusive with `opts.publicKey`. Async-friendly. Closes the kid-rotation gap where signers carried `kid` but verifiers only accepted a single static key. **`b.middleware.bearerAuth`** — new middleware that extracts `Authorization: Bearer <token>`, runs an operator-supplied `verify(token)` function, and attaches `req.user`. Distinct from cookie-session `attachUser`. Missing-Authorization passes through (so cookie path can take over); invalid/null/throw rejects 401 + `WWW-Authenticate: Bearer error="invalid_token"` per RFC 6750 §3. **`b.auth.jwt.verifyExternal`** — new generic classical-alg JWT verifier (RS256 / RS384 / RS512 / PS256 / PS384 / PS512 / ES256 / ES384 / ES512 / EdDSA) for integration with external IdPs (Auth0 / Okta / Keycloak / Cognito / Azure AD / Google / Apple). `algorithms` is REQUIRED with no default — defends the alg-confusion class (CVE-2024-54150 / CVE-2025-30144 / CVE-2026-22817 Hono). HMAC algs and `none` are explicitly refused (HMAC + JWKS public-key trust source IS the alg-confusion vector). Three key-source options: `jwks` (pre-fetched array), `jwksUri` (auto-fetched + TTL-cached via `b.httpClient` SSRF gate), `keyResolver` (custom). Standard claim checks (`exp` / `nbf` / `iat` / `aud` / `iss` / `sub`) with operator-tunable `clockSkewMs`. **`b.auth.password.params()`** — new accessor returning the active Argon2id params (`memoryCostKib` / `timeCost` / `parallelism`) plus the OWASP 2026 floor (`19 MiB` / `t>=2` / `p>=1`) plus `meetsFloor: bool`. Compliance-audit visibility without parsing PHC strings. Smoke 8458 → 8481 / wiki e2e 178 / Linux container smoke 8481 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
12
14
 
13
15
  - **0.7.18** (2026-05-05) — transport-layer smuggling hardening (four ship-blocker fixes). **HTTP request-smuggling defense** in `b.middleware.bodyParser` per RFC 9112 §6.1: rejects requests with both `Content-Length` and `Transfer-Encoding` headers (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 rejection responds 400 + `Connection: close` so the upstream proxy doesn't reuse the socket. **Static-serve symlink-escape + filename safety** in `b.staticServe`: `_resolveSafe` now `fs.realpathSync`-es the resolved path (defeats symlink-out-of-root) AND validates the basename through `b.guardFilename` at the balanced profile (rejects path traversal, null-byte, NTFS alternate data streams, UNC paths, RTLO bidi, overlong UTF-8, Windows reserved device names, double-extension; balanced profile chosen over strict so legitimate operator-deposited shell-exec extensions like `.exe`/`.bin` remain serveable). **Outbound SMTP smuggling defense** in `b.mail` SMTP transport: every produced RFC 822 wire (post-DKIM-sign) is run through `b.guardEmail.validateMessage` at strict profile before the socket opens; refuses on critical issues — bare CR / bare LF + smuggled SMTP verbs (CVE-2023-51764 Postfix / CVE-2023-51765 Sendmail / CVE-2023-51766 Exim / CVE-2026-32178 .NET class) cannot leave the framework even when operator-supplied subject/body/headers contain the pattern. **DKIM `l=` body-length tag forbidden** in `b.mail.dkim.create`: passing `bodyLength` now throws `dkim/l-tag-forbidden` at create-time. M³AAWG / Gmail / Microsoft 365 guidance is "never use l=" — it enables append-after-signature attacks where an attacker appends arbitrary content past the signed length and the DKIM signature still validates against the original prefix. The body is always hashed in full. Smoke 8450 → 8458 / wiki e2e 178 / Linux container smoke 8458 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
package/lib/cookies.js CHANGED
@@ -144,6 +144,41 @@ function serialize(name, value, attrs) {
144
144
  _validateValue(value);
145
145
  attrs = attrs || {};
146
146
 
147
+ // RFC 6265bis §4.1.3 cookie-prefix invariants — refused at serialize-
148
+ // time so an operator-side typo doesn't ship a broken cookie that
149
+ // browsers silently reject (and the operator wonders why their cookie
150
+ // never sets).
151
+ //
152
+ // __Secure-* — MUST be Secure
153
+ // __Host-* — MUST be Secure, Path=/, NO Domain
154
+ //
155
+ // Caught at the source so every caller (csrf-protect / session /
156
+ // operator) gets the same enforcement.
157
+ if (name.indexOf("__Secure-") === 0) {
158
+ if (attrs.secure !== true) {
159
+ throw new CookieError("cookies/prefix-secure-required",
160
+ "__Secure-* cookies MUST set Secure (RFC 6265bis §4.1.3.1) — got '" +
161
+ name + "' without secure: true");
162
+ }
163
+ }
164
+ if (name.indexOf("__Host-") === 0) {
165
+ if (attrs.secure !== true) {
166
+ throw new CookieError("cookies/prefix-host-secure-required",
167
+ "__Host-* cookies MUST set Secure (RFC 6265bis §4.1.3.2) — got '" +
168
+ name + "' without secure: true");
169
+ }
170
+ if (attrs.path !== "/") {
171
+ throw new CookieError("cookies/prefix-host-path-required",
172
+ "__Host-* cookies MUST set Path=/ (RFC 6265bis §4.1.3.2) — got '" +
173
+ name + "' with path=" + JSON.stringify(attrs.path || "<unset>"));
174
+ }
175
+ if (attrs.domain) {
176
+ throw new CookieError("cookies/prefix-host-no-domain",
177
+ "__Host-* cookies MUST NOT set Domain (RFC 6265bis §4.1.3.2) — got '" +
178
+ name + "' with domain=" + JSON.stringify(attrs.domain));
179
+ }
180
+ }
181
+
147
182
  var parts = [name + "=" + encodeURIComponent(value)];
148
183
 
149
184
  if (attrs.maxAge !== undefined && attrs.maxAge !== null) {
@@ -155,6 +155,63 @@ function _appendSetCookie(res, value) {
155
155
  // owns size caps, content-type dispatch, and prototype-pollution
156
156
  // defense; csrf-protect just reads the validated value.
157
157
 
158
+ // Origin / Referer cross-check helper. Returns null when the request's
159
+ // origin is acceptable, or a short reason string ("origin-mismatch" /
160
+ // "referer-mismatch" / "no-origin-or-referer" / "malformed-url") when
161
+ // it should be refused. Compares against opts.allowedOrigins (when
162
+ // supplied) OR same-origin (the request's own Host header).
163
+ //
164
+ // Implementation note — we deliberately don't run safeUrl here; safeUrl
165
+ // throws on file:// / data:// schemes which would crash the middleware
166
+ // instead of refusing the request. URL constructor + try/catch is the
167
+ // right shape for "is this URL well-formed and what's its origin?".
168
+ function _checkOriginAllowed(req, allowedOrigins, isHttpsFn) {
169
+ var headers = req.headers || {};
170
+ var origin = headers.origin;
171
+ var referer = headers.referer;
172
+ if (typeof origin !== "string" && typeof referer !== "string") {
173
+ // No Origin/Referer at all — common for non-browser clients
174
+ // (curl, server-to-server). The token check still applies; this
175
+ // gate doesn't add to it. Defense-in-depth against a stolen
176
+ // cookie via a browser-rendered cross-origin fetch IS the value;
177
+ // headless clients carry their own auth threat model.
178
+ return null;
179
+ }
180
+
181
+ var requestOrigin = (isHttpsFn && isHttpsFn(req) ? "https://" : "http://") +
182
+ (headers.host || "");
183
+
184
+ function _originOf(rawUrl) {
185
+ try {
186
+ var u = new URL(rawUrl); // allow:raw-new-url — origin-shape inspection (NOT outbound). Intentionally tolerates file:// / data: which safeUrl.parse refuses.
187
+ return u.origin; // "https://host:port" — no path / query / fragment
188
+ } catch (_e) { return null; }
189
+ }
190
+
191
+ function _isAllowed(candidateOrigin) {
192
+ if (!candidateOrigin) return false;
193
+ if (candidateOrigin === requestOrigin) return true;
194
+ if (Array.isArray(allowedOrigins)) {
195
+ for (var i = 0; i < allowedOrigins.length; i += 1) {
196
+ if (candidateOrigin === allowedOrigins[i]) return true;
197
+ }
198
+ }
199
+ return false;
200
+ }
201
+
202
+ if (typeof origin === "string" && origin.length > 0) {
203
+ var oo = _originOf(origin);
204
+ if (oo === null) return "malformed-origin";
205
+ if (!_isAllowed(oo)) return "origin-mismatch (" + oo + " vs " + requestOrigin + ")";
206
+ return null;
207
+ }
208
+ // Origin absent — fall back to Referer.
209
+ var ro = _originOf(referer);
210
+ if (ro === null) return "malformed-referer";
211
+ if (!_isAllowed(ro)) return "referer-mismatch (" + ro + " vs " + requestOrigin + ")";
212
+ return null;
213
+ }
214
+
158
215
  function _writeReject(res, message) {
159
216
  if (typeof res.writeHead === "function") {
160
217
  var body = JSON.stringify({ error: message });
@@ -171,7 +228,7 @@ function create(opts) {
171
228
 
172
229
  validateOpts(opts, [
173
230
  "cookie", "tokenLookup", "fieldName", "headerName", "methods", "audit",
174
- "trustProxy",
231
+ "trustProxy", "checkOrigin", "allowedOrigins",
175
232
  ], "middleware.csrfProtect");
176
233
  var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
177
234
  ? opts.trustProxy : false;
@@ -193,6 +250,20 @@ function create(opts) {
193
250
  var methods = (opts.methods || DEFAULT_METHODS).map(function (m) { return m.toUpperCase(); });
194
251
  var auditOn = opts.audit !== false;
195
252
 
253
+ // Origin / Referer cross-check — second-line defense alongside the
254
+ // double-submit token. If the request's Origin (or Referer when
255
+ // Origin is absent — Safari pre-12, certain CORS mode requests)
256
+ // doesn't resolve to a same-origin or operator-allowlisted origin,
257
+ // refuse before the token check.
258
+ //
259
+ // Default: enabled (defense-in-depth — same shape as bot-guard /
260
+ // rate-limit / CSP nonce — every default ON per Core Rule §3).
261
+ // Operator opt-out: opts.checkOrigin = false.
262
+ // Operator allowlist: opts.allowedOrigins = ["https://app.example.com"].
263
+ var checkOrigin = opts.checkOrigin !== false;
264
+ var allowedOrigins = Array.isArray(opts.allowedOrigins)
265
+ ? opts.allowedOrigins.slice() : null;
266
+
196
267
  // Cookie issuance config (only when opts.cookie is set).
197
268
  var cookieCfg = null;
198
269
  if (hasCookie) {
@@ -282,6 +353,18 @@ function create(opts) {
282
353
 
283
354
  if (methods.indexOf(req.method) === -1) return next();
284
355
 
356
+ // Origin / Referer cross-check (defense-in-depth alongside the
357
+ // double-submit token). Refuses cross-origin state-changing
358
+ // requests even when the token is valid (e.g. operator-mistaken
359
+ // CORS configuration that exposes the cookie).
360
+ if (checkOrigin) {
361
+ var originReason = _checkOriginAllowed(req, allowedOrigins, _isHttps);
362
+ if (originReason !== null) {
363
+ _emitDenied(req, "origin/referer: " + originReason);
364
+ return _writeReject(res, "CSRF cross-origin request refused.");
365
+ }
366
+ }
367
+
285
368
  if (!cookieCfg) {
286
369
  // Session-stored mode — operator's tokenLookup is the source.
287
370
  expected = opts.tokenLookup(req);
@@ -0,0 +1,129 @@
1
+ "use strict";
2
+ /**
3
+ * fetch-metadata — Sec-Fetch-Site / Sec-Fetch-Mode / Sec-Fetch-Dest
4
+ * isolation primitive (Resource Isolation Policy / Site Isolation —
5
+ * https://web.dev/fetch-metadata/).
6
+ *
7
+ * Browsers attach Sec-Fetch-* headers describing the FETCH context
8
+ * (same-site? cross-site? typed-URL? navigation? script load?).
9
+ * This middleware refuses cross-site requests on state-changing
10
+ * methods unless the operator explicitly allowlists them — a second-
11
+ * line defense alongside CSRF tokens.
12
+ *
13
+ * var fmGate = b.middleware.fetchMetadata({
14
+ * allowSameSite: true, // default
15
+ * allowCrossSite: false, // default — refuse cross-site state changes
16
+ * allowedDest: ["empty", "document"], // operator allowlist of Sec-Fetch-Dest
17
+ * allowedNavigate: true, // allow direct navigations (typed URL / bookmark)
18
+ * methods: ["POST","PUT","DELETE","PATCH"],
19
+ * audit: true,
20
+ * });
21
+ * router.use("/api", fmGate);
22
+ *
23
+ * Refusal shape: 403 application/json + audit row. Same-origin /
24
+ * same-site requests pass through; cross-site refused unless
25
+ * allowedDest contains the request's Sec-Fetch-Dest. None / undefined
26
+ * (legacy browsers without fetch-metadata) is treated per
27
+ * `allowMissing` (default true — don't break older clients).
28
+ *
29
+ * Fail-open posture: when the request is missing Sec-Fetch-* entirely
30
+ * (curl, server-to-server, browser <Chrome 76 / <Firefox 90 / <Safari
31
+ * 16.4), the gate defers to other auth/CSRF layers. The browser-fetch-
32
+ * metadata isolation IS the value-add; non-browser clients carry their
33
+ * own auth threat model.
34
+ */
35
+
36
+ var requestHelpers = require("../request-helpers");
37
+ var validateOpts = require("../validate-opts");
38
+ var lazyRequire = require("../lazy-require");
39
+
40
+ var audit = lazyRequire(function () { return require("../audit"); });
41
+ var observability = lazyRequire(function () { return require("../observability"); });
42
+
43
+ var DEFAULT_METHODS = Object.freeze(["POST", "PUT", "DELETE", "PATCH"]);
44
+
45
+ function _writeReject(res, message) {
46
+ if (res.headersSent) return;
47
+ var body = JSON.stringify({ error: message });
48
+ res.writeHead(requestHelpers.HTTP_STATUS.FORBIDDEN, {
49
+ "Content-Type": "application/json; charset=utf-8",
50
+ "Content-Length": Buffer.byteLength(body),
51
+ });
52
+ res.end(body);
53
+ }
54
+
55
+ function create(opts) {
56
+ opts = opts || {};
57
+ validateOpts(opts, [
58
+ "allowSameSite", "allowCrossSite", "allowMissing",
59
+ "allowedDest", "allowedNavigate", "methods", "audit",
60
+ ], "middleware.fetchMetadata");
61
+
62
+ var allowSameSite = opts.allowSameSite !== false;
63
+ var allowCrossSite = opts.allowCrossSite === true;
64
+ var allowMissing = opts.allowMissing !== false;
65
+ var allowedDest = Array.isArray(opts.allowedDest) ? opts.allowedDest.slice() : null;
66
+ var allowedNavigate = opts.allowedNavigate !== false;
67
+ var methods = (opts.methods || DEFAULT_METHODS).map(function (m) { return m.toUpperCase(); });
68
+ var auditOn = opts.audit !== false;
69
+
70
+ function _emitDenied(req, reason) {
71
+ if (!auditOn) return;
72
+ try {
73
+ audit().safeEmit({
74
+ action: "auth.fetch_metadata.denied",
75
+ outcome: "denied",
76
+ actor: requestHelpers.extractActorContext(req),
77
+ reason: reason,
78
+ metadata: { method: req.method, path: (req.url || "").split("?")[0] },
79
+ });
80
+ } catch (_e) { /* audit best-effort */ }
81
+ }
82
+
83
+ return function fetchMetadata(req, res, next) {
84
+ if (methods.indexOf(req.method) === -1) return next();
85
+
86
+ var headers = req.headers || {};
87
+ var site = headers["sec-fetch-site"];
88
+ var mode = headers["sec-fetch-mode"];
89
+ var dest = headers["sec-fetch-dest"];
90
+
91
+ if (typeof site !== "string" || site.length === 0) {
92
+ // No Sec-Fetch-Site header — legacy browser or non-browser client.
93
+ // Defer to other auth/CSRF layers per allowMissing.
94
+ if (!allowMissing) {
95
+ _emitDenied(req, "fetch-metadata-missing");
96
+ return _writeReject(res, "Fetch-metadata required.");
97
+ }
98
+ return next();
99
+ }
100
+
101
+ // Direct navigations — typed URL, bookmark, history navigation.
102
+ if (site === "none") {
103
+ if (allowedNavigate) return next();
104
+ _emitDenied(req, "navigate-disallowed");
105
+ return _writeReject(res, "Direct navigation not allowed for this method.");
106
+ }
107
+
108
+ if (site === "same-origin") return next();
109
+
110
+ if (site === "same-site") {
111
+ if (allowSameSite) return next();
112
+ _emitDenied(req, "same-site-disallowed");
113
+ return _writeReject(res, "Same-site request not allowed.");
114
+ }
115
+
116
+ // cross-site
117
+ if (allowCrossSite) return next();
118
+ if (allowedDest && typeof dest === "string" && allowedDest.indexOf(dest) !== -1) {
119
+ return next();
120
+ }
121
+ _emitDenied(req, "cross-site-refused (mode=" + (mode || "?") +
122
+ ", dest=" + (dest || "?") + ")");
123
+ try { observability().count("auth.fetch_metadata.cross_site_refused", 1, {}); }
124
+ catch (_e) { /* best-effort */ }
125
+ return _writeReject(res, "Cross-site request refused.");
126
+ };
127
+ }
128
+
129
+ module.exports = { create: create };
@@ -27,6 +27,7 @@ var cspNonce = require("./csp-nonce");
27
27
  var csrfProtect = require("./csrf-protect");
28
28
  var dbRoleFor = require("./db-role-for");
29
29
  var errorHandler = require("./error-handler");
30
+ var fetchMetadata = require("./fetch-metadata");
30
31
  var health = require("./health");
31
32
  var networkAllowlist = require("./network-allowlist");
32
33
  var rateLimit = require("./rate-limit");
@@ -47,6 +48,7 @@ module.exports = {
47
48
  bearerAuth: bearerAuth.create,
48
49
  requireAuth: requireAuth.create,
49
50
  csrfProtect: csrfProtect.create,
51
+ fetchMetadata: fetchMetadata.create,
50
52
  bodyParser: bodyParser.create,
51
53
  health: health.create,
52
54
  compression: compression.create,
@@ -69,6 +71,7 @@ module.exports = {
69
71
  bearerAuth: bearerAuth,
70
72
  requireAuth: requireAuth,
71
73
  csrfProtect: csrfProtect,
74
+ fetchMetadata: fetchMetadata,
72
75
  bodyParser: bodyParser,
73
76
  health: health,
74
77
  compression: compression,
@@ -46,12 +46,15 @@ var DEFAULT_PERMISSIONS = [
46
46
  "screen-wake-lock=()", "sync-xhr=()", "usb=()", "web-share=()", "xr-spatial-tracking=()",
47
47
  ];
48
48
 
49
- // Strict CSP — no 'unsafe-inline' on script-src OR style-src. Operators
50
- // with inline styles / scripts wire `b.middleware.cspNonce()` and use
51
- // `{{ cspNonce }}` in their views (or req.cspNonce in handlers); the
52
- // nonce hooks into the policy via the `csp` opt or a custom value.
53
- // Operators in transitional environments who genuinely need inline
54
- // styles set `csp` explicitly to the loose form.
49
+ // Strict CSP — no 'unsafe-inline' on script-src OR style-src.
50
+ // Trusted Types (require-trusted-types-for 'script') enables the
51
+ // browser's strongest XSS-mitigation primitive DOM-sink writes via
52
+ // innerHTML / outerHTML / setHTML require typed values, surfacing
53
+ // every untrusted-string-to-DOM path at runtime so operators can audit
54
+ // + fix them. Compatible browsers (Chrome 83+, Edge 83+) enforce;
55
+ // Firefox + Safari ignore (no regression). Operators with inline
56
+ // scripts wire `b.middleware.cspNonce()` and use `{{ cspNonce }}` in
57
+ // views.
55
58
  var DEFAULT_CSP =
56
59
  "default-src 'self'; " +
57
60
  "script-src 'self'; " +
@@ -62,7 +65,9 @@ var DEFAULT_CSP =
62
65
  "frame-ancestors 'none'; " +
63
66
  "base-uri 'self'; " +
64
67
  "form-action 'self'; " +
65
- "object-src 'none';";
68
+ "object-src 'none'; " +
69
+ "require-trusted-types-for 'script'; " +
70
+ "trusted-types 'allow-duplicates' default;";
66
71
 
67
72
  function create(opts) {
68
73
  opts = opts || {};
package/lib/static.js CHANGED
@@ -168,19 +168,21 @@ function _resolveSafe(root, requestedPath) {
168
168
 
169
169
  // Symlink-escape defense — the lexical resolve above only sees the
170
170
  // requested path tokens; a symlink anywhere along `resolved` can
171
- // still point outside `rootResolved` on disk. realpath every node
172
- // (only when it exists; missing files are routed through the
173
- // standard 404 path by the caller).
171
+ // still point outside `rootResolved` on disk. Compare via realpath
172
+ // for the escape check ONLY; do NOT substitute the realpath result
173
+ // for `resolved`. Substituting breaks downstream consumers that key
174
+ // on the lexical path (revoke list, ETag cache, audit row) AND
175
+ // breaks deploys where the OS prefix-symlinks the temp dir
176
+ // (macOS: /var/folders/X/Y → /private/var/folders/X/Y).
174
177
  try {
175
178
  var real = fs.realpathSync(resolved);
176
179
  var rootReal = fs.realpathSync(rootResolved);
177
180
  if (real !== rootReal && !real.startsWith(rootReal + path.sep)) return null;
178
- resolved = real;
179
181
  } catch (_e) {
180
182
  // Path doesn't exist (or is denied) — fall through with the lexical
181
183
  // resolution so the caller's stat() returns the natural ENOENT and
182
184
  // 404s. realpath failures from non-existence are NOT a smuggling
183
- // signal.
185
+ // signal; the lexical bound check above already rejected escapes.
184
186
  }
185
187
 
186
188
  // Filename safety — the basename gates against path-traversal /
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.7.19",
3
+ "version": "0.7.20",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:3cf8a44f-f452-436c-a92e-cc1ae392a5e5",
5
+ "serialNumber": "urn:uuid:be5934ef-c4b9-4516-a8ee-b8bb2c31ee26",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-05T05:30:46.323Z",
8
+ "timestamp": "2026-05-05T05:52:29.844Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.7.19",
22
+ "bom-ref": "@blamejs/core@0.7.20",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.7.19",
25
+ "version": "0.7.20",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.7.19",
29
+ "purl": "pkg:npm/%40blamejs/core@0.7.20",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.7.19",
57
+ "ref": "@blamejs/core@0.7.20",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]