@blamejs/core 0.7.4 → 0.7.19

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.
@@ -0,0 +1,152 @@
1
+ "use strict";
2
+ /**
3
+ * bearer-auth middleware — extracts `Authorization: Bearer <token>`,
4
+ * runs an operator-supplied verifier, and attaches the result to
5
+ * `req.user`. Distinct from `attachUser` (which reads session cookies)
6
+ * — this is the API-token / JWT / OAuth-access-token path.
7
+ *
8
+ * Mount this for routes that accept bearer tokens. Operators that
9
+ * accept BOTH cookie sessions AND bearer tokens mount both: bearerAuth
10
+ * runs first; if no Bearer header, it calls next() so attachUser /
11
+ * requireAuth can take over. If a Bearer header IS present but invalid,
12
+ * bearerAuth rejects with 401 immediately (avoids the
13
+ * "is this a bearer or a cookie session?" collision in attach-user.js).
14
+ *
15
+ * var bearer = b.middleware.bearerAuth({
16
+ * verify: async function (token) {
17
+ * // operator-supplied: return a user object or null/throw
18
+ * var rec = await b.apiKey.verify(token);
19
+ * return rec ? { id: rec.ownerId, scopes: rec.scopes } : null;
20
+ * },
21
+ * audit: true, // default
22
+ * scheme: "Bearer", // default; some ops use "Token"
23
+ * errorMessage: "Bearer token required.",
24
+ * });
25
+ * router.use("/api", bearer);
26
+ *
27
+ * Verify result shape:
28
+ * - object truthy → req.user = result; next()
29
+ * - null / undefined / false → 401 (token invalid)
30
+ * - throws an Error with .code === "auth-bearer/expired" → 401 + WWW-Authenticate
31
+ *
32
+ * Audit: `auth.bearer.success` on accept; `auth.bearer.failure` with
33
+ * reason on reject. Both carry actor context (clientIp, userAgent,
34
+ * route).
35
+ */
36
+
37
+ var lazyRequire = require("../lazy-require");
38
+ var requestHelpers = require("../request-helpers");
39
+ var validateOpts = require("../validate-opts");
40
+ var { AuthError } = require("../framework-error");
41
+
42
+ var audit = lazyRequire(function () { return require("../audit"); });
43
+ var observability = lazyRequire(function () { return require("../observability"); });
44
+
45
+ function _writeUnauthorized(res, scheme, message, realm) {
46
+ if (res.headersSent) return;
47
+ var body = JSON.stringify({ error: message });
48
+ var challenge = scheme + (realm ? ' realm="' + realm + '"' : "");
49
+ res.writeHead(401, { // allow:raw-byte-literal — HTTP 401 status
50
+ "Content-Type": "application/json; charset=utf-8",
51
+ "Content-Length": Buffer.byteLength(body),
52
+ "WWW-Authenticate": challenge,
53
+ });
54
+ res.end(body);
55
+ }
56
+
57
+ function _extractToken(req, scheme) {
58
+ var h = req.headers && req.headers.authorization;
59
+ if (typeof h !== "string" || h.length === 0) return null;
60
+ var prefix = scheme + " ";
61
+ if (h.length <= prefix.length) return null;
62
+ if (h.slice(0, prefix.length).toLowerCase() !== prefix.toLowerCase()) return null;
63
+ var token = h.slice(prefix.length).trim();
64
+ return token.length > 0 ? token : null;
65
+ }
66
+
67
+ function create(opts) {
68
+ opts = opts || {};
69
+ validateOpts(opts, [
70
+ "verify", "audit", "scheme", "errorMessage", "realm",
71
+ "tokenAttachKey", "userAttachKey",
72
+ ], "middleware.bearerAuth");
73
+
74
+ if (typeof opts.verify !== "function") {
75
+ throw new AuthError("auth-bearer/missing-verify",
76
+ "middleware.bearerAuth requires a verify(token) function — operators MUST supply " +
77
+ "the verification path (b.apiKey.verify / b.auth.jwt.verifyExternal / custom)");
78
+ }
79
+ var auditOn = opts.audit !== false;
80
+ var scheme = opts.scheme || "Bearer";
81
+ var errorMessage = opts.errorMessage || "Bearer token required.";
82
+ var realm = opts.realm || null;
83
+ var tokenAttach = opts.tokenAttachKey || "bearerToken";
84
+ var userAttach = opts.userAttachKey || "user";
85
+
86
+ function _emitAudit(action, outcome, req, reason) {
87
+ if (!auditOn) return;
88
+ try {
89
+ var actor = requestHelpers.extractActorContext(req);
90
+ audit().safeEmit({
91
+ action: action, outcome: outcome,
92
+ metadata: Object.assign({}, actor, {
93
+ route: req.url,
94
+ method: req.method,
95
+ reason: reason || null,
96
+ }),
97
+ });
98
+ } catch (_e) { /* audit best-effort */ }
99
+ }
100
+
101
+ function _emitObs(metric, n, tags) {
102
+ try { observability().count(metric, n, tags || {}); }
103
+ catch (_e) { /* best-effort */ }
104
+ }
105
+
106
+ return async function bearerAuth(req, res, next) {
107
+ var token = _extractToken(req, scheme);
108
+ if (!token) {
109
+ // No Bearer header — fall through. Cookie-based session middleware
110
+ // running after this can attach a user via the cookie path.
111
+ return next();
112
+ }
113
+
114
+ var user;
115
+ try {
116
+ user = await opts.verify(token);
117
+ } catch (e) {
118
+ var code = (e && e.code) || "auth-bearer/verify-failed";
119
+ _emitAudit("auth.bearer.failure", "failure", req, code);
120
+ _emitObs("auth.bearer.rejected", 1, { reason: code });
121
+ // Per RFC 6750 §3 — `Bearer error="invalid_token"` is the
122
+ // standardized challenge for verifier-rejected tokens.
123
+ var challenge = scheme + ' error="invalid_token"' +
124
+ (realm ? ', realm="' + realm + '"' : "");
125
+ if (!res.headersSent) {
126
+ var body = JSON.stringify({ error: errorMessage });
127
+ res.writeHead(401, { // allow:raw-byte-literal — HTTP 401 status
128
+ "Content-Type": "application/json; charset=utf-8",
129
+ "Content-Length": Buffer.byteLength(body),
130
+ "WWW-Authenticate": challenge,
131
+ });
132
+ res.end(body);
133
+ }
134
+ return;
135
+ }
136
+
137
+ if (!user) {
138
+ _emitAudit("auth.bearer.failure", "failure", req, "verifier-returned-null");
139
+ _emitObs("auth.bearer.rejected", 1, { reason: "verifier-null" });
140
+ _writeUnauthorized(res, scheme, errorMessage, realm);
141
+ return;
142
+ }
143
+
144
+ req[tokenAttach] = token;
145
+ req[userAttach] = user;
146
+ _emitAudit("auth.bearer.success", "success", req, null);
147
+ _emitObs("auth.bearer.accepted", 1, {});
148
+ next();
149
+ };
150
+ }
151
+
152
+ module.exports = { create: create };
@@ -241,6 +241,68 @@ function _hasBody(req) {
241
241
  return false;
242
242
  }
243
243
 
244
+ // HTTP request-smuggling defense per RFC 9112 §6.1 — covers the
245
+ // CVE-2022-31394 / CVE-2024-27316 / CL.TE / TE.CL / TE.TE class.
246
+ // Returns null on clean; { status, code, message } on smuggling-shaped
247
+ // request that the caller MUST reject with 400 + Connection: close.
248
+ function _detectSmuggling(req) {
249
+ var headers = req.headers || {};
250
+ var cl = headers["content-length"];
251
+ var te = headers["transfer-encoding"];
252
+
253
+ // 1. Both Content-Length AND Transfer-Encoding present — RFC 9112
254
+ // §6.1 says receiver MUST reject; the dual presence is the canonical
255
+ // CL.TE / TE.CL smuggling shape.
256
+ if (typeof cl === "string" && cl.length > 0 &&
257
+ typeof te === "string" && te.length > 0) {
258
+ return {
259
+ status: HTTP_STATUS.BAD_REQUEST, code: "smuggling/te-cl-conflict",
260
+ message: "request has both Content-Length and Transfer-Encoding " +
261
+ "headers (RFC 9112 §6.1 — request-smuggling vector)",
262
+ };
263
+ }
264
+
265
+ // 2. Multiple Content-Length values. Node's http parser collapses
266
+ // duplicate headers into a comma-separated string — `cl.indexOf(",")`
267
+ // catches it.
268
+ if (typeof cl === "string" && cl.indexOf(",") !== -1) {
269
+ return {
270
+ status: HTTP_STATUS.BAD_REQUEST, code: "smuggling/multiple-content-length",
271
+ message: "request has multiple Content-Length values (RFC 9112 §6.1)",
272
+ };
273
+ }
274
+
275
+ // 3. Transfer-Encoding present — final coding MUST be `chunked`
276
+ // (RFC 9112 §6.1). Anything else is a smuggling vector or
277
+ // server-side decode error.
278
+ if (typeof te === "string" && te.length > 0) {
279
+ var tokens = te.toLowerCase().split(",").map(function (t) { return t.trim(); });
280
+ var last = tokens[tokens.length - 1];
281
+ if (last !== "chunked") {
282
+ return {
283
+ status: HTTP_STATUS.BAD_REQUEST, code: "smuggling/te-not-chunked",
284
+ message: "request has Transfer-Encoding but final coding is not " +
285
+ "`chunked` (RFC 9112 §6.1 requires chunked be last)",
286
+ };
287
+ }
288
+ // 4. Duplicate `chunked` token (TE: chunked, chunked) — explicitly
289
+ // forbidden by RFC 9112 §6.1.
290
+ var chunkedCount = 0;
291
+ for (var i = 0; i < tokens.length; i += 1) {
292
+ if (tokens[i] === "chunked") chunkedCount += 1;
293
+ }
294
+ if (chunkedCount > 1) {
295
+ return {
296
+ status: HTTP_STATUS.BAD_REQUEST, code: "smuggling/duplicate-chunked",
297
+ message: "Transfer-Encoding lists `chunked` more than once " +
298
+ "(RFC 9112 §6.1 — TE.TE smuggling vector)",
299
+ };
300
+ }
301
+ }
302
+
303
+ return null;
304
+ }
305
+
244
306
  function _writeError(res, status, message, code) {
245
307
  if (res.headersSent) return;
246
308
  var body = JSON.stringify({ error: message, code: code });
@@ -955,6 +1017,23 @@ function create(opts) {
955
1017
  var keepRawBody = !!opts.keepRawBody;
956
1018
 
957
1019
  return async function bodyParser(req, res, next) {
1020
+ // RFC 9112 §6.1 request-smuggling defense — runs BEFORE _hasBody
1021
+ // so the smuggling shape is rejected even if the request would
1022
+ // otherwise short-circuit as no-body. Reject with 400 +
1023
+ // Connection: close so the upstream proxy doesn't reuse the socket.
1024
+ var smug = _detectSmuggling(req);
1025
+ if (smug) {
1026
+ if (!res.headersSent) {
1027
+ var smugBody = JSON.stringify({ error: smug.message, code: smug.code });
1028
+ res.writeHead(smug.status, {
1029
+ "Content-Type": "application/json; charset=utf-8",
1030
+ "Content-Length": Buffer.byteLength(smugBody),
1031
+ "Connection": "close",
1032
+ });
1033
+ res.end(smugBody);
1034
+ }
1035
+ return;
1036
+ }
958
1037
  if (!_hasBody(req)) return next();
959
1038
  if (req.body !== undefined) return next(); // already parsed by an earlier middleware
960
1039
 
@@ -18,6 +18,7 @@
18
18
  */
19
19
  var apiEncrypt = require("./api-encrypt");
20
20
  var attachUser = require("./attach-user");
21
+ var bearerAuth = require("./bearer-auth");
21
22
  var bodyParser = require("./body-parser");
22
23
  var botGuard = require("./bot-guard");
23
24
  var compression = require("./compression");
@@ -43,6 +44,7 @@ module.exports = {
43
44
  cors: cors.create,
44
45
  rateLimit: rateLimit.create,
45
46
  attachUser: attachUser.create,
47
+ bearerAuth: bearerAuth.create,
46
48
  requireAuth: requireAuth.create,
47
49
  csrfProtect: csrfProtect.create,
48
50
  bodyParser: bodyParser.create,
@@ -64,6 +66,7 @@ module.exports = {
64
66
  cors: cors,
65
67
  rateLimit: rateLimit,
66
68
  attachUser: attachUser,
69
+ bearerAuth: bearerAuth,
67
70
  requireAuth: requireAuth,
68
71
  csrfProtect: csrfProtect,
69
72
  bodyParser: bodyParser,
@@ -82,10 +82,30 @@ function requireNonNegativeFiniteIntIfPresent(value, label, errorClass, code) {
82
82
  return value;
83
83
  }
84
84
 
85
+ // requireAllPositiveFiniteIntIfPresent — batch validator. Walk each
86
+ // opt-name in the list; for any that is present in opts, require it to
87
+ // be a positive finite integer (otherwise throw via errorClass with the
88
+ // shared code). Used by primitives whose entry points have a 3-6
89
+ // numeric opts that all share the same shape constraint, so the inline
90
+ // call sequence doesn't repeat per primitive.
91
+ //
92
+ // nb.requireAllPositiveFiniteIntIfPresent(opts,
93
+ // ["maxBytes", "maxAttrValueBytes", "maxTagDepth", "maxAttrsPerTag"],
94
+ // "guardHtml.validate", GuardHtmlError, "html.bad-opt");
95
+ function requireAllPositiveFiniteIntIfPresent(opts, names, labelPrefix, errorClass, code) {
96
+ if (!opts || !Array.isArray(names)) return;
97
+ for (var i = 0; i < names.length; i += 1) {
98
+ var n = names[i];
99
+ requirePositiveFiniteIntIfPresent(opts[n],
100
+ (labelPrefix || "") + ": " + n, errorClass, code);
101
+ }
102
+ }
103
+
85
104
  module.exports = {
86
105
  shape: shape,
87
106
  isPositiveFiniteInt: isPositiveFiniteInt,
88
107
  isNonNegativeFiniteInt: isNonNegativeFiniteInt,
89
108
  requirePositiveFiniteIntIfPresent: requirePositiveFiniteIntIfPresent,
90
109
  requireNonNegativeFiniteIntIfPresent: requireNonNegativeFiniteIntIfPresent,
110
+ requireAllPositiveFiniteIntIfPresent: requireAllPositiveFiniteIntIfPresent,
91
111
  };
package/lib/session.js CHANGED
@@ -54,6 +54,23 @@ var DEFAULT_TTL_MS = C.TIME.days(7);
54
54
  // expiresAt away from epoch overflow + database-int boundary issues.
55
55
  var MAX_TTL_MS = C.TIME.days(3650); // ~10 years
56
56
 
57
+ // Idle + absolute timeout defaults per OWASP ASVS 5.0 §3.3 + NIST
58
+ // SP 800-63B-4. expiresAt is the operator-set window; idle/absolute
59
+ // are independent enforcement floors that shorten the effective
60
+ // session lifetime even when the operator picked a long ttlMs.
61
+ //
62
+ // - idle: session expires N ms after the last verify() / touch().
63
+ // Default 30 minutes — short enough to defeat session-token
64
+ // theft via short-lived foothold; long enough that a user
65
+ // reading a long article doesn't get logged out.
66
+ // - absolute: session always expires at most N ms after creation,
67
+ // regardless of activity. Default 12 hours — re-auth at
68
+ // least once per shift even on a continuously-active
69
+ // session. Repeated touch() with extendBy cannot push past
70
+ // this ceiling.
71
+ var DEFAULT_IDLE_TIMEOUT_MS = C.TIME.minutes(30);
72
+ var DEFAULT_ABSOLUTE_TIMEOUT_MS = C.TIME.hours(12);
73
+
57
74
  function _validateTtl(ttl, where) {
58
75
  if (typeof ttl !== "number" || !isFinite(ttl) || ttl <= 0) {
59
76
  throw _err("INVALID_ARG",
@@ -197,15 +214,55 @@ async function verify(token, verifyOpts) {
197
214
  [sidHash]
198
215
  );
199
216
  if (!row) return null;
200
- if (Number(row.expiresAt) < Date.now()) {
201
- // Expired — clean up and return null. Cleanup is leader-only;
202
- // verify is anywhere, so a follower observing an expired row
203
- // skips the cleanup (next leader-side call will purge it).
217
+ var nowMs = Date.now();
218
+ if (Number(row.expiresAt) < nowMs) {
219
+ // Expired (operator-set ttl) clean up and return null. Cleanup
220
+ // is leader-only; verify is anywhere, so a follower observing an
221
+ // expired row skips the cleanup (next leader-side call purges it).
204
222
  if (cluster.isLeader()) {
205
223
  try { await _deleteBySidHash(sidHash); } catch (_e) { /* best-effort */ }
206
224
  }
207
225
  return null;
208
226
  }
227
+
228
+ // Idle + absolute timeout enforcement (OWASP ASVS 5.0 §3.3 / NIST
229
+ // SP 800-63B-4). These shorten the effective lifetime even when the
230
+ // operator picked a long ttlMs. Defaults: idle 30m, absolute 12h.
231
+ // Operator opt-out by passing 0 (disables that timeout).
232
+ var idleMs = verifyOpts.idleTimeoutMs !== undefined
233
+ ? verifyOpts.idleTimeoutMs : DEFAULT_IDLE_TIMEOUT_MS;
234
+ var absMs = verifyOpts.absoluteTimeoutMs !== undefined
235
+ ? verifyOpts.absoluteTimeoutMs : DEFAULT_ABSOLUTE_TIMEOUT_MS;
236
+ if (idleMs > 0) {
237
+ var lastActivity = Number(row.lastActivity);
238
+ if ((nowMs - lastActivity) > idleMs) {
239
+ try {
240
+ audit.safeEmit({
241
+ action: "auth.session.expired_idle", outcome: "warning",
242
+ metadata: { idleMs: nowMs - lastActivity, threshold: idleMs },
243
+ });
244
+ } catch (_ignored) { /* audit best-effort */ }
245
+ if (cluster.isLeader()) {
246
+ try { await _deleteBySidHash(sidHash); } catch (_e) { /* best-effort */ }
247
+ }
248
+ return null;
249
+ }
250
+ }
251
+ if (absMs > 0) {
252
+ var createdAt = Number(row.createdAt);
253
+ if ((nowMs - createdAt) > absMs) {
254
+ try {
255
+ audit.safeEmit({
256
+ action: "auth.session.expired_absolute", outcome: "warning",
257
+ metadata: { ageMs: nowMs - createdAt, threshold: absMs },
258
+ });
259
+ } catch (_ignored) { /* audit best-effort */ }
260
+ if (cluster.isLeader()) {
261
+ try { await _deleteBySidHash(sidHash); } catch (_e) { /* best-effort */ }
262
+ }
263
+ return null;
264
+ }
265
+ }
209
266
  // Unseal sealed columns (userId, data) using the cryptoField pipeline
210
267
  // so we return cleartext to the caller — same shape as the previous
211
268
  // db().from(...).first() path delivered.
package/lib/static.js CHANGED
@@ -36,6 +36,7 @@ var fsp = require("node:fs/promises");
36
36
  var nodeCrypto = require("node:crypto");
37
37
  var path = require("node:path");
38
38
  var C = require("./constants");
39
+ var gateContract = require("./gate-contract");
39
40
  var lazyRequire = require("./lazy-require");
40
41
  var numericBounds = require("./numeric-bounds");
41
42
  var requestHelpers = require("./request-helpers");
@@ -47,6 +48,11 @@ var { StaticServeError } = require("./framework-error");
47
48
  // observability is ready.
48
49
  var observability = lazyRequire(function () { return require("./observability"); });
49
50
 
51
+ // guard-* family is wired on by default; lazy-loaded to avoid eager
52
+ // import cycles. Operators opt out via contentSafety: null (audited).
53
+ var guardAll = lazyRequire(function () { return require("./guard-all"); });
54
+ var guardFilename = lazyRequire(function () { return require("./guard-filename"); });
55
+
50
56
  var _err = StaticServeError.factory;
51
57
 
52
58
  var HTTP = requestHelpers.HTTP_STATUS;
@@ -159,6 +165,39 @@ function _resolveSafe(root, requestedPath) {
159
165
  var rootResolved = path.resolve(root);
160
166
  if (resolved !== rootResolved &&
161
167
  !resolved.startsWith(rootResolved + path.sep)) return null;
168
+
169
+ // Symlink-escape defense — the lexical resolve above only sees the
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).
174
+ try {
175
+ var real = fs.realpathSync(resolved);
176
+ var rootReal = fs.realpathSync(rootResolved);
177
+ if (real !== rootReal && !real.startsWith(rootReal + path.sep)) return null;
178
+ resolved = real;
179
+ } catch (_e) {
180
+ // Path doesn't exist (or is denied) — fall through with the lexical
181
+ // resolution so the caller's stat() returns the natural ENOENT and
182
+ // 404s. realpath failures from non-existence are NOT a smuggling
183
+ // signal.
184
+ }
185
+
186
+ // Filename safety — the basename gates against path-traversal /
187
+ // null-byte / NTFS ADS / UNC / RTLO bidi / overlong UTF-8 / Windows
188
+ // reserved device names. Uses balanced profile + explicit
189
+ // shellExecExtPolicy: "allow" because static-serve serves operator-
190
+ // deposited disk content: shell-exec extensions (.exe / .bin / .so /
191
+ // legitimate `<name>.<hash>.js` bundler output) are valid here. The
192
+ // other balanced checks still reject the traversal + smuggling
193
+ // surface the user surfaced.
194
+ var fname = path.basename(resolved);
195
+ var rv = guardFilename().validate(fname, {
196
+ profile: "balanced",
197
+ shellExecExtPolicy: "allow",
198
+ });
199
+ if (!rv.ok) return null;
200
+
162
201
  return resolved;
163
202
  }
164
203
 
@@ -236,10 +275,8 @@ function _validateCreateOpts(opts) {
236
275
  "staticServe.create: indexFile", StaticServeError, "BAD_OPT");
237
276
  numericBounds.requireNonNegativeFiniteIntIfPresent(opts.defaultMaxAge,
238
277
  "staticServe.create: defaultMaxAge", StaticServeError, "BAD_OPT");
239
- if (opts.contentTypes !== undefined && opts.contentTypes !== null &&
240
- (typeof opts.contentTypes !== "object" || Array.isArray(opts.contentTypes))) {
241
- throw _err("BAD_OPT", "staticServe.create: contentTypes must be a plain object");
242
- }
278
+ validateOpts.optionalPlainObject(opts.contentTypes,
279
+ "staticServe.create: contentTypes", StaticServeError, "BAD_OPT");
243
280
  validateOpts.optionalObjectWithMethod(opts.permissions, "check",
244
281
  "staticServe.create: permissions", StaticServeError, "BAD_OPT",
245
282
  "must be a b.permissions instance (check fn)");
@@ -266,6 +303,29 @@ function _validateCreateOpts(opts) {
266
303
  validateOpts.auditShape(opts.audit, "staticServe.create", StaticServeError);
267
304
  validateOpts.observabilityShape(opts.observability, "staticServe.create", StaticServeError);
268
305
  validateOpts.optionalFunction(opts.onServe, "staticServe.create: onServe", StaticServeError);
306
+ // contentSafety — extension-keyed gate map. Default behaviour: when
307
+ // undefined, the framework wires b.guardAll.byExtension({ profile:
308
+ // "strict" }) automatically so every shipped guard is ON by default.
309
+ // Explicit opt-out: contentSafety: null (audited at create() time so
310
+ // a security review can reconstruct which deploys disabled the
311
+ // default-on protection).
312
+ // Example: contentSafety: { ".csv": b.guardCsv.gate({ profile: "strict" }) }
313
+ if (opts.contentSafety !== undefined && opts.contentSafety !== null) {
314
+ validateOpts.optionalPlainObject(opts.contentSafety,
315
+ "staticServe.create: contentSafety", StaticServeError, "BAD_OPT",
316
+ "must be a plain { ext: gate } object, null to opt out, or " +
317
+ "undefined for the default-on b.guardAll wiring");
318
+ var safetyKeys = Object.keys(opts.contentSafety);
319
+ for (var sk = 0; sk < safetyKeys.length; sk++) {
320
+ var ext = safetyKeys[sk];
321
+ var g = opts.contentSafety[ext];
322
+ if (!g || typeof g.check !== "function") {
323
+ throw _err("BAD_OPT",
324
+ "staticServe.create: contentSafety[" + JSON.stringify(ext) +
325
+ "] must be a gate (b.guardCsv.gate / b.guardHtml.gate / etc.)");
326
+ }
327
+ }
328
+ }
269
329
  validateOpts.optionalBoolean(opts.acceptRanges, "staticServe.create: acceptRanges", StaticServeError);
270
330
  validateOpts.optionalBoolean(opts.auditSuccess, "staticServe.create: auditSuccess", StaticServeError);
271
331
  validateOpts.optionalBoolean(opts.auditFailures, "staticServe.create: auditFailures", StaticServeError);
@@ -393,6 +453,7 @@ function create(opts) {
393
453
  "acceptRanges", "auditSuccess", "auditFailures",
394
454
  "maxBytesPerActorPerWindowMs", "maxBytesAllActorsPerWindowMs",
395
455
  "bandwidthWindowMs", "maxConcurrentDownloadsPerActor", "maxIdleMs",
456
+ "contentSafety", "contentSafetyDisabledReason",
396
457
  ], "staticServe.create");
397
458
  _validateCreateOpts(opts);
398
459
  var cfg = validateOpts.applyDefaults(opts, DEFAULTS);
@@ -408,6 +469,37 @@ function create(opts) {
408
469
  var retention = opts.retention || null;
409
470
  var revokeStore = opts.revokeStore || null;
410
471
  var allowedFileTypes = Array.isArray(opts.allowedFileTypes) ? opts.allowedFileTypes.slice() : [];
472
+ // contentSafety: undefined → wire b.guardAll.byExtension({ profile: "strict" })
473
+ // contentSafety: null → explicit opt-out, audit row emitted
474
+ // contentSafety: { ... } → use operator-supplied map
475
+ var contentSafety;
476
+ if (opts.contentSafety === undefined) {
477
+ // Strict profile is the security-correct default. Operators who
478
+ // serve a broader content vocabulary opt up explicitly via
479
+ // contentSafety: b.guardAll.byExtension({ profile: "balanced" |
480
+ // "permissive" }).
481
+ contentSafety = guardAll().byExtension({
482
+ profile: "strict",
483
+ audit: opts.audit,
484
+ observability: opts.observability,
485
+ });
486
+ } else if (opts.contentSafety === null) {
487
+ if (opts.audit && typeof opts.audit.safeEmit === "function") {
488
+ try {
489
+ opts.audit.safeEmit({
490
+ action: "staticServe.contentSafety.disabled",
491
+ actor: {},
492
+ outcome: "success",
493
+ metadata: {
494
+ reason: opts.contentSafetyDisabledReason || "operator-explicit-opt-out",
495
+ },
496
+ });
497
+ } catch (_e) { /* audit best-effort */ }
498
+ }
499
+ contentSafety = null;
500
+ } else {
501
+ contentSafety = opts.contentSafety;
502
+ }
411
503
  var onServe = opts.onServe || null;
412
504
  var audit = opts.audit || null;
413
505
  var auditSuccess = cfg.auditSuccess;
@@ -573,6 +665,68 @@ function create(opts) {
573
665
  }
574
666
  }
575
667
 
668
+ // Content-safety gate — operator-supplied per-extension gate
669
+ // (b.guardCsv.gate / b.guardHtml.gate / etc.). Reads the file once
670
+ // up to maxRuntimeMs and routes the bytes through the gate's
671
+ // check() before serving. The gate's decision is honored:
672
+ // - serve → continue with the original bytes
673
+ // - sanitize → continue with decision.sanitized
674
+ // - refuse → 415 / opaque to clients
675
+ // - audit-only / warn → continue (gate emits to audit)
676
+ var gateBytesOverride = null;
677
+ if (contentSafety) {
678
+ var ext = path.extname(absPath).toLowerCase();
679
+ var safetyGate = contentSafety[ext];
680
+ if (safetyGate && typeof safetyGate.check === "function") {
681
+ var gateBuf;
682
+ try { gateBuf = await fsp.readFile(absPath); }
683
+ catch (_e) {
684
+ stats.failures += 1;
685
+ return _writeError(res, HTTP.INTERNAL_SERVER_ERROR,
686
+ "read_failed", "Internal Server Error");
687
+ }
688
+ var gateDecision;
689
+ try {
690
+ gateDecision = await safetyGate.check({
691
+ bytes: gateBuf,
692
+ contentType: _contentTypeFor(absPath, contentTypes),
693
+ filename: path.basename(absPath),
694
+ actor: actorCtx,
695
+ route: urlPath,
696
+ direction: "outbound",
697
+ req: req,
698
+ });
699
+ } catch (gateErr) {
700
+ stats.failures += 1;
701
+ _emitObs("staticServe.content_safety_threw", 1, { route: urlPath });
702
+ if (auditFailures) {
703
+ emitAudit("staticServe.serve.failure", Object.assign({
704
+ outcome: "failure", reason: "content_safety_threw", resource: urlPath,
705
+ error: gateErr && gateErr.message,
706
+ }, actorCtx));
707
+ }
708
+ return _writeError(res, HTTP.INTERNAL_SERVER_ERROR,
709
+ "content_safety_threw", "Internal Server Error");
710
+ }
711
+ if (!gateDecision.ok || gateDecision.action === "refuse") {
712
+ stats.failures += 1;
713
+ _emitObs("staticServe.content_safety_refused", 1, { route: urlPath });
714
+ if (auditFailures) {
715
+ emitAudit("staticServe.serve.failure", Object.assign({
716
+ outcome: "failure", reason: "content_safety_refused",
717
+ resource: urlPath, ext: ext,
718
+ issues: gateContract.summarizeIssues(gateDecision.issues),
719
+ }, actorCtx));
720
+ }
721
+ return _writeError(res, HTTP.UNSUPPORTED_MEDIA_TYPE,
722
+ "content_safety_refused", "Unsupported Media Type");
723
+ }
724
+ if (gateDecision.action === "sanitize" && gateDecision.sanitized) {
725
+ gateBytesOverride = gateDecision.sanitized;
726
+ }
727
+ }
728
+ }
729
+
576
730
  var cacheControl = _cacheControlFor(urlPath);
577
731
 
578
732
  var headersIn = req.headers || {};
@@ -734,6 +888,32 @@ function create(opts) {
734
888
  return;
735
889
  }
736
890
 
891
+ // Sanitized override path — content-safety gate replaced the
892
+ // bytes; emit them directly without re-reading the file. Bypasses
893
+ // range / idle-timer machinery (the override is already in memory
894
+ // and Range over a sanitized variant doesn't have a useful
895
+ // contract — sanitization changes byte offsets).
896
+ if (gateBytesOverride) {
897
+ var overrideHeaders = Object.assign({}, headers, {
898
+ "Content-Length": gateBytesOverride.length,
899
+ });
900
+ delete overrideHeaders["Content-Range"];
901
+ res.writeHead(HTTP.OK, overrideHeaders);
902
+ res.end(gateBytesOverride);
903
+ stats.requestsServed += 1;
904
+ stats.bytesServed += gateBytesOverride.length;
905
+ _emitObs("staticServe.requests_served", 1, { route: urlPath, method: "GET", sanitized: true });
906
+ _emitObs("staticServe.bytes_served", gateBytesOverride.length, { route: urlPath, sanitized: true });
907
+ if (auditSuccess) {
908
+ emitAudit("staticServe.serve.success", Object.assign({
909
+ outcome: "success", resource: urlPath, method: "GET",
910
+ size: gateBytesOverride.length, contentType: overrideHeaders["Content-Type"],
911
+ sanitized: true,
912
+ }, actorCtx));
913
+ }
914
+ return;
915
+ }
916
+
737
917
  res.writeHead(status, headers);
738
918
 
739
919
  // Acquire concurrency slot (released on stream end / error / abort).
@@ -266,6 +266,26 @@ function optionalObjectWithMethod(value, method, label, errorClass, code, descri
266
266
  return value;
267
267
  }
268
268
 
269
+ // optionalPlainObject — required-shape validator for optional opts that
270
+ // accept a plain object (not array, not null when undefined-meaning-absent
271
+ // is intended). Replaces the recurring `if (X !== undefined && X !== null)
272
+ // { if (typeof X !== "object" || Array.isArray(X)) throw }` cascade
273
+ // shared by api-key (metadata), db-declare-view, db-declare-row-policy,
274
+ // and static.js (contentSafety).
275
+ //
276
+ // undefined / null → returns the value unchanged (caller can default).
277
+ // non-object OR array → throws via errorClass with the operator-facing
278
+ // description (e.g. "metadata must be a plain object or null").
279
+ function optionalPlainObject(value, label, errorClass, code, description) {
280
+ if (value === undefined || value === null) return value;
281
+ if (typeof value !== "object" || Array.isArray(value)) {
282
+ _throw(errorClass, code, (label || "opt") + " " +
283
+ (description || "must be a plain object or null"),
284
+ "validate-opts/bad-plain-object");
285
+ }
286
+ return value;
287
+ }
288
+
269
289
  // makeAuditEmitter — closure factory parallel to safeAsync.makeDropCallback.
270
290
  // Replaces the per-file `function _emit(action, info) { if (!audit) return;
271
291
  // try { audit.safeEmit(Object.assign({ action: action }, info || {})); }
@@ -312,6 +332,7 @@ module.exports.optionalFunction = optionalFunction;
312
332
  module.exports.optionalNonEmptyString = optionalNonEmptyString;
313
333
  module.exports.optionalNonEmptyStringArray = optionalNonEmptyStringArray;
314
334
  module.exports.optionalObjectWithMethod = optionalObjectWithMethod;
335
+ module.exports.optionalPlainObject = optionalPlainObject;
315
336
  module.exports.requireNonEmptyString = requireNonEmptyString;
316
337
  module.exports.observabilityShape = observabilityShape;
317
338
  module.exports.requireObject = requireObject;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.7.4",
3
+ "version": "0.7.19",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",