@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.
- package/CHANGELOG.md +30 -0
- package/README.md +1 -0
- package/index.js +27 -1
- package/lib/api-key.js +2 -5
- package/lib/auth/jwt-external.js +365 -0
- package/lib/auth/jwt.js +27 -1
- package/lib/auth/password.js +34 -0
- package/lib/codepoint-class.js +196 -0
- package/lib/csv.js +25 -36
- package/lib/db-declare-view.js +3 -4
- package/lib/file-upload.js +213 -10
- package/lib/framework-error.js +78 -0
- package/lib/gate-contract.js +971 -0
- package/lib/guard-all.js +405 -0
- package/lib/guard-archive.js +739 -0
- package/lib/guard-csv.js +816 -0
- package/lib/guard-email.js +744 -0
- package/lib/guard-filename.js +724 -0
- package/lib/guard-html.js +976 -0
- package/lib/guard-json.js +729 -0
- package/lib/guard-markdown.js +586 -0
- package/lib/guard-svg.js +976 -0
- package/lib/guard-xml.js +405 -0
- package/lib/guard-yaml.js +529 -0
- package/lib/mail-dkim.js +13 -6
- package/lib/mail.js +19 -0
- package/lib/middleware/bearer-auth.js +152 -0
- package/lib/middleware/body-parser.js +79 -0
- package/lib/middleware/index.js +3 -0
- package/lib/numeric-bounds.js +20 -0
- package/lib/session.js +61 -4
- package/lib/static.js +184 -4
- package/lib/validate-opts.js +21 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
|
@@ -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
|
|
package/lib/middleware/index.js
CHANGED
|
@@ -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,
|
package/lib/numeric-bounds.js
CHANGED
|
@@ -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
|
-
|
|
201
|
-
|
|
202
|
-
//
|
|
203
|
-
//
|
|
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
|
-
|
|
240
|
-
|
|
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).
|
package/lib/validate-opts.js
CHANGED
|
@@ -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;
|