@blamejs/core 0.6.0 → 0.6.2
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 +5 -0
- package/README.md +2 -2
- package/lib/api-key.js +14 -9
- package/lib/auth/jwt.js +19 -2
- package/lib/break-glass.js +38 -6
- package/lib/cluster.js +15 -5
- package/lib/db-query.js +22 -0
- package/lib/db-schema.js +7 -3
- package/lib/http-client.js +53 -2
- package/lib/middleware/body-parser.js +35 -8
- package/lib/middleware/cors.js +69 -26
- package/lib/middleware/csrf-protect.js +39 -5
- package/lib/middleware/rate-limit.js +21 -5
- package/lib/migrations.js +19 -14
- package/lib/safe-sql.js +38 -0
- package/lib/seeders.js +18 -13
- package/lib/session.js +23 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,11 @@ 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.6.x
|
|
10
|
+
|
|
11
|
+
- **0.6.1** (2026-05-01) — security tightenings + operator-facing jargon sweep
|
|
12
|
+
- **0.6.0** (2026-05-01) — wiki restructured into 22 focused pages + missing-primitive coverage
|
|
13
|
+
|
|
9
14
|
## v0.5.x
|
|
10
15
|
|
|
11
16
|
- **0.5.18** (2026-05-01) — bypass-fix sweep: route through existing primitives
|
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ The modern Node app is a 1,200-package supply-chain liability with no LTS calend
|
|
|
17
17
|
|
|
18
18
|
## Status
|
|
19
19
|
|
|
20
|
-
Pre-1.0. Usable end-to-end — operators can build production apps on it today; the surface is still subject to change before 1.0. Recent line is **v0.6.
|
|
20
|
+
Pre-1.0. Usable end-to-end — operators can build production apps on it today; the surface is still subject to change before 1.0. Recent line is **v0.6.2** ([releases](https://github.com/blamejs/blamejs/releases) · [npm](https://www.npmjs.com/package/@blamejs/core) · [container](https://github.com/blamejs/blamejs/pkgs/container/blamejs-wiki)).
|
|
21
21
|
|
|
22
22
|
```js
|
|
23
23
|
var b = require("@blamejs/core");
|
|
@@ -115,7 +115,7 @@ Because when something breaks, `blame` should know exactly where it lives. We ow
|
|
|
115
115
|
|
|
116
116
|
## Contributing
|
|
117
117
|
|
|
118
|
-
Patches welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the dev setup, house rules (zero npm runtime deps, PQC-only crypto, audit-on-every-action,
|
|
118
|
+
Patches welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the dev setup, house rules (zero npm runtime deps, PQC-only crypto, audit-on-every-action, ship-complete-not-incremental), and the PR loop. New to the codebase? Start with [ARCHITECTURE.md](ARCHITECTURE.md) for the orientation map.
|
|
119
119
|
|
|
120
120
|
Community standards: [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) (Contributor Covenant 2.1). Be excellent.
|
|
121
121
|
|
package/lib/api-key.js
CHANGED
|
@@ -74,7 +74,12 @@ function _emitEvent(name, value, labels) {
|
|
|
74
74
|
|
|
75
75
|
var _err = ApiKeyError.factory;
|
|
76
76
|
|
|
77
|
-
var TABLE
|
|
77
|
+
var TABLE = "_blamejs_api_keys";
|
|
78
|
+
// Pre-quoted form for SQL interpolation. Defense-in-depth: even though
|
|
79
|
+
// our constant is bare-identifier-shaped, every interpolation site uses
|
|
80
|
+
// the wrapped form so a future rename to a reserved-word or
|
|
81
|
+
// whitespace-bearing name would still resolve correctly.
|
|
82
|
+
var Q_TABLE = '"' + TABLE + '"';
|
|
78
83
|
|
|
79
84
|
// Column order used for INSERT — kept as a constant so the placeholders
|
|
80
85
|
// list and the values list stay in sync. Must match _blamejs_api_keys'
|
|
@@ -286,7 +291,7 @@ function create(opts) {
|
|
|
286
291
|
function _selectAll() {
|
|
287
292
|
return "SELECT id, namespace, ownerId, ownerIdHash, secretHash, " +
|
|
288
293
|
"secondarySecretHash, secondaryExpiresAt, " +
|
|
289
|
-
"scopes, metadata, createdAt, expiresAt, revokedAt, lastUsedAt, prefix FROM " +
|
|
294
|
+
"scopes, metadata, createdAt, expiresAt, revokedAt, lastUsedAt, prefix FROM " + Q_TABLE;
|
|
290
295
|
}
|
|
291
296
|
|
|
292
297
|
function _scrubRecord(row) {
|
|
@@ -352,7 +357,7 @@ function create(opts) {
|
|
|
352
357
|
var quoted = COLS.map(function (c) { return '"' + c + '"'; }).join(", ");
|
|
353
358
|
|
|
354
359
|
await clusterStorage.execute(
|
|
355
|
-
"INSERT INTO " +
|
|
360
|
+
"INSERT INTO " + Q_TABLE + " (" + quoted + ") VALUES (" + placeholders + ")",
|
|
356
361
|
values
|
|
357
362
|
);
|
|
358
363
|
|
|
@@ -453,7 +458,7 @@ function create(opts) {
|
|
|
453
458
|
if (trackLastUsedAt && cluster.isLeader()) {
|
|
454
459
|
try {
|
|
455
460
|
await clusterStorage.execute(
|
|
456
|
-
"UPDATE " +
|
|
461
|
+
"UPDATE " + Q_TABLE + " SET lastUsedAt = ? WHERE id = ?",
|
|
457
462
|
[nowMs, compositeId]
|
|
458
463
|
);
|
|
459
464
|
} catch (_e) { /* best-effort; verify success not blocked by lastUsed update */ }
|
|
@@ -480,7 +485,7 @@ function create(opts) {
|
|
|
480
485
|
var compositeId = _composedId(namespace, idHex);
|
|
481
486
|
var nowMs = clock();
|
|
482
487
|
var result = await clusterStorage.execute(
|
|
483
|
-
"UPDATE " +
|
|
488
|
+
"UPDATE " + Q_TABLE + " SET revokedAt = ? WHERE id = ? AND revokedAt IS NULL",
|
|
484
489
|
[nowMs, compositeId]
|
|
485
490
|
);
|
|
486
491
|
var changed = (result.rowCount || 0) > 0;
|
|
@@ -537,7 +542,7 @@ function create(opts) {
|
|
|
537
542
|
if (gracePeriodMs > 0) {
|
|
538
543
|
// Move current hash → secondary slot, install new hash as primary.
|
|
539
544
|
await clusterStorage.execute(
|
|
540
|
-
"UPDATE " +
|
|
545
|
+
"UPDATE " + Q_TABLE + " SET secretHash = ?, " +
|
|
541
546
|
"secondarySecretHash = ?, secondaryExpiresAt = ? WHERE id = ?",
|
|
542
547
|
[newHash, existing.secretHash, nowMs + gracePeriodMs, compositeId]
|
|
543
548
|
);
|
|
@@ -545,7 +550,7 @@ function create(opts) {
|
|
|
545
550
|
// Hard cutover — old secret stops working immediately. Clears
|
|
546
551
|
// any prior secondary slot too.
|
|
547
552
|
await clusterStorage.execute(
|
|
548
|
-
"UPDATE " +
|
|
553
|
+
"UPDATE " + Q_TABLE + " SET secretHash = ?, " +
|
|
549
554
|
"secondarySecretHash = NULL, secondaryExpiresAt = NULL WHERE id = ?",
|
|
550
555
|
[newHash, compositeId]
|
|
551
556
|
);
|
|
@@ -639,7 +644,7 @@ function create(opts) {
|
|
|
639
644
|
// extra round-trip per purge call which runs on a schedule (not
|
|
640
645
|
// request-rate), so the cost is irrelevant.
|
|
641
646
|
var idRows = await clusterStorage.execute(
|
|
642
|
-
"SELECT id FROM " +
|
|
647
|
+
"SELECT id FROM " + Q_TABLE + " WHERE namespace = ? AND " +
|
|
643
648
|
"((revokedAt IS NOT NULL AND revokedAt < ?) OR " +
|
|
644
649
|
" (expiresAt IS NOT NULL AND expiresAt < ?))",
|
|
645
650
|
[namespace, threshold, threshold]
|
|
@@ -652,7 +657,7 @@ function create(opts) {
|
|
|
652
657
|
}
|
|
653
658
|
|
|
654
659
|
var result = await clusterStorage.execute(
|
|
655
|
-
"DELETE FROM " +
|
|
660
|
+
"DELETE FROM " + Q_TABLE + " WHERE namespace = ? AND " +
|
|
656
661
|
"((revokedAt IS NOT NULL AND revokedAt < ?) OR " +
|
|
657
662
|
" (expiresAt IS NOT NULL AND expiresAt < ?))",
|
|
658
663
|
[namespace, threshold, threshold]
|
package/lib/auth/jwt.js
CHANGED
|
@@ -258,11 +258,28 @@ async function verify(token, opts) {
|
|
|
258
258
|
}
|
|
259
259
|
var tol = typeof opts.clockToleranceSec === "number" ? opts.clockToleranceSec : 0;
|
|
260
260
|
var p = decoded.payload;
|
|
261
|
-
|
|
261
|
+
|
|
262
|
+
// Strict registered-claim typing per RFC 7519 §4.1. exp / nbf / iat MUST
|
|
263
|
+
// be NumericDate values (JSON numeric, seconds since epoch). A claim
|
|
264
|
+
// present-but-not-a-number is a malformed token — silently skipping the
|
|
265
|
+
// check would let a token with `exp: "0"` or `exp: "9999999999"` (string)
|
|
266
|
+
// bypass expiration enforcement entirely.
|
|
267
|
+
function _requireNumericDate(name, value) {
|
|
268
|
+
if (typeof value !== "number" || !isFinite(value)) {
|
|
269
|
+
throw new AuthError("auth-jwt/malformed",
|
|
270
|
+
"claim '" + name + "' must be a finite number (RFC 7519 NumericDate), got " +
|
|
271
|
+
(value === null ? "null" : typeof value));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (p.exp !== undefined) _requireNumericDate("exp", p.exp);
|
|
275
|
+
if (p.nbf !== undefined) _requireNumericDate("nbf", p.nbf);
|
|
276
|
+
if (p.iat !== undefined) _requireNumericDate("iat", p.iat);
|
|
277
|
+
|
|
278
|
+
if (p.exp !== undefined && p.exp + tol < nowSec) {
|
|
262
279
|
throw new AuthError("auth-jwt/expired",
|
|
263
280
|
"token expired at exp=" + p.exp + " (now=" + nowSec + ", tolerance=" + tol + "s)");
|
|
264
281
|
}
|
|
265
|
-
if (
|
|
282
|
+
if (p.nbf !== undefined && p.nbf - tol > nowSec) {
|
|
266
283
|
throw new AuthError("auth-jwt/not-yet-valid",
|
|
267
284
|
"token not yet valid: nbf=" + p.nbf + " (now=" + nowSec + ", tolerance=" + tol + "s)");
|
|
268
285
|
}
|
package/lib/break-glass.js
CHANGED
|
@@ -48,6 +48,7 @@ var observability = require("./observability");
|
|
|
48
48
|
var requestHelpers = require("./request-helpers");
|
|
49
49
|
var safeAsync = require("./safe-async");
|
|
50
50
|
var safeJson = require("./safe-json");
|
|
51
|
+
var safeSql = require("./safe-sql");
|
|
51
52
|
var totp = require("./totp");
|
|
52
53
|
var validateOpts = require("./validate-opts");
|
|
53
54
|
var { defineClass } = require("./framework-error");
|
|
@@ -248,8 +249,12 @@ async function migrate(table, opts) {
|
|
|
248
249
|
var lastId = "";
|
|
249
250
|
// Iterate via _id-keyset paging so we don't load the whole table into memory.
|
|
250
251
|
while (true) {
|
|
252
|
+
// table is already validated as a safe identifier shape via
|
|
253
|
+
// _validatePolicySet — wrap in "..." per the framework's
|
|
254
|
+
// identifier-quoting convention.
|
|
255
|
+
var qTable = '"' + table + '"';
|
|
251
256
|
var rows = await clusterStorage.executeAll(
|
|
252
|
-
"SELECT * FROM " +
|
|
257
|
+
"SELECT * FROM " + qTable + " WHERE _id > ? ORDER BY _id ASC LIMIT ?",
|
|
253
258
|
[lastId, batchSize]
|
|
254
259
|
);
|
|
255
260
|
if (!rows || rows.length === 0) break;
|
|
@@ -275,11 +280,14 @@ async function migrate(table, opts) {
|
|
|
275
280
|
// the cell ciphertext stays as a literal string, not double-sealed.
|
|
276
281
|
var setCols = Object.keys(update).filter(function (k) { return k !== "_id"; });
|
|
277
282
|
if (setCols.length > 0) {
|
|
278
|
-
|
|
283
|
+
// Column names came from the validated policy.columns —
|
|
284
|
+
// also wrap each in "..." for the same identifier-quoting
|
|
285
|
+
// convention.
|
|
286
|
+
var setSql = setCols.map(function (k) { return '"' + k + '" = ?'; }).join(", ");
|
|
279
287
|
var vals = setCols.map(function (k) { return update[k]; });
|
|
280
288
|
vals.push(row._id);
|
|
281
289
|
await clusterStorage.execute(
|
|
282
|
-
"UPDATE " +
|
|
290
|
+
"UPDATE " + qTable + " SET " + setSql + " WHERE _id = ?",
|
|
283
291
|
vals
|
|
284
292
|
);
|
|
285
293
|
migratedRows++;
|
|
@@ -343,6 +351,20 @@ function _validatePolicySet(table, opts) {
|
|
|
343
351
|
throw new BreakGlassError("breakglass/bad-policy",
|
|
344
352
|
"policy.set: table must be a non-empty string");
|
|
345
353
|
}
|
|
354
|
+
// Identifier safety: the table name flows raw into SQL via interpolation
|
|
355
|
+
// in migrate() / unsealRowAsService(). safeSql.validateIdentifier closes
|
|
356
|
+
// the shape so a malicious / mistyped name with embedded `"` or
|
|
357
|
+
// SQL-keyword shape can't break out of the wrapping quotes.
|
|
358
|
+
// allowReserved: true because every interpolation site quotes the
|
|
359
|
+
// identifier, so reserved-word names work via the SQL standard quoting
|
|
360
|
+
// rule.
|
|
361
|
+
try {
|
|
362
|
+
safeSql.validateIdentifier(table, { allowReserved: true });
|
|
363
|
+
} catch (e) {
|
|
364
|
+
throw new BreakGlassError("breakglass/bad-policy",
|
|
365
|
+
"policy.set: table '" + table + "' is not a valid SQL identifier: " +
|
|
366
|
+
((e && e.message) || String(e)));
|
|
367
|
+
}
|
|
346
368
|
if (!opts || typeof opts !== "object") {
|
|
347
369
|
throw new BreakGlassError("breakglass/bad-policy",
|
|
348
370
|
"policy.set: opts is required");
|
|
@@ -358,10 +380,20 @@ function _validatePolicySet(table, opts) {
|
|
|
358
380
|
"policy.set: columns must be a non-empty array");
|
|
359
381
|
}
|
|
360
382
|
for (var i = 0; i < opts.columns.length; i++) {
|
|
361
|
-
|
|
383
|
+
var colName = opts.columns[i];
|
|
384
|
+
if (typeof colName !== "string" || colName.length === 0) {
|
|
362
385
|
throw new BreakGlassError("breakglass/bad-policy",
|
|
363
386
|
"policy.set: columns[" + i + "] must be a non-empty string");
|
|
364
387
|
}
|
|
388
|
+
// Same identifier-shape check as the table — column names flow into
|
|
389
|
+
// the migrate() UPDATE statement as bare names.
|
|
390
|
+
try {
|
|
391
|
+
safeSql.validateIdentifier(colName, { allowReserved: true });
|
|
392
|
+
} catch (e) {
|
|
393
|
+
throw new BreakGlassError("breakglass/bad-policy",
|
|
394
|
+
"policy.set: columns[" + i + "]='" + colName + "' is not a valid SQL identifier: " +
|
|
395
|
+
((e && e.message) || String(e)));
|
|
396
|
+
}
|
|
365
397
|
}
|
|
366
398
|
if (!Array.isArray(opts.factors) || opts.factors.length === 0) {
|
|
367
399
|
throw new BreakGlassError("breakglass/bad-policy",
|
|
@@ -894,7 +926,7 @@ async function unsealRow(grantHandle, table, rowId, opts) {
|
|
|
894
926
|
// glass-locked columns separately (their ciphertext was written
|
|
895
927
|
// by encryptCell at app-write time, not by cryptoField.sealRow).
|
|
896
928
|
var rows = await clusterStorage.executeAll(
|
|
897
|
-
"SELECT * FROM " + table + " WHERE _id = ?",
|
|
929
|
+
"SELECT * FROM " + '"' + table + '"' + " WHERE _id = ?",
|
|
898
930
|
[String(rowId)]
|
|
899
931
|
);
|
|
900
932
|
if (!rows || rows.length === 0) {
|
|
@@ -1093,7 +1125,7 @@ async function unsealRowAsService(req, table, rowId, opts) {
|
|
|
1093
1125
|
// Fetch + unseal the row (Model A or Model B path, same as
|
|
1094
1126
|
// operator-initiated unsealRow).
|
|
1095
1127
|
var rows = await clusterStorage.executeAll(
|
|
1096
|
-
"SELECT * FROM " + table + " WHERE _id = ?",
|
|
1128
|
+
"SELECT * FROM " + '"' + table + '"' + " WHERE _id = ?",
|
|
1097
1129
|
[String(rowId)]
|
|
1098
1130
|
);
|
|
1099
1131
|
if (!rows || rows.length === 0) {
|
package/lib/cluster.js
CHANGED
|
@@ -57,6 +57,7 @@ var lazyRequire = require("./lazy-require");
|
|
|
57
57
|
var { boot } = require("./log");
|
|
58
58
|
var safeAsync = require("./safe-async");
|
|
59
59
|
var safeJson = require("./safe-json");
|
|
60
|
+
var safeSql = require("./safe-sql");
|
|
60
61
|
var safeUrl = require("./safe-url");
|
|
61
62
|
var { FrameworkError, ClusterError } = require("./framework-error");
|
|
62
63
|
|
|
@@ -273,12 +274,21 @@ async function init(opts) {
|
|
|
273
274
|
// hash → FATAL via process.exit(1). Same posture as the
|
|
274
275
|
// single-node audit.tip sidecar rollback check.
|
|
275
276
|
async function _checkChainTipRollback(chainName, logTable, tipTable) {
|
|
277
|
+
// Both tables are framework-internal constants from the call sites
|
|
278
|
+
// (`_blamejs_audit_log`, `_blamejs_consent_log`, etc.). Validate +
|
|
279
|
+
// quote per the framework's identifier-quoting convention so a
|
|
280
|
+
// future rename can't silently break the query.
|
|
281
|
+
safeSql.validateIdentifier(logTable, { allowReserved: true });
|
|
282
|
+
safeSql.validateIdentifier(tipTable, { allowReserved: true });
|
|
283
|
+
var qLogTable = safeSql.quoteIdentifier(logTable);
|
|
284
|
+
var qTipTable = safeSql.quoteIdentifier(tipTable);
|
|
285
|
+
|
|
276
286
|
var tipRows;
|
|
277
287
|
try {
|
|
278
288
|
tipRows = await externalDb().query(
|
|
279
|
-
"SELECT atMonotonicCounter, rowHash FROM " +
|
|
280
|
-
" WHERE scope =
|
|
281
|
-
[],
|
|
289
|
+
"SELECT atMonotonicCounter, rowHash FROM " + qTipTable +
|
|
290
|
+
" WHERE scope = " + (configuredDialect === "postgres" ? "$1" : "?"),
|
|
291
|
+
[chainName],
|
|
282
292
|
{ backend: configuredExternalDbBackend }
|
|
283
293
|
);
|
|
284
294
|
} catch (e) {
|
|
@@ -298,7 +308,7 @@ async function _checkChainTipRollback(chainName, logTable, tipTable) {
|
|
|
298
308
|
var tipHash = tip.rowHash;
|
|
299
309
|
|
|
300
310
|
var currentRows = await externalDb().query(
|
|
301
|
-
"SELECT MAX(monotonicCounter) AS m FROM " +
|
|
311
|
+
"SELECT MAX(monotonicCounter) AS m FROM " + qLogTable,
|
|
302
312
|
[],
|
|
303
313
|
{ backend: configuredExternalDbBackend }
|
|
304
314
|
);
|
|
@@ -317,7 +327,7 @@ async function _checkChainTipRollback(chainName, logTable, tipTable) {
|
|
|
317
327
|
|
|
318
328
|
if (tipHash) {
|
|
319
329
|
var hashRows = await externalDb().query(
|
|
320
|
-
"SELECT rowHash FROM " +
|
|
330
|
+
"SELECT rowHash FROM " + qLogTable + " WHERE monotonicCounter = " +
|
|
321
331
|
(configuredDialect === "postgres" ? "$1" : "?"),
|
|
322
332
|
[tipCounter],
|
|
323
333
|
{ backend: configuredExternalDbBackend }
|
package/lib/db-query.js
CHANGED
|
@@ -29,11 +29,33 @@
|
|
|
29
29
|
var { Readable } = require("node:stream");
|
|
30
30
|
var cryptoField = require("./crypto-field");
|
|
31
31
|
var { generateToken } = require("./crypto");
|
|
32
|
+
var safeSql = require("./safe-sql");
|
|
32
33
|
|
|
33
34
|
var ALLOWED_OPS = new Set(["=", "!=", "<>", "<", "<=", ">", ">=", "IS", "IS NOT", "LIKE", "IN"]);
|
|
34
35
|
|
|
35
36
|
class Query {
|
|
36
37
|
constructor(database, tableName) {
|
|
38
|
+
// Identifier safety: tableName flows into SQL via interpolation
|
|
39
|
+
// (parameter placeholders only bind values, not names). Validate at
|
|
40
|
+
// construction so an attacker-controlled name with embedded `"` or
|
|
41
|
+
// SQL keywords can't break out of the wrapping quotes downstream.
|
|
42
|
+
// Cross-schema queries (e.g., Postgres `public.users`) need the
|
|
43
|
+
// schema-qualified API, not a dotted single-identifier — reject `.`
|
|
44
|
+
// here so the failure mode is explicit.
|
|
45
|
+
if (typeof tableName !== "string") {
|
|
46
|
+
throw new TypeError("Query: tableName must be a string, got " + typeof tableName);
|
|
47
|
+
}
|
|
48
|
+
if (tableName.indexOf(".") !== -1) {
|
|
49
|
+
throw new Error("Query: tableName '" + tableName + "' contains '.' — use a single " +
|
|
50
|
+
"identifier; cross-schema queries are not supported by db.from(). " +
|
|
51
|
+
"For Postgres-style schema.table access, use b.externalDb.query directly.");
|
|
52
|
+
}
|
|
53
|
+
// allowReserved: true — db-query always wraps the identifier in
|
|
54
|
+
// `"..."` so a table named `order` resolves correctly via the SQL
|
|
55
|
+
// standard quoting rule. The reserved-word block in safeSql is for
|
|
56
|
+
// call sites that interpolate unquoted.
|
|
57
|
+
safeSql.validateIdentifier(tableName, { allowReserved: true });
|
|
58
|
+
|
|
37
59
|
this._db = database;
|
|
38
60
|
this._table = tableName;
|
|
39
61
|
this._where = [];
|
package/lib/db-schema.js
CHANGED
|
@@ -33,10 +33,14 @@ function runSql(database, sql) { return database["exec"](sql); }
|
|
|
33
33
|
// ---- Internal migrations table ----
|
|
34
34
|
|
|
35
35
|
var MIGRATIONS_TABLE = "_blamejs_migrations";
|
|
36
|
+
// Pre-quoted for SQL interpolation — keeps the call sites consistent
|
|
37
|
+
// with lib/migrations.js and lib/seeders.js so an identifier rename
|
|
38
|
+
// doesn't silently break.
|
|
39
|
+
var Q_MIGRATIONS_TABLE = '"' + MIGRATIONS_TABLE + '"';
|
|
36
40
|
|
|
37
41
|
function ensureMigrationsTable(database) {
|
|
38
42
|
runSql(database,
|
|
39
|
-
"CREATE TABLE IF NOT EXISTS " +
|
|
43
|
+
"CREATE TABLE IF NOT EXISTS " + Q_MIGRATIONS_TABLE + " (" +
|
|
40
44
|
" name TEXT PRIMARY KEY," +
|
|
41
45
|
" description TEXT," +
|
|
42
46
|
" appliedAt TEXT NOT NULL" +
|
|
@@ -200,7 +204,7 @@ function runMigrations(database, migrationDir) {
|
|
|
200
204
|
}).map(function (e) { return e.name; }).sort();
|
|
201
205
|
|
|
202
206
|
var appliedSet = new Set();
|
|
203
|
-
database.prepare("SELECT name FROM " +
|
|
207
|
+
database.prepare("SELECT name FROM " + Q_MIGRATIONS_TABLE).all().forEach(function (r) {
|
|
204
208
|
appliedSet.add(r.name);
|
|
205
209
|
});
|
|
206
210
|
|
|
@@ -227,7 +231,7 @@ function runMigrations(database, migrationDir) {
|
|
|
227
231
|
runSql(database, "BEGIN");
|
|
228
232
|
mig.up(database);
|
|
229
233
|
database.prepare(
|
|
230
|
-
"INSERT INTO " +
|
|
234
|
+
"INSERT INTO " + Q_MIGRATIONS_TABLE + " (name, description, appliedAt) VALUES (?, ?, ?)"
|
|
231
235
|
).run(file, mig.description || "", new Date().toISOString());
|
|
232
236
|
runSql(database, "COMMIT");
|
|
233
237
|
} catch (e) {
|
package/lib/http-client.js
CHANGED
|
@@ -122,13 +122,62 @@ var _transports = new Map();
|
|
|
122
122
|
// idle sockets reaped quickly between bursts. ecdhCurve / minVersion
|
|
123
123
|
// come from pqc-agent and cannot be set here — the framework's
|
|
124
124
|
// PQC-only TLS posture is one place, in lib/pqc-agent.js.
|
|
125
|
-
|
|
125
|
+
//
|
|
126
|
+
// Operators tune at boot via `b.httpClient.configurePool({...})`.
|
|
127
|
+
// Existing transports stay on whichever values were active when they
|
|
128
|
+
// were created — reconfigure runs before any outbound request to take
|
|
129
|
+
// effect on the per-origin cache.
|
|
130
|
+
var DEFAULT_AGENT_OPTS = Object.freeze({
|
|
126
131
|
keepAlive: true,
|
|
127
132
|
keepAliveMsecs: 1000,
|
|
128
133
|
maxSockets: 16,
|
|
129
134
|
maxFreeSockets: 8,
|
|
130
135
|
scheduling: "lifo",
|
|
131
|
-
};
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
var HTTP_CLIENT_AGENT_OPTS = Object.assign({}, DEFAULT_AGENT_OPTS);
|
|
139
|
+
|
|
140
|
+
function configurePool(opts) {
|
|
141
|
+
if (!opts || typeof opts !== "object") {
|
|
142
|
+
throw new Error("httpClient.configurePool: opts must be an object");
|
|
143
|
+
}
|
|
144
|
+
var allowed = ["keepAlive", "keepAliveMsecs", "maxSockets", "maxFreeSockets", "scheduling"];
|
|
145
|
+
for (var k in opts) {
|
|
146
|
+
if (!Object.prototype.hasOwnProperty.call(opts, k)) continue;
|
|
147
|
+
if (allowed.indexOf(k) === -1) {
|
|
148
|
+
throw new Error("httpClient.configurePool: unknown option '" + k +
|
|
149
|
+
"'. Allowed: " + allowed.join(", "));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function _requirePositiveInt(name, value) {
|
|
153
|
+
if (typeof value !== "number" || !isFinite(value) || value <= 0 || Math.floor(value) !== value) {
|
|
154
|
+
throw new Error("httpClient.configurePool: " + name +
|
|
155
|
+
" must be a positive integer, got " + JSON.stringify(value));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (opts.maxSockets !== undefined) _requirePositiveInt("maxSockets", opts.maxSockets);
|
|
159
|
+
if (opts.maxFreeSockets !== undefined) _requirePositiveInt("maxFreeSockets", opts.maxFreeSockets);
|
|
160
|
+
if (opts.keepAliveMsecs !== undefined) _requirePositiveInt("keepAliveMsecs", opts.keepAliveMsecs);
|
|
161
|
+
if (opts.keepAlive !== undefined && typeof opts.keepAlive !== "boolean") {
|
|
162
|
+
throw new Error("httpClient.configurePool: keepAlive must be a boolean");
|
|
163
|
+
}
|
|
164
|
+
if (opts.scheduling !== undefined && opts.scheduling !== "lifo" && opts.scheduling !== "fifo") {
|
|
165
|
+
throw new Error("httpClient.configurePool: scheduling must be 'lifo' or 'fifo'");
|
|
166
|
+
}
|
|
167
|
+
Object.assign(HTTP_CLIENT_AGENT_OPTS, opts);
|
|
168
|
+
// Existing transports keep their old values (Agent constructor
|
|
169
|
+
// copies). Drop the per-origin cache + tear down idle sockets so
|
|
170
|
+
// subsequent requests build fresh transports with the new opts.
|
|
171
|
+
_transports.forEach(function (t) {
|
|
172
|
+
if (t && t.kind === "h1" && t.agent && typeof t.agent.destroy === "function") {
|
|
173
|
+
try { t.agent.destroy(); } catch (_e) {}
|
|
174
|
+
}
|
|
175
|
+
if (t && t.kind === "h2" && t.session && typeof t.session.close === "function") {
|
|
176
|
+
try { t.session.close(); } catch (_e) {}
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
_transports.clear();
|
|
180
|
+
}
|
|
132
181
|
|
|
133
182
|
// h2 session connect options. Same TLS posture as h1 Agent.
|
|
134
183
|
var DEFAULT_H2_TLS_OPTS = {
|
|
@@ -1026,8 +1075,10 @@ function _getCachedTransportKind(url) {
|
|
|
1026
1075
|
|
|
1027
1076
|
module.exports = {
|
|
1028
1077
|
request: request,
|
|
1078
|
+
configurePool: configurePool,
|
|
1029
1079
|
DEFAULT_CONTROL_PLANE_CAP: DEFAULT_CONTROL_PLANE_CAP,
|
|
1030
1080
|
DEFAULT_GET_CAP: DEFAULT_GET_CAP,
|
|
1081
|
+
DEFAULT_AGENT_OPTS: DEFAULT_AGENT_OPTS,
|
|
1031
1082
|
_resetForTest: _resetForTest,
|
|
1032
1083
|
_getCachedTransportCount: _getCachedTransportCount,
|
|
1033
1084
|
_getCachedTransportKind: _getCachedTransportKind,
|
|
@@ -203,12 +203,31 @@ function _typeMatches(actual, allowed) {
|
|
|
203
203
|
return false;
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
// RFC 9112 §6.1: Content-Length MUST be a sequence of decimal digits with
|
|
207
|
+
// no whitespace, sign, or trailing garbage. parseInt("123abc") returning
|
|
208
|
+
// 123 is the lenient parse that lets malformed headers slip past the
|
|
209
|
+
// preflight cap; the strict regex catches them at the boundary.
|
|
210
|
+
var STRICT_CONTENT_LENGTH = /^\d+$/;
|
|
211
|
+
|
|
212
|
+
function _parseContentLength(cl) {
|
|
213
|
+
if (typeof cl !== "string" || !STRICT_CONTENT_LENGTH.test(cl)) return null;
|
|
214
|
+
var n = Number(cl);
|
|
215
|
+
return isFinite(n) ? n : null;
|
|
216
|
+
}
|
|
217
|
+
|
|
206
218
|
function _hasBody(req) {
|
|
207
219
|
if (!BODY_BEARING_METHODS.has(req.method)) return false;
|
|
208
220
|
var cl = req.headers && req.headers["content-length"];
|
|
209
|
-
if (cl === "
|
|
210
|
-
|
|
211
|
-
|
|
221
|
+
if (typeof cl === "string") {
|
|
222
|
+
var clNum = _parseContentLength(cl);
|
|
223
|
+
// Spec-shaped zero (the only RFC 9112 §6.1 zero) → no body. Malformed
|
|
224
|
+
// values (non-decimal-digits) flow through as "yes, has body" so the
|
|
225
|
+
// downstream _bufferBody call rejects with 400 — silently treating
|
|
226
|
+
// a malformed header as "no body" would let the request slip past
|
|
227
|
+
// the parser entirely.
|
|
228
|
+
if (clNum === 0) return false;
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
212
231
|
var te = req.headers && req.headers["transfer-encoding"];
|
|
213
232
|
if (typeof te === "string" && te.length > 0) return true;
|
|
214
233
|
return false;
|
|
@@ -230,14 +249,22 @@ function _bufferBody(req, limit) {
|
|
|
230
249
|
return new Promise(function (resolve, reject) {
|
|
231
250
|
var cl = req.headers && req.headers["content-length"];
|
|
232
251
|
if (typeof cl === "string") {
|
|
233
|
-
var clNum =
|
|
234
|
-
if (
|
|
235
|
-
|
|
252
|
+
var clNum = _parseContentLength(cl);
|
|
253
|
+
if (clNum === null) {
|
|
254
|
+
// RFC 9112 §6.1 — malformed Content-Length is a 400.
|
|
255
|
+
reject(new BodyParserError(
|
|
256
|
+
"body-parser/bad-content-length",
|
|
257
|
+
"Content-Length is not a sequence of decimal digits: " + JSON.stringify(cl),
|
|
258
|
+
true, 400
|
|
259
|
+
));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (clNum > limit) {
|
|
263
|
+
reject(new BodyParserError(
|
|
236
264
|
"body-parser/too-large",
|
|
237
265
|
"request body exceeds limit (" + clNum + " > " + limit + ")",
|
|
238
266
|
true, 413
|
|
239
|
-
);
|
|
240
|
-
reject(err);
|
|
267
|
+
));
|
|
241
268
|
return;
|
|
242
269
|
}
|
|
243
270
|
}
|
package/lib/middleware/cors.js
CHANGED
|
@@ -13,14 +13,19 @@
|
|
|
13
13
|
* works behind TLS-terminating proxies where the framework can't
|
|
14
14
|
* infer scheme), or
|
|
15
15
|
* 2. Origin === request's inferred scheme/host/port (from req.socket
|
|
16
|
-
* and req.headers.host — correct for direct deployments)
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
16
|
+
* and req.headers.host — correct for direct deployments).
|
|
17
|
+
*
|
|
18
|
+
* Origin: null shortcut (opt-in via strictNullOrigin: false): browsers
|
|
19
|
+
* send `Origin: null` on form-navigation POSTs from a page whose response
|
|
20
|
+
* carries `Referrer-Policy: no-referrer` (or similarly strict policy) —
|
|
21
|
+
* the Origin is opaqued to prevent a cross-site leak. The Sec-Fetch-Site
|
|
22
|
+
* Fetch-metadata signal distinguishes a same-origin nav from a genuine
|
|
23
|
+
* cross-site post in that opaque-origin world. Default is to REFUSE the
|
|
24
|
+
* shortcut (strictNullOrigin: true) — non-browser clients can forge
|
|
25
|
+
* Sec-Fetch-Site freely, and operators using `refuseUnknown: true` as a
|
|
26
|
+
* stricter "refuse unrecognized origin" policy expect the gate to hold.
|
|
27
|
+
* Operators with a no-referrer page that legitimately produces
|
|
28
|
+
* Origin: null on same-origin POSTs flip strictNullOrigin: false.
|
|
24
29
|
*
|
|
25
30
|
* Options:
|
|
26
31
|
* {
|
|
@@ -33,14 +38,16 @@
|
|
|
33
38
|
* maxAgeSeconds: 600
|
|
34
39
|
* refuseUnknown: true (refuse cross-origin requests from unlisted
|
|
35
40
|
* origins instead of just omitting CORS headers)
|
|
41
|
+
* strictNullOrigin: true (default — refuse Origin: null even with
|
|
42
|
+
* Sec-Fetch-Site: same-origin. Set false to
|
|
43
|
+
* allow the no-referrer-page edge case.)
|
|
36
44
|
* }
|
|
37
45
|
*
|
|
38
46
|
* Audit: refuseUnknown blocks emit system.cors.block with the offending Origin.
|
|
39
47
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* request.
|
|
48
|
+
* Configuration validation: opts.siteOrigin must parse as an http(s) URL,
|
|
49
|
+
* opts.origins entries must be strings or RegExp. Bad config surfaces at
|
|
50
|
+
* create() not at first cross-origin request.
|
|
44
51
|
*/
|
|
45
52
|
var lazyRequire = require("../lazy-require");
|
|
46
53
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
@@ -60,12 +67,26 @@ function _xffIpFor(trustProxy) {
|
|
|
60
67
|
|
|
61
68
|
var CorsError = defineClass("CorsError", { alwaysPermanent: true });
|
|
62
69
|
|
|
70
|
+
// allowList entries:
|
|
71
|
+
// - { kind: "string", canonical: "https://app.example.com", original: "..." }
|
|
72
|
+
// - { kind: "regex", pattern: /.../ }
|
|
73
|
+
// Both raw entry and the inbound origin run through _canonicalOrigin
|
|
74
|
+
// before equality so case differences ("https://APP" vs "https://app")
|
|
75
|
+
// and default-port differences ("https://x:443" vs "https://x") match.
|
|
63
76
|
function _matchOrigin(origin, allowList) {
|
|
64
77
|
if (!origin) return null;
|
|
78
|
+
var canon = _canonicalOrigin(origin);
|
|
65
79
|
for (var i = 0; i < allowList.length; i++) {
|
|
66
80
|
var entry = allowList[i];
|
|
67
|
-
if (
|
|
68
|
-
|
|
81
|
+
if (entry.kind === "string") {
|
|
82
|
+
if (canon !== null && entry.canonical === canon) return origin;
|
|
83
|
+
} else if (entry.kind === "regex") {
|
|
84
|
+
// Regex entries match against the raw origin (operator wrote the
|
|
85
|
+
// pattern with whatever case / port shape they intended). Also try
|
|
86
|
+
// the canonical form so case-insensitive intent works without /i.
|
|
87
|
+
if (entry.pattern.test(origin)) return origin;
|
|
88
|
+
if (canon !== null && entry.pattern.test(canon)) return origin;
|
|
89
|
+
}
|
|
69
90
|
}
|
|
70
91
|
return null;
|
|
71
92
|
}
|
|
@@ -104,12 +125,15 @@ function _inferRequestOrigin(req, trustProxy) {
|
|
|
104
125
|
return _canonicalOrigin(proto + "://" + host);
|
|
105
126
|
}
|
|
106
127
|
|
|
107
|
-
function _isSameOrigin(req, originHeader, configuredSiteOrigins, trustProxy) {
|
|
108
|
-
// Origin: null
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
128
|
+
function _isSameOrigin(req, originHeader, configuredSiteOrigins, trustProxy, strictNullOrigin) {
|
|
129
|
+
// Origin: null arrives when a browser opaques the Origin (e.g.
|
|
130
|
+
// Referrer-Policy: no-referrer on the page). Sec-Fetch-Site can
|
|
131
|
+
// distinguish the same-origin case, but non-browser clients can forge
|
|
132
|
+
// that header freely — strictNullOrigin: true (default) refuses the
|
|
133
|
+
// shortcut so refuseUnknown holds against forged callers. Operators
|
|
134
|
+
// with a legitimate no-referrer page flip strictNullOrigin: false.
|
|
112
135
|
if (originHeader === "null") {
|
|
136
|
+
if (strictNullOrigin) return false;
|
|
113
137
|
var sfs = req && req.headers && req.headers["sec-fetch-site"];
|
|
114
138
|
if (sfs === "same-origin" || sfs === "none") return true;
|
|
115
139
|
return false;
|
|
@@ -137,17 +161,31 @@ function create(opts) {
|
|
|
137
161
|
validateOpts(opts, [
|
|
138
162
|
"origins", "siteOrigin", "methods", "headers", "exposeHeaders",
|
|
139
163
|
"credentials", "maxAgeSeconds", "refuseUnknown", "trustProxy",
|
|
164
|
+
"strictNullOrigin",
|
|
140
165
|
], "middleware.cors");
|
|
141
166
|
var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
|
|
142
167
|
? opts.trustProxy : false;
|
|
143
168
|
var _xffIp = _xffIpFor(trustProxy);
|
|
144
169
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
170
|
+
// Build a canonicalized allowList at create() time. String entries
|
|
171
|
+
// get parsed through _canonicalOrigin so case + default-port
|
|
172
|
+
// differences match consistently between the configured value and
|
|
173
|
+
// the inbound Origin header. RegExp entries stay as the operator
|
|
174
|
+
// wrote them.
|
|
175
|
+
var rawOrigins = opts.origins || [];
|
|
176
|
+
var origins = [];
|
|
177
|
+
for (var oi = 0; oi < rawOrigins.length; oi++) {
|
|
178
|
+
var entry = rawOrigins[oi];
|
|
179
|
+
if (typeof entry === "string") {
|
|
180
|
+
var canonEntry = _canonicalOrigin(entry);
|
|
181
|
+
if (canonEntry === null) {
|
|
182
|
+
throw new CorsError("cors/bad-origin",
|
|
183
|
+
"origins[" + oi + "]='" + entry + "' is not a parseable http(s) URL");
|
|
184
|
+
}
|
|
185
|
+
origins.push({ kind: "string", canonical: canonEntry, original: entry });
|
|
186
|
+
} else if (entry instanceof RegExp) {
|
|
187
|
+
origins.push({ kind: "regex", pattern: entry });
|
|
188
|
+
} else {
|
|
151
189
|
throw new CorsError("cors/bad-origin",
|
|
152
190
|
"origins[" + oi + "] must be a string or RegExp (got " + typeof entry + ")");
|
|
153
191
|
}
|
|
@@ -185,6 +223,11 @@ function create(opts) {
|
|
|
185
223
|
var credentials = !!opts.credentials;
|
|
186
224
|
var maxAge = String(opts.maxAgeSeconds || 600);
|
|
187
225
|
var refuseUnknown = opts.refuseUnknown !== false;
|
|
226
|
+
// strictNullOrigin defaults true: refuse Origin: null even with
|
|
227
|
+
// Sec-Fetch-Site: same-origin (non-browser callers can forge that
|
|
228
|
+
// header). Operators with a no-referrer page producing legitimate
|
|
229
|
+
// Origin: null on same-origin POSTs flip to false explicitly.
|
|
230
|
+
var strictNullOrigin = opts.strictNullOrigin !== false;
|
|
188
231
|
|
|
189
232
|
return function cors(req, res, next) {
|
|
190
233
|
var origin = req.headers && req.headers.origin;
|
|
@@ -193,7 +236,7 @@ function create(opts) {
|
|
|
193
236
|
// Same-origin POST/PUT/etc. carry an Origin header per the Fetch
|
|
194
237
|
// spec but should not be subject to CORS allow-listing — they're
|
|
195
238
|
// the operator's own site talking to itself.
|
|
196
|
-
if (_isSameOrigin(req, origin, siteOrigins, trustProxy)) return next();
|
|
239
|
+
if (_isSameOrigin(req, origin, siteOrigins, trustProxy, strictNullOrigin)) return next();
|
|
197
240
|
|
|
198
241
|
var matched = _matchOrigin(origin, origins);
|
|
199
242
|
if (!matched) {
|
|
@@ -50,7 +50,9 @@
|
|
|
50
50
|
* Full options:
|
|
51
51
|
* {
|
|
52
52
|
* cookie: true | { EITHER this...
|
|
53
|
-
* name: "csrf",
|
|
53
|
+
* name: auto: "__Host-csrf" over HTTPS, "csrf" over HTTP.
|
|
54
|
+
* Operators with a custom name override here; the framework
|
|
55
|
+
* validates that __Host-* names carry path="/" and Secure.
|
|
54
56
|
* sameSite: "Lax" | "Strict" | "None",
|
|
55
57
|
* secure: auto-detected from request scheme,
|
|
56
58
|
* path: "/",
|
|
@@ -73,7 +75,15 @@ var audit = lazyRequire(function () { return require("../audit"); });
|
|
|
73
75
|
var DEFAULT_FIELD_NAME = "_csrf";
|
|
74
76
|
var DEFAULT_HEADER_NAME = "X-CSRF-Token";
|
|
75
77
|
var DEFAULT_METHODS = Object.freeze(["POST", "PUT", "DELETE", "PATCH"]);
|
|
76
|
-
|
|
78
|
+
|
|
79
|
+
// Default cookie name uses the RFC 6265bis __Host- prefix when the request
|
|
80
|
+
// is over HTTPS. The prefix forces browsers to refuse the cookie unless
|
|
81
|
+
// it carries Secure + Path=/ + no Domain attribute — closing the
|
|
82
|
+
// "malicious sibling subdomain sets a cookie on the parent domain to
|
|
83
|
+
// subvert double-submit verification" attack class. On plain HTTP (dev),
|
|
84
|
+
// browsers reject __Host- entirely, so we fall back to the bare name.
|
|
85
|
+
var DEFAULT_COOKIE_NAME_SECURE = "__Host-csrf";
|
|
86
|
+
var DEFAULT_COOKIE_NAME_INSECURE = "csrf";
|
|
77
87
|
|
|
78
88
|
function _parseCookieHeader(header) {
|
|
79
89
|
// Minimal cookie-header parser — RFC 6265 §5.2 form. Ignores attributes,
|
|
@@ -191,7 +201,9 @@ function create(opts) {
|
|
|
191
201
|
throw new Error("middleware.csrfProtect: opts.cookie must be true or an object");
|
|
192
202
|
}
|
|
193
203
|
cookieCfg = {
|
|
194
|
-
name:
|
|
204
|
+
// name: explicit operator override wins; otherwise auto-resolved
|
|
205
|
+
// per-request based on whether the cookie is being issued Secure.
|
|
206
|
+
name: raw.name || null,
|
|
195
207
|
sameSite: raw.sameSite || "Lax",
|
|
196
208
|
// secure: undefined means auto-detect from request scheme; explicit
|
|
197
209
|
// true/false overrides.
|
|
@@ -203,6 +215,27 @@ function create(opts) {
|
|
|
203
215
|
if (["Lax", "Strict", "None"].indexOf(cookieCfg.sameSite) === -1) {
|
|
204
216
|
throw new Error("middleware.csrfProtect: opts.cookie.sameSite must be Lax|Strict|None");
|
|
205
217
|
}
|
|
218
|
+
// __Host- prefix safety: if operator picks a __Host- name, the
|
|
219
|
+
// Path/Domain/Secure constraints must be compatible. Path must be "/",
|
|
220
|
+
// no Domain (we never set one), Secure resolved per-request. Catch
|
|
221
|
+
// operator-side typos (e.g. __Host-csrf with a custom path) at boot.
|
|
222
|
+
if (cookieCfg.name && /^__Host-/.test(cookieCfg.name)) {
|
|
223
|
+
if (cookieCfg.path !== "/") {
|
|
224
|
+
throw new Error("middleware.csrfProtect: __Host-* cookie name requires path='/'");
|
|
225
|
+
}
|
|
226
|
+
if (cookieCfg.secure === false) {
|
|
227
|
+
throw new Error("middleware.csrfProtect: __Host-* cookie name requires secure (cannot be explicit false)");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Resolve the cookie name for a specific request — operator override
|
|
233
|
+
// wins; otherwise __Host-csrf when the cookie will be Secure, plain
|
|
234
|
+
// csrf when over HTTP (browsers reject __Host- without Secure).
|
|
235
|
+
function _resolveCookieName(req) {
|
|
236
|
+
if (cookieCfg.name) return cookieCfg.name;
|
|
237
|
+
var willBeSecure = cookieCfg.secure == null ? _isHttps(req) : !!cookieCfg.secure;
|
|
238
|
+
return willBeSecure ? DEFAULT_COOKIE_NAME_SECURE : DEFAULT_COOKIE_NAME_INSECURE;
|
|
206
239
|
}
|
|
207
240
|
|
|
208
241
|
function _emitDenied(req, reason) {
|
|
@@ -221,14 +254,15 @@ function create(opts) {
|
|
|
221
254
|
// req.csrfToken so templates have something to render.
|
|
222
255
|
function _issueIfNeeded(req, res) {
|
|
223
256
|
if (!cookieCfg) return null;
|
|
257
|
+
var cookieName = _resolveCookieName(req);
|
|
224
258
|
var cookies = _parseCookieHeader(req.headers && req.headers.cookie);
|
|
225
|
-
var existing = cookies[
|
|
259
|
+
var existing = cookies[cookieName];
|
|
226
260
|
if (existing && /^[a-f0-9]{2,}$/.test(existing)) {
|
|
227
261
|
req.csrfToken = existing;
|
|
228
262
|
return existing;
|
|
229
263
|
}
|
|
230
264
|
var fresh = forms.generateCsrfToken();
|
|
231
|
-
var setCookie = _formatSetCookie(
|
|
265
|
+
var setCookie = _formatSetCookie(cookieName, fresh, {
|
|
232
266
|
path: cookieCfg.path,
|
|
233
267
|
sameSite: cookieCfg.sameSite,
|
|
234
268
|
secure: cookieCfg.secure == null ? _isHttps(req) : !!cookieCfg.secure,
|
|
@@ -64,11 +64,23 @@ function _clientIpFor(trustProxy) {
|
|
|
64
64
|
};
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// Reject NaN / Infinity / negative / non-positive / non-number at create
|
|
68
|
+
// time so a misconfigured rate-limit can't silently degrade to "no
|
|
69
|
+
// limit" or produce divide-by-zero verdicts at request time.
|
|
70
|
+
function _requirePositiveNumber(name, value) {
|
|
71
|
+
if (typeof value !== "number" || !isFinite(value) || value <= 0) {
|
|
72
|
+
throw new Error("middleware.rateLimit: " + name + " must be a positive finite number, got " +
|
|
73
|
+
JSON.stringify(value));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
67
77
|
// ---- Memory backend (token bucket) ----
|
|
68
78
|
|
|
69
79
|
function _memoryBackend(opts) {
|
|
70
|
-
var burst = opts.burst
|
|
71
|
-
var refillPerSecond = opts.refillPerSecond
|
|
80
|
+
var burst = opts.burst != null ? opts.burst : 60;
|
|
81
|
+
var refillPerSecond = opts.refillPerSecond != null ? opts.refillPerSecond : 10;
|
|
82
|
+
_requirePositiveNumber("burst", burst);
|
|
83
|
+
_requirePositiveNumber("refillPerSecond", refillPerSecond);
|
|
72
84
|
var buckets = new Map();
|
|
73
85
|
|
|
74
86
|
// Periodic GC of stale buckets so the map doesn't grow unbounded.
|
|
@@ -128,9 +140,13 @@ function _memoryBackend(opts) {
|
|
|
128
140
|
// ---- Cluster backend (fixed-window counter, SQL-backed) ----
|
|
129
141
|
|
|
130
142
|
function _clusterBackend(opts) {
|
|
131
|
-
var limit = opts.limit
|
|
132
|
-
var windowMs = opts.windowMs
|
|
133
|
-
var pruneIntervalMs = opts.pruneIntervalMs
|
|
143
|
+
var limit = opts.limit != null ? opts.limit : 60;
|
|
144
|
+
var windowMs = opts.windowMs != null ? opts.windowMs : C.TIME.minutes(1);
|
|
145
|
+
var pruneIntervalMs = opts.pruneIntervalMs != null
|
|
146
|
+
? opts.pruneIntervalMs : C.TIME.minutes(5);
|
|
147
|
+
_requirePositiveNumber("limit", limit);
|
|
148
|
+
_requirePositiveNumber("windowMs", windowMs);
|
|
149
|
+
_requirePositiveNumber("pruneIntervalMs", pruneIntervalMs);
|
|
134
150
|
var lastPruneAt = 0;
|
|
135
151
|
|
|
136
152
|
// Best-effort prune of expired window rows. Rate-limited at the
|
package/lib/migrations.js
CHANGED
|
@@ -55,6 +55,10 @@ class MigrationError extends FrameworkError {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
var MIGRATIONS_TABLE = "_blamejs_migrations";
|
|
58
|
+
// Always interpolate identifiers wrapped in `"..."` so a reserved-word
|
|
59
|
+
// or whitespace-bearing name resolves correctly (defense-in-depth even
|
|
60
|
+
// though our constant is bare-identifier-shaped).
|
|
61
|
+
var Q_MIGRATIONS_TABLE = '"' + MIGRATIONS_TABLE + '"';
|
|
58
62
|
// Filename grammar: leading numeric prefix (any width), then '-', then a
|
|
59
63
|
// non-empty body, then '.js'. Numeric prefix orders execution. Letters
|
|
60
64
|
// in the body include hyphens, underscores, and alphanumerics; anything
|
|
@@ -68,7 +72,7 @@ function _runSql(db, sql) { return db["exec"](sql); }
|
|
|
68
72
|
|
|
69
73
|
function _ensureTable(db) {
|
|
70
74
|
_runSql(db,
|
|
71
|
-
"CREATE TABLE IF NOT EXISTS " +
|
|
75
|
+
"CREATE TABLE IF NOT EXISTS " + Q_MIGRATIONS_TABLE + " (" +
|
|
72
76
|
" name TEXT PRIMARY KEY," +
|
|
73
77
|
" description TEXT," +
|
|
74
78
|
" appliedAt TEXT NOT NULL" +
|
|
@@ -80,11 +84,12 @@ function _ensureTable(db) {
|
|
|
80
84
|
// concurrently against the same DB race on this table: the winner of
|
|
81
85
|
// the INSERT acquires the lock; the loser sees a UNIQUE violation and
|
|
82
86
|
// the operator gets a clear "lock held by other process" error.
|
|
83
|
-
var LOCK_TABLE
|
|
87
|
+
var LOCK_TABLE = "_blamejs_migrations_lock";
|
|
88
|
+
var Q_LOCK_TABLE = '"' + LOCK_TABLE + '"';
|
|
84
89
|
|
|
85
90
|
function _ensureLockTable(db) {
|
|
86
91
|
_runSql(db,
|
|
87
|
-
"CREATE TABLE IF NOT EXISTS " +
|
|
92
|
+
"CREATE TABLE IF NOT EXISTS " + Q_LOCK_TABLE + " (" +
|
|
88
93
|
" scope TEXT PRIMARY KEY," +
|
|
89
94
|
" lockedAt INTEGER NOT NULL," +
|
|
90
95
|
" lockedBy TEXT NOT NULL," +
|
|
@@ -108,19 +113,19 @@ function _acquireLock(db, opts) {
|
|
|
108
113
|
// Try to insert; if there's a stale lock, optionally force-replace it.
|
|
109
114
|
try {
|
|
110
115
|
db.prepare(
|
|
111
|
-
"INSERT INTO " +
|
|
116
|
+
"INSERT INTO " + Q_LOCK_TABLE + " (scope, lockedAt, lockedBy) VALUES ('lock', ?, ?)"
|
|
112
117
|
).run(nowMs, holder);
|
|
113
118
|
return holder;
|
|
114
119
|
} catch {
|
|
115
120
|
// PRIMARY KEY conflict → existing lock. Inspect it.
|
|
116
121
|
var existing = db.prepare(
|
|
117
|
-
"SELECT lockedAt, lockedBy FROM " +
|
|
122
|
+
"SELECT lockedAt, lockedBy FROM " + Q_LOCK_TABLE + " WHERE scope = 'lock'"
|
|
118
123
|
).get();
|
|
119
124
|
if (!existing) {
|
|
120
125
|
// Race window between INSERT failure and SELECT — try once more.
|
|
121
126
|
try {
|
|
122
127
|
db.prepare(
|
|
123
|
-
"INSERT INTO " +
|
|
128
|
+
"INSERT INTO " + Q_LOCK_TABLE + " (scope, lockedAt, lockedBy) VALUES ('lock', ?, ?)"
|
|
124
129
|
).run(nowMs, holder);
|
|
125
130
|
return holder;
|
|
126
131
|
} catch (e2) {
|
|
@@ -135,10 +140,10 @@ function _acquireLock(db, opts) {
|
|
|
135
140
|
// single transaction so the next process can't slip in between.
|
|
136
141
|
_runSql(db, "BEGIN IMMEDIATE");
|
|
137
142
|
try {
|
|
138
|
-
db.prepare("DELETE FROM " +
|
|
143
|
+
db.prepare("DELETE FROM " + Q_LOCK_TABLE + " WHERE scope = 'lock' AND lockedAt = ?")
|
|
139
144
|
.run(existing.lockedAt);
|
|
140
145
|
db.prepare(
|
|
141
|
-
"INSERT INTO " +
|
|
146
|
+
"INSERT INTO " + Q_LOCK_TABLE + " (scope, lockedAt, lockedBy) VALUES ('lock', ?, ?)"
|
|
142
147
|
).run(nowMs, holder);
|
|
143
148
|
_runSql(db, "COMMIT");
|
|
144
149
|
return holder;
|
|
@@ -163,7 +168,7 @@ function _releaseLock(db, holder) {
|
|
|
163
168
|
// the operator explicitly used the staleAfterMs path.
|
|
164
169
|
try {
|
|
165
170
|
db.prepare(
|
|
166
|
-
"DELETE FROM " +
|
|
171
|
+
"DELETE FROM " + Q_LOCK_TABLE + " WHERE scope = 'lock' AND lockedBy = ?"
|
|
167
172
|
).run(holder);
|
|
168
173
|
} catch (_e) { /* best-effort release; operator can DELETE manually */ }
|
|
169
174
|
}
|
|
@@ -240,7 +245,7 @@ function create(opts) {
|
|
|
240
245
|
var db = _resolveDb(opts);
|
|
241
246
|
_ensureTable(db);
|
|
242
247
|
return db.prepare(
|
|
243
|
-
"SELECT name, description, appliedAt FROM " +
|
|
248
|
+
"SELECT name, description, appliedAt FROM " + Q_MIGRATIONS_TABLE +
|
|
244
249
|
" ORDER BY appliedAt ASC, name ASC"
|
|
245
250
|
).all();
|
|
246
251
|
}
|
|
@@ -262,7 +267,7 @@ function create(opts) {
|
|
|
262
267
|
_ensureTable(db);
|
|
263
268
|
return _withLock(db, opts, function () {
|
|
264
269
|
var appliedSet = new Set(
|
|
265
|
-
db.prepare("SELECT name FROM " +
|
|
270
|
+
db.prepare("SELECT name FROM " + Q_MIGRATIONS_TABLE).all()
|
|
266
271
|
.map(function (r) { return r.name; })
|
|
267
272
|
);
|
|
268
273
|
var files = _list(dir);
|
|
@@ -276,7 +281,7 @@ function create(opts) {
|
|
|
276
281
|
_txn(db, function () {
|
|
277
282
|
mod.up(db);
|
|
278
283
|
db.prepare(
|
|
279
|
-
"INSERT INTO " +
|
|
284
|
+
"INSERT INTO " + Q_MIGRATIONS_TABLE +
|
|
280
285
|
" (name, description, appliedAt) VALUES (?, ?, ?)"
|
|
281
286
|
).run(file, mod.description || "", new Date().toISOString());
|
|
282
287
|
});
|
|
@@ -306,7 +311,7 @@ function create(opts) {
|
|
|
306
311
|
// then by name as a stable tiebreaker for fixtures with identical
|
|
307
312
|
// timestamps).
|
|
308
313
|
var rows = db.prepare(
|
|
309
|
-
"SELECT name FROM " +
|
|
314
|
+
"SELECT name FROM " + Q_MIGRATIONS_TABLE +
|
|
310
315
|
" ORDER BY appliedAt DESC, name DESC LIMIT ?"
|
|
311
316
|
).all(steps);
|
|
312
317
|
|
|
@@ -323,7 +328,7 @@ function create(opts) {
|
|
|
323
328
|
try {
|
|
324
329
|
_txn(db, function () {
|
|
325
330
|
mod.down(db);
|
|
326
|
-
db.prepare("DELETE FROM " +
|
|
331
|
+
db.prepare("DELETE FROM " + Q_MIGRATIONS_TABLE + " WHERE name = ?").run(file);
|
|
327
332
|
});
|
|
328
333
|
} catch (e) {
|
|
329
334
|
throw new MigrationError("migrations/down-failed",
|
package/lib/safe-sql.js
CHANGED
|
@@ -124,6 +124,43 @@ function quoteIdentifier(name, dialect) {
|
|
|
124
124
|
return '"' + name + '"';
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
// Quote a multi-part qualified name like `schema.table` or
|
|
128
|
+
// `database.schema.table`. Each segment is validated + quoted
|
|
129
|
+
// independently so the dotted form `"schema"."table"` resolves
|
|
130
|
+
// correctly. Replaces the wrong shape `"schema.table"` (one literal
|
|
131
|
+
// identifier with a dot in it). Accepts an array of parts OR a string
|
|
132
|
+
// with `.` as the separator.
|
|
133
|
+
//
|
|
134
|
+
// quoteQualified(["public", "users"]) → '"public"."users"'
|
|
135
|
+
// quoteQualified("public.users") → '"public"."users"'
|
|
136
|
+
// quoteQualified(["public", "Order"], "postgres")
|
|
137
|
+
// → '"public"."Order"' (case preserved)
|
|
138
|
+
// quoteQualified("dbA.public.users") → '"dbA"."public"."users"'
|
|
139
|
+
function quoteQualified(parts, dialect) {
|
|
140
|
+
var arr;
|
|
141
|
+
if (typeof parts === "string") {
|
|
142
|
+
if (parts.length === 0) {
|
|
143
|
+
throw new SafeSqlError("qualified name must not be empty", "sql/empty");
|
|
144
|
+
}
|
|
145
|
+
arr = parts.split(".");
|
|
146
|
+
} else if (Array.isArray(parts)) {
|
|
147
|
+
arr = parts.slice();
|
|
148
|
+
} else {
|
|
149
|
+
throw new SafeSqlError(
|
|
150
|
+
"qualified name must be a string or array, got " + typeof parts,
|
|
151
|
+
"sql/bad-type"
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (arr.length === 0) {
|
|
155
|
+
throw new SafeSqlError("qualified name must have at least one segment", "sql/empty");
|
|
156
|
+
}
|
|
157
|
+
var quoted = [];
|
|
158
|
+
for (var i = 0; i < arr.length; i++) {
|
|
159
|
+
quoted.push(quoteIdentifier(arr[i], dialect));
|
|
160
|
+
}
|
|
161
|
+
return quoted.join(".");
|
|
162
|
+
}
|
|
163
|
+
|
|
127
164
|
function assertOneOf(name, allowlist) {
|
|
128
165
|
if (typeof name !== "string") {
|
|
129
166
|
throw new SafeSqlError("name must be a string", "sql/bad-type");
|
|
@@ -149,6 +186,7 @@ function assertOneOf(name, allowlist) {
|
|
|
149
186
|
module.exports = {
|
|
150
187
|
validateIdentifier: validateIdentifier,
|
|
151
188
|
quoteIdentifier: quoteIdentifier,
|
|
189
|
+
quoteQualified: quoteQualified,
|
|
152
190
|
assertOneOf: assertOneOf,
|
|
153
191
|
SafeSqlError: SafeSqlError,
|
|
154
192
|
// Exposed so consumers can compose their own validators
|
package/lib/seeders.js
CHANGED
|
@@ -68,6 +68,11 @@ var _err = SeederError.factory;
|
|
|
68
68
|
|
|
69
69
|
var SEEDERS_TABLE = "_blamejs_seeders";
|
|
70
70
|
var LOCK_TABLE = "_blamejs_seeders_lock";
|
|
71
|
+
// Pre-quoted forms used at every SQL interpolation site — defense in
|
|
72
|
+
// depth so a future rename to a reserved-word or whitespace-bearing
|
|
73
|
+
// table name doesn't silently break the query.
|
|
74
|
+
var Q_SEEDERS_TABLE = '"' + SEEDERS_TABLE + '"';
|
|
75
|
+
var Q_LOCK_TABLE = '"' + LOCK_TABLE + '"';
|
|
71
76
|
|
|
72
77
|
// Filename grammar: leading numeric prefix (any width), '-', non-empty
|
|
73
78
|
// body of [A-Za-z0-9_-], '.js'. Same shape as migrations to avoid
|
|
@@ -285,7 +290,7 @@ function _ensureTables(db) {
|
|
|
285
290
|
// EXISTS here is defensive for tests that hand-seed a fresh
|
|
286
291
|
// node:sqlite Database without going through b.db.
|
|
287
292
|
_runSql(db,
|
|
288
|
-
"CREATE TABLE IF NOT EXISTS " +
|
|
293
|
+
"CREATE TABLE IF NOT EXISTS " + Q_SEEDERS_TABLE + " (" +
|
|
289
294
|
" env TEXT NOT NULL," +
|
|
290
295
|
" name TEXT NOT NULL," +
|
|
291
296
|
" description TEXT," +
|
|
@@ -295,7 +300,7 @@ function _ensureTables(db) {
|
|
|
295
300
|
")"
|
|
296
301
|
);
|
|
297
302
|
_runSql(db,
|
|
298
|
-
"CREATE TABLE IF NOT EXISTS " +
|
|
303
|
+
"CREATE TABLE IF NOT EXISTS " + Q_LOCK_TABLE + " (" +
|
|
299
304
|
" scope TEXT PRIMARY KEY CHECK (scope = 'lock')," +
|
|
300
305
|
" lockedAt INTEGER NOT NULL," +
|
|
301
306
|
" lockedBy TEXT NOT NULL" +
|
|
@@ -312,18 +317,18 @@ function _acquireLock(db, lockStaleAfterMs, clock) {
|
|
|
312
317
|
var nowMs = clock();
|
|
313
318
|
try {
|
|
314
319
|
db.prepare(
|
|
315
|
-
"INSERT INTO " +
|
|
320
|
+
"INSERT INTO " + Q_LOCK_TABLE + " (scope, lockedAt, lockedBy) VALUES ('lock', ?, ?)"
|
|
316
321
|
).run(nowMs, holder);
|
|
317
322
|
return holder;
|
|
318
323
|
} catch (_e) {
|
|
319
324
|
var existing = db.prepare(
|
|
320
|
-
"SELECT lockedAt, lockedBy FROM " +
|
|
325
|
+
"SELECT lockedAt, lockedBy FROM " + Q_LOCK_TABLE + " WHERE scope = 'lock'"
|
|
321
326
|
).get();
|
|
322
327
|
if (!existing) {
|
|
323
328
|
// Race window between INSERT failure and SELECT — try once more.
|
|
324
329
|
try {
|
|
325
330
|
db.prepare(
|
|
326
|
-
"INSERT INTO " +
|
|
331
|
+
"INSERT INTO " + Q_LOCK_TABLE + " (scope, lockedAt, lockedBy) VALUES ('lock', ?, ?)"
|
|
327
332
|
).run(nowMs, holder);
|
|
328
333
|
return holder;
|
|
329
334
|
} catch (e2) {
|
|
@@ -335,10 +340,10 @@ function _acquireLock(db, lockStaleAfterMs, clock) {
|
|
|
335
340
|
if (lockStaleAfterMs > 0 && ageMs > lockStaleAfterMs) {
|
|
336
341
|
_runSql(db, "BEGIN IMMEDIATE");
|
|
337
342
|
try {
|
|
338
|
-
db.prepare("DELETE FROM " +
|
|
343
|
+
db.prepare("DELETE FROM " + Q_LOCK_TABLE + " WHERE scope = 'lock' AND lockedAt = ?")
|
|
339
344
|
.run(existing.lockedAt);
|
|
340
345
|
db.prepare(
|
|
341
|
-
"INSERT INTO " +
|
|
346
|
+
"INSERT INTO " + Q_LOCK_TABLE + " (scope, lockedAt, lockedBy) VALUES ('lock', ?, ?)"
|
|
342
347
|
).run(nowMs, holder);
|
|
343
348
|
_runSql(db, "COMMIT");
|
|
344
349
|
return holder;
|
|
@@ -358,7 +363,7 @@ function _acquireLock(db, lockStaleAfterMs, clock) {
|
|
|
358
363
|
function _releaseLock(db, holder) {
|
|
359
364
|
try {
|
|
360
365
|
db.prepare(
|
|
361
|
-
"DELETE FROM " +
|
|
366
|
+
"DELETE FROM " + Q_LOCK_TABLE + " WHERE scope = 'lock' AND lockedBy = ?"
|
|
362
367
|
).run(holder);
|
|
363
368
|
} catch (_e) { /* best-effort */ }
|
|
364
369
|
}
|
|
@@ -409,7 +414,7 @@ function create(opts) {
|
|
|
409
414
|
|
|
410
415
|
function _appliedRows(db, env) {
|
|
411
416
|
return db.prepare(
|
|
412
|
-
"SELECT name, description, appliedAt, rerunnable FROM " +
|
|
417
|
+
"SELECT name, description, appliedAt, rerunnable FROM " + Q_SEEDERS_TABLE +
|
|
413
418
|
" WHERE env = ? ORDER BY appliedAt ASC, name ASC"
|
|
414
419
|
).all(env);
|
|
415
420
|
}
|
|
@@ -472,7 +477,7 @@ function create(opts) {
|
|
|
472
477
|
var holder = _acquireLock(db, lockStaleAfterMs, clock);
|
|
473
478
|
try {
|
|
474
479
|
var appliedSet = new Set(
|
|
475
|
-
db.prepare("SELECT name FROM " +
|
|
480
|
+
db.prepare("SELECT name FROM " + Q_SEEDERS_TABLE + " WHERE env = ?").all(env)
|
|
476
481
|
.map(function (r) { return r.name; })
|
|
477
482
|
);
|
|
478
483
|
|
|
@@ -507,21 +512,21 @@ function create(opts) {
|
|
|
507
512
|
await mod.run(db, ctx);
|
|
508
513
|
if (alreadyApplied && mod.rerunnable) {
|
|
509
514
|
db.prepare(
|
|
510
|
-
"UPDATE " +
|
|
515
|
+
"UPDATE " + Q_SEEDERS_TABLE +
|
|
511
516
|
" SET appliedAt = ?, description = ?, rerunnable = ?" +
|
|
512
517
|
" WHERE env = ? AND name = ?"
|
|
513
518
|
).run(new Date(clock()).toISOString(), mod.description || "",
|
|
514
519
|
mod.rerunnable ? 1 : 0, env, name);
|
|
515
520
|
} else if (alreadyApplied && force) {
|
|
516
521
|
db.prepare(
|
|
517
|
-
"UPDATE " +
|
|
522
|
+
"UPDATE " + Q_SEEDERS_TABLE +
|
|
518
523
|
" SET appliedAt = ?, description = ?" +
|
|
519
524
|
" WHERE env = ? AND name = ?"
|
|
520
525
|
).run(new Date(clock()).toISOString(), mod.description || "",
|
|
521
526
|
env, name);
|
|
522
527
|
} else {
|
|
523
528
|
db.prepare(
|
|
524
|
-
"INSERT INTO " +
|
|
529
|
+
"INSERT INTO " + Q_SEEDERS_TABLE +
|
|
525
530
|
" (env, name, description, appliedAt, rerunnable) VALUES (?, ?, ?, ?, ?)"
|
|
526
531
|
).run(env, name, mod.description || "",
|
|
527
532
|
new Date(clock()).toISOString(), mod.rerunnable ? 1 : 0);
|
package/lib/session.js
CHANGED
|
@@ -47,6 +47,22 @@ var { SessionError } = require("./framework-error");
|
|
|
47
47
|
var _err = SessionError.factory;
|
|
48
48
|
|
|
49
49
|
var DEFAULT_TTL_MS = C.TIME.days(7);
|
|
50
|
+
// Sanity bound: any session that lives longer than this is almost
|
|
51
|
+
// certainly a misconfigured Infinity / oversized literal. Keeps
|
|
52
|
+
// expiresAt away from epoch overflow + database-int boundary issues.
|
|
53
|
+
var MAX_TTL_MS = C.TIME.days(3650); // ~10 years
|
|
54
|
+
|
|
55
|
+
function _validateTtl(ttl, where) {
|
|
56
|
+
if (typeof ttl !== "number" || !isFinite(ttl) || ttl <= 0) {
|
|
57
|
+
throw _err("INVALID_ARG",
|
|
58
|
+
where + ": ttlMs must be a positive finite number, got " + JSON.stringify(ttl), true);
|
|
59
|
+
}
|
|
60
|
+
if (ttl > MAX_TTL_MS) {
|
|
61
|
+
throw _err("INVALID_ARG",
|
|
62
|
+
where + ": ttlMs " + ttl + " exceeds maximum " + MAX_TTL_MS + " (~10 years). " +
|
|
63
|
+
"Sessions this long suggest a misconfigured value.", true);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
50
66
|
var SID_NAMESPACE = "bj-session:";
|
|
51
67
|
|
|
52
68
|
// Column order used for INSERT — kept as a constant so the placeholders
|
|
@@ -76,8 +92,8 @@ async function create(opts) {
|
|
|
76
92
|
if (!opts || !opts.userId) {
|
|
77
93
|
throw _err("INVALID_ARG", "session.create requires { userId }", true);
|
|
78
94
|
}
|
|
79
|
-
var ttl =
|
|
80
|
-
|
|
95
|
+
var ttl = opts.ttlMs !== undefined ? opts.ttlMs : DEFAULT_TTL_MS;
|
|
96
|
+
_validateTtl(ttl, "session.create");
|
|
81
97
|
|
|
82
98
|
var sid = generateToken(32); // 64 hex chars; only place the plaintext sid lives
|
|
83
99
|
var sidHash = _hashSid(sid);
|
|
@@ -243,8 +259,11 @@ async function rotate(oldToken, opts) {
|
|
|
243
259
|
var newSidHash = _hashSid(newSid);
|
|
244
260
|
var oldSidHash = _hashSid(oldToken);
|
|
245
261
|
var nowMs = Date.now();
|
|
246
|
-
var newExpires
|
|
247
|
-
|
|
262
|
+
var newExpires = null;
|
|
263
|
+
if (opts.ttlMs !== undefined) {
|
|
264
|
+
_validateTtl(opts.ttlMs, "session.rotate");
|
|
265
|
+
newExpires = nowMs + opts.ttlMs;
|
|
266
|
+
}
|
|
248
267
|
|
|
249
268
|
var setParts = ['"sidHash" = ?', '"lastActivity" = ?'];
|
|
250
269
|
var setParams = [newSidHash, nowMs];
|