@blamejs/core 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.5.x
10
10
 
11
+ - **0.5.2** (2026-04-30) — b.breakGlass: passkey factor + service-account bypass + admin tools
12
+ - **0.5.1** (2026-04-30) — b.breakGlass: per-cell encryption + context binding + migrate
11
13
  - **0.5.0** (2026-04-30) — b.breakGlass: column-policy / row-enforcement step-up auth
12
14
 
13
15
  ## v0.4.x
@@ -54,6 +54,7 @@ var { defineClass } = require("./framework-error");
54
54
  var vault = lazyRequire(function () { return require("./vault"); });
55
55
 
56
56
  var lockout = lazyRequire(function () { return require("./auth/lockout"); });
57
+ var passkey = lazyRequire(function () { return require("./auth/passkey"); });
57
58
 
58
59
  // Errors — all 14 codes documented in the spec. `permanent: true`
59
60
  // means caller's input is bad (Tier-A); `permanent: false` means
@@ -67,7 +68,7 @@ var DEFAULT_MAX_ROWS = 1; // operator-locked: row-by-row auth
67
68
  var DEFAULT_REASON_MIN_LEN = 12;
68
69
  var DEFAULT_LOCKED_BEHAVIOR = "throw"; // or "redact"
69
70
  var DEFAULT_AUDIT_REASON = "cleartext";
70
- var ALLOWED_FACTORS = ["totp"]; // passkey added in v0.5.2
71
+ var ALLOWED_FACTORS = ["totp", "passkey"];
71
72
  var ALLOWED_REASON_STORAGE = ["cleartext", "hmac", "both"];
72
73
 
73
74
  // In-memory policy cache. Cluster-shared via the policies table; the
@@ -135,19 +136,18 @@ function _kCell(dek, table, rowId, column) {
135
136
 
136
137
  async function _ensureDek(table) {
137
138
  if (dekCache.has(table)) return dekCache.get(table);
138
- // The DEK lives in the policy row's serviceAccountBypassJson slot
139
- // (repurposed in Model A; v0.5.0 explicitly rejected the slot, so
140
- // it's unused in Model A rows). For Model B rows, we vault-seal the
141
- // DEK and store it there. Pre-v1 schema: clean and simple.
139
+ // DEK is vault-sealed and stored in the policy row's `dekSealed`
140
+ // column. Generated lazily on first use of cryptographic-mode for
141
+ // the table. Cached in-memory after first read.
142
142
  var rows = await clusterStorage.executeAll(
143
- "SELECT serviceAccountBypassJson FROM _blamejs_break_glass_policies WHERE tableName = ?",
143
+ "SELECT dekSealed FROM _blamejs_break_glass_policies WHERE tableName = ?",
144
144
  [table]
145
145
  );
146
146
  if (!rows || rows.length === 0) {
147
147
  throw new BreakGlassError("breakglass/policy-not-set",
148
148
  "_ensureDek: no policy for table '" + table + "'", true);
149
149
  }
150
- var sealed = rows[0].serviceAccountBypassJson;
150
+ var sealed = rows[0].dekSealed;
151
151
  var dek;
152
152
  if (sealed) {
153
153
  dek = Buffer.from(vault().unseal(sealed), "base64");
@@ -155,7 +155,7 @@ async function _ensureDek(table) {
155
155
  dek = generateBytes(32);
156
156
  var sealedDek = vault().seal(dek.toString("base64"));
157
157
  await clusterStorage.execute(
158
- "UPDATE _blamejs_break_glass_policies SET serviceAccountBypassJson = ? WHERE tableName = ?",
158
+ "UPDATE _blamejs_break_glass_policies SET dekSealed = ? WHERE tableName = ?",
159
159
  [sealedDek, table]
160
160
  );
161
161
  }
@@ -399,9 +399,35 @@ function _validatePolicySet(table, opts) {
399
399
  throw new BreakGlassError("breakglass/bad-policy",
400
400
  "policy.set: auditReasonStorage must be one of " + ALLOWED_REASON_STORAGE.join("/"));
401
401
  }
402
+ // Service-account bypass: explicit opt-in per table. Operators
403
+ // declare the apiKey ids that may bypass + a required role; the
404
+ // framework requires BOTH to grant the bypass. Without this opt set,
405
+ // there is NO bypass path — every read of a glass-locked column
406
+ // requires a fresh grant.
407
+ var serviceAccountBypass = null;
402
408
  if (opts.serviceAccountBypass != null && opts.serviceAccountBypass !== false) {
403
- throw new BreakGlassError("breakglass/bad-policy",
404
- "policy.set: serviceAccountBypass ships in v0.5.2 leave unset for v0.5.0");
409
+ var sab = opts.serviceAccountBypass;
410
+ if (!sab || typeof sab !== "object") {
411
+ throw new BreakGlassError("breakglass/bad-policy",
412
+ "policy.set: serviceAccountBypass must be an object { enabled, apiKeyIds, requireRole }");
413
+ }
414
+ if (sab.enabled !== true) {
415
+ throw new BreakGlassError("breakglass/bad-policy",
416
+ "policy.set: serviceAccountBypass.enabled must be true (set serviceAccountBypass: false to disable)");
417
+ }
418
+ if (!Array.isArray(sab.apiKeyIds) || sab.apiKeyIds.length === 0) {
419
+ throw new BreakGlassError("breakglass/bad-policy",
420
+ "policy.set: serviceAccountBypass.apiKeyIds must be a non-empty array of apiKey ids");
421
+ }
422
+ if (typeof sab.requireRole !== "string" || sab.requireRole.length === 0) {
423
+ throw new BreakGlassError("breakglass/bad-policy",
424
+ "policy.set: serviceAccountBypass.requireRole must be a non-empty role / scope string");
425
+ }
426
+ serviceAccountBypass = {
427
+ enabled: true,
428
+ apiKeyIds: sab.apiKeyIds.slice(),
429
+ requireRole: sab.requireRole,
430
+ };
405
431
  }
406
432
  return {
407
433
  cryptographic: opts.cryptographic === true,
@@ -414,6 +440,7 @@ function _validatePolicySet(table, opts) {
414
440
  onLockedAccess: opts.onLockedAccess || DEFAULT_LOCKED_BEHAVIOR,
415
441
  requireScope: opts.requireScope != null ? opts.requireScope : null,
416
442
  auditReasonStorage: opts.auditReasonStorage || DEFAULT_AUDIT_REASON,
443
+ serviceAccountBypass: serviceAccountBypass,
417
444
  };
418
445
  }
419
446
 
@@ -433,7 +460,9 @@ async function policySet(table, opts, callerOpts) {
433
460
  sessionPin: validated.sessionPin ? 1 : 0,
434
461
  onLockedAccess: validated.onLockedAccess,
435
462
  requireScope: validated.requireScope,
436
- serviceAccountBypassJson: null,
463
+ serviceAccountBypassJson: validated.serviceAccountBypass
464
+ ? JSON.stringify(validated.serviceAccountBypass)
465
+ : null,
437
466
  auditReasonStorage: validated.auditReasonStorage,
438
467
  updatedAt: Date.now(),
439
468
  };
@@ -492,6 +521,9 @@ async function policyGet(table) {
492
521
  sessionPin: unsealed.sessionPin === 1,
493
522
  onLockedAccess: unsealed.onLockedAccess,
494
523
  requireScope: unsealed.requireScope,
524
+ serviceAccountBypass: unsealed.serviceAccountBypassJson
525
+ ? safeJson.parse(unsealed.serviceAccountBypassJson, { maxBytes: C.BYTES.kib(8) })
526
+ : null,
495
527
  auditReasonStorage: unsealed.auditReasonStorage,
496
528
  updatedAt: Number(unsealed.updatedAt),
497
529
  };
@@ -542,6 +574,34 @@ function _verifyTotpFactor(factor) {
542
574
  return { ok: verified !== false, step: verified };
543
575
  }
544
576
 
577
+ // Passkey factor — operator presents a WebAuthn assertion plus the
578
+ // challenge/origin/RPID + the previously-enrolled credential record.
579
+ // Phishing-resistant; the private key lives on the YubiKey, not in
580
+ // the framework's vault. v0.5.2 uses passkey for identity verification
581
+ // to gate grant issuance; PRF-derived per-policy DEK material (which
582
+ // would give true vault-key-alone-doesn't-decrypt defense) is a
583
+ // follow-up.
584
+ async function _verifyPasskeyFactor(factor) {
585
+ if (!factor || typeof factor !== "object") return { ok: false };
586
+ if (!factor.response || !factor.expectedChallenge ||
587
+ !factor.expectedOrigin || !factor.expectedRPID || !factor.credential) {
588
+ return { ok: false };
589
+ }
590
+ try {
591
+ var result = await passkey().verifyAuthentication({
592
+ response: factor.response,
593
+ expectedChallenge: factor.expectedChallenge,
594
+ expectedOrigin: factor.expectedOrigin,
595
+ expectedRPID: factor.expectedRPID,
596
+ credential: factor.credential,
597
+ requireUserVerification: factor.requireUserVerification !== false,
598
+ });
599
+ return { ok: result && result.verified === true };
600
+ } catch (_e) {
601
+ return { ok: false };
602
+ }
603
+ }
604
+
545
605
  async function grant(opts) {
546
606
  _requireInit();
547
607
  if (!opts || typeof opts !== "object") {
@@ -613,6 +673,8 @@ async function grant(opts) {
613
673
  var factorOk = false;
614
674
  if (factorType === "totp") {
615
675
  factorOk = _verifyTotpFactor(opts.factor).ok;
676
+ } else if (factorType === "passkey") {
677
+ factorOk = (await _verifyPasskeyFactor(opts.factor)).ok;
616
678
  }
617
679
 
618
680
  if (!factorOk) {
@@ -937,6 +999,220 @@ async function listActive(opts) {
937
999
  return out;
938
1000
  }
939
1001
 
1002
+ // ---- Service-account bypass ----
1003
+ //
1004
+ // Some legitimate workloads (nightly de-identification, scheduled
1005
+ // compliance reports) need PHI access without a human at the
1006
+ // keyboard. The framework refuses to silently bypass — operators
1007
+ // declare explicit service-account bypasses per-table with an apiKey
1008
+ // allowlist + required role. Both must match (verified apiKey id is
1009
+ // in the allowlist AND the apiKey carries the required role/scope)
1010
+ // before the bypass is granted. Each bypass emits its own distinct
1011
+ // audit row so post-incident review can distinguish operator-initiated
1012
+ // from service-initiated reads.
1013
+
1014
+ async function unsealRowAsService(req, table, rowId, opts) {
1015
+ _requireInit();
1016
+ opts = opts || {};
1017
+ if (!req || typeof req !== "object") {
1018
+ throw new BreakGlassError("breakglass/bad-grant-opts",
1019
+ "unsealRowAsService: req is required (with verified req.apiKey)");
1020
+ }
1021
+ if (typeof table !== "string" || table.length === 0) {
1022
+ throw new BreakGlassError("breakglass/bad-grant-opts",
1023
+ "unsealRowAsService: table must be a non-empty string");
1024
+ }
1025
+ var policy = await policyGet(table);
1026
+ if (!policy) {
1027
+ throw new BreakGlassError("breakglass/policy-not-set",
1028
+ "unsealRowAsService: no break-glass policy for table '" + table + "'", true);
1029
+ }
1030
+ if (!policy.serviceAccountBypass) {
1031
+ throw new BreakGlassError("breakglass/bypass-not-configured",
1032
+ "unsealRowAsService: serviceAccountBypass is not configured for '" + table + "'", true);
1033
+ }
1034
+ var apiKeyOnReq = req.apiKey;
1035
+ if (!apiKeyOnReq || typeof apiKeyOnReq.id !== "string") {
1036
+ throw new BreakGlassError("breakglass/bypass-no-apikey",
1037
+ "unsealRowAsService: req.apiKey.id is required (operator must run b.middleware.requireApiKey before this path)", true);
1038
+ }
1039
+ if (policy.serviceAccountBypass.apiKeyIds.indexOf(apiKeyOnReq.id) === -1) {
1040
+ audit.safeEmit({
1041
+ action: "breakglass.grant.bypass",
1042
+ outcome: "denied",
1043
+ actor: requestHelpers.extractActorContext(req),
1044
+ reason: "apikey-not-in-allowlist",
1045
+ metadata: { table: table, rowId: String(rowId), apiKeyId: apiKeyOnReq.id },
1046
+ });
1047
+ throw new BreakGlassError("breakglass/bypass-unauthorized",
1048
+ "unsealRowAsService: apiKey '" + apiKeyOnReq.id +
1049
+ "' is not in the bypass allowlist for '" + table + "'", true);
1050
+ }
1051
+ // Role check — actor must carry policy.serviceAccountBypass.requireRole
1052
+ // either as a direct scope or via b.permissions.check resolution.
1053
+ var actorScopes = Array.isArray(apiKeyOnReq.scopes) ? apiKeyOnReq.scopes :
1054
+ Array.isArray(apiKeyOnReq.roles) ? apiKeyOnReq.roles :
1055
+ [];
1056
+ var requiredRole = policy.serviceAccountBypass.requireRole;
1057
+ var hasRole = actorScopes.indexOf(requiredRole) !== -1;
1058
+ if (!hasRole) {
1059
+ audit.safeEmit({
1060
+ action: "breakglass.grant.bypass",
1061
+ outcome: "denied",
1062
+ actor: requestHelpers.extractActorContext(req),
1063
+ reason: "missing-role",
1064
+ metadata: { table: table, rowId: String(rowId), apiKeyId: apiKeyOnReq.id, requiredRole: requiredRole },
1065
+ });
1066
+ throw new BreakGlassError("breakglass/bypass-unauthorized",
1067
+ "unsealRowAsService: apiKey '" + apiKeyOnReq.id +
1068
+ "' lacks required role '" + requiredRole + "'", true);
1069
+ }
1070
+
1071
+ // Fetch + unseal the row (Model A or Model B path, same as
1072
+ // operator-initiated unsealRow).
1073
+ var rows = await clusterStorage.executeAll(
1074
+ "SELECT * FROM " + table + " WHERE _id = ?",
1075
+ [String(rowId)]
1076
+ );
1077
+ if (!rows || rows.length === 0) {
1078
+ throw new BreakGlassError("breakglass/row-not-found",
1079
+ "unsealRowAsService: " + table + "[" + rowId + "] not found", true);
1080
+ }
1081
+ var unsealedRow;
1082
+ if (policy.cryptographic) {
1083
+ var rawCipher = {};
1084
+ for (var c = 0; c < policy.columns.length; c++) rawCipher[policy.columns[c]] = rows[0][policy.columns[c]];
1085
+ var rowMinusLocked = Object.assign({}, rows[0]);
1086
+ for (var c2 = 0; c2 < policy.columns.length; c2++) delete rowMinusLocked[policy.columns[c2]];
1087
+ unsealedRow = cryptoField.unsealRow(table, rowMinusLocked);
1088
+ for (var c3 = 0; c3 < policy.columns.length; c3++) {
1089
+ var col = policy.columns[c3];
1090
+ if (rawCipher[col] == null) continue;
1091
+ unsealedRow[col] = await decryptCell(rawCipher[col],
1092
+ { table: table, rowId: String(rowId), column: col });
1093
+ }
1094
+ } else {
1095
+ unsealedRow = cryptoField.unsealRow(table, rows[0]);
1096
+ }
1097
+
1098
+ audit.safeEmit({
1099
+ action: "breakglass.grant.bypass",
1100
+ outcome: "success",
1101
+ actor: requestHelpers.extractActorContext(req),
1102
+ reason: typeof opts.reason === "string" ? opts.reason : null,
1103
+ metadata: {
1104
+ table: table,
1105
+ rowId: String(rowId),
1106
+ apiKeyId: apiKeyOnReq.id,
1107
+ requiredRole: requiredRole,
1108
+ columns: policy.columns.slice(),
1109
+ },
1110
+ });
1111
+ observability.event("breakglass.grant.bypass", { table: table });
1112
+ return unsealedRow;
1113
+ }
1114
+
1115
+ // ---- Admin tools ----
1116
+ //
1117
+ // `listActiveAll` returns every active grant (across all actors) —
1118
+ // for security-team dashboards and offboarding workflows.
1119
+ // `revokeAll` mass-revokes grants matching criteria — used by IR
1120
+ // teams when an account is suspected compromised. Both require
1121
+ // admin scope (operator wires via opts.requireScope or their own gate).
1122
+
1123
+ async function listActiveAll(opts) {
1124
+ _requireInit();
1125
+ opts = opts || {};
1126
+ var nowMs = Date.now();
1127
+ var clauses = ["(revokedAt IS NULL)", "expiresAt > ?", "rowsConsumed < maxRowsPerGrant"];
1128
+ var params = [nowMs];
1129
+ if (opts.table) {
1130
+ clauses.push("scopeTable = ?");
1131
+ params.push(opts.table);
1132
+ }
1133
+ if (opts.since) {
1134
+ clauses.push("issuedAt >= ?");
1135
+ params.push(opts.since);
1136
+ }
1137
+ var rows = await clusterStorage.executeAll(
1138
+ "SELECT * FROM _blamejs_break_glass_grants WHERE " + clauses.join(" AND ") +
1139
+ " ORDER BY issuedAt DESC",
1140
+ params
1141
+ );
1142
+ var out = [];
1143
+ for (var i = 0; i < (rows || []).length; i++) {
1144
+ var u = cryptoField.unsealRow("_blamejs_break_glass_grants", rows[i]);
1145
+ out.push({
1146
+ id: u._id,
1147
+ issuedToActorId: u.issuedToActorId,
1148
+ scopeTable: u.scopeTable,
1149
+ scopeColumns: safeJson.parse(u.scopeColumnsJson || "[]", { maxBytes: C.BYTES.kib(64) }),
1150
+ factorType: u.factorType,
1151
+ issuedAt: Number(u.issuedAt),
1152
+ expiresAt: Number(u.expiresAt),
1153
+ rowsRemaining: Number(u.maxRowsPerGrant) - Number(u.rowsConsumed),
1154
+ });
1155
+ }
1156
+ audit.safeEmit({
1157
+ action: "breakglass.admin.listactiveall",
1158
+ outcome: "success",
1159
+ actor: requestHelpers.resolveActorWithOverride(opts.callerOpts),
1160
+ metadata: { resultCount: out.length, filterTable: opts.table || null },
1161
+ });
1162
+ return out;
1163
+ }
1164
+
1165
+ async function revokeAll(criteria, opts) {
1166
+ _requireInit();
1167
+ if (!criteria || typeof criteria !== "object") {
1168
+ throw new BreakGlassError("breakglass/bad-revoke-criteria",
1169
+ "revokeAll: criteria is required ({ actorId?, table? })");
1170
+ }
1171
+ if (!criteria.actorId && !criteria.table) {
1172
+ throw new BreakGlassError("breakglass/bad-revoke-criteria",
1173
+ "revokeAll: at least one of { actorId, table } is required (refusing to mass-revoke without scope)");
1174
+ }
1175
+ opts = opts || {};
1176
+ var clauses = ["revokedAt IS NULL"];
1177
+ var params = [];
1178
+ if (criteria.actorId) {
1179
+ var derived = cryptoField.computeDerived(
1180
+ "_blamejs_break_glass_grants", "issuedToActorId", criteria.actorId
1181
+ );
1182
+ if (derived) {
1183
+ clauses.push("issuedToActorHash = ?");
1184
+ params.push(derived.value);
1185
+ }
1186
+ }
1187
+ if (criteria.table) {
1188
+ clauses.push("scopeTable = ?");
1189
+ params.push(criteria.table);
1190
+ }
1191
+ // Snapshot the to-be-revoked grant ids first so audit captures specifics.
1192
+ var ids = await clusterStorage.executeAll(
1193
+ "SELECT _id FROM _blamejs_break_glass_grants WHERE " + clauses.join(" AND "),
1194
+ params
1195
+ );
1196
+ var nowMs = Date.now();
1197
+ await clusterStorage.execute(
1198
+ "UPDATE _blamejs_break_glass_grants SET revokedAt = ? WHERE " + clauses.join(" AND "),
1199
+ [nowMs].concat(params)
1200
+ );
1201
+ audit.safeEmit({
1202
+ action: "breakglass.admin.revokeall",
1203
+ outcome: "success",
1204
+ actor: requestHelpers.resolveActorWithOverride(opts.callerOpts),
1205
+ reason: typeof criteria.reason === "string" ? criteria.reason : null,
1206
+ metadata: {
1207
+ filterActorId: criteria.actorId || null,
1208
+ filterTable: criteria.table || null,
1209
+ revokedCount: (ids || []).length,
1210
+ revokedIds: (ids || []).map(function (r) { return r._id; }),
1211
+ },
1212
+ });
1213
+ return { revokedCount: (ids || []).length };
1214
+ }
1215
+
940
1216
  // ---- Sweep (best-effort cleanup of expired grants) ----
941
1217
 
942
1218
  async function _sweepExpired(opts) {
@@ -984,6 +1260,14 @@ module.exports = {
984
1260
  encryptCell: encryptCell,
985
1261
  decryptCell: decryptCell,
986
1262
  migrate: migrate,
1263
+ // Service-account bypass — operator-declared per-table, gated by
1264
+ // (apiKey id in allowlist) AND (apiKey carries required role).
1265
+ unsealRowAsService: unsealRowAsService,
1266
+ // Admin tools — for security-team dashboards (listActiveAll) and
1267
+ // incident-response offboarding (revokeAll). Operators wire their
1268
+ // own gate (requireScope or middleware) on the calling routes.
1269
+ listActiveAll: listActiveAll,
1270
+ revokeAll: revokeAll,
987
1271
  BreakGlassError: BreakGlassError,
988
1272
 
989
1273
  // Test-only / sweep — operators with active grant volume wire this
package/lib/db.js CHANGED
@@ -446,6 +446,7 @@ var FRAMEWORK_SCHEMA = [
446
446
  onLockedAccess: "TEXT NOT NULL DEFAULT 'throw'",
447
447
  requireScope: "TEXT",
448
448
  serviceAccountBypassJson: "TEXT",
449
+ dekSealed: "TEXT",
449
450
  auditReasonStorage: "TEXT NOT NULL DEFAULT 'cleartext'",
450
451
  updatedAt: "INTEGER NOT NULL",
451
452
  },
@@ -584,6 +584,7 @@ function _breakGlassPoliciesDDL(dialect) {
584
584
  " onLockedAccess TEXT NOT NULL DEFAULT 'throw'," +
585
585
  " requireScope TEXT," +
586
586
  " serviceAccountBypassJson TEXT," +
587
+ " dekSealed TEXT," +
587
588
  " auditReasonStorage TEXT NOT NULL DEFAULT 'cleartext'," +
588
589
  " updatedAt " + t.INT + " NOT NULL" +
589
590
  ")",
@@ -50,20 +50,25 @@ var validateOpts = require("../validate-opts");
50
50
  var audit = lazyRequire(function () { return require("../audit"); });
51
51
 
52
52
  // Bot-guard's "trust the proxy header" semantics for actor.ip — the
53
- // audit event records the apparent source even when behind a CDN.
54
- // extractActorContext defaults to socket.remoteAddress; we override.
55
- function _xffIp(req) {
56
- var xff = req.headers && req.headers["x-forwarded-for"];
57
- if (xff) return String(xff).split(",")[0].trim();
58
- return (req.socket && req.socket.remoteAddress) || null;
53
+ // audit event records the apparent source even when behind a CDN, but
54
+ // only when the operator opts in to trustProxy. Without the opt, we
55
+ // stick to socket.remoteAddress so an attacker-forged XFF can't
56
+ // pollute audit attribution.
57
+ function _xffIpFor(trustProxy) {
58
+ return function (req) {
59
+ return requestHelpers.clientIp(req, { trustProxy: trustProxy });
60
+ };
59
61
  }
60
62
 
61
63
  function create(opts) {
62
64
  opts = opts || {};
63
65
  validateOpts(opts, [
64
66
  "mode", "onlyForHtml", "allowedAgents", "blockedAgents",
65
- "skipPaths", "statusOnBlock", "bodyOnBlock",
67
+ "skipPaths", "statusOnBlock", "bodyOnBlock", "trustProxy",
66
68
  ], "middleware.botGuard");
69
+ var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
70
+ ? opts.trustProxy : false;
71
+ var _xffIp = _xffIpFor(trustProxy);
67
72
  var mode = opts.mode || "block";
68
73
  var onlyForHtml = opts.onlyForHtml !== false;
69
74
  var allowedAgents = (opts.allowedAgents || []).map(function (r) { return r instanceof RegExp ? r : new RegExp(r); });
@@ -49,13 +49,13 @@ var safeUrl = require("../safe-url");
49
49
  var validateOpts = require("../validate-opts");
50
50
  var { defineClass } = require("../framework-error");
51
51
 
52
- // CORS audit events trust the proxy header for actor.ip same
53
- // semantics as bot-guard (the apparent origin is what matters when
54
- // blocking cross-origin attempts behind a CDN).
55
- function _xffIp(req) {
56
- var xff = req.headers && req.headers["x-forwarded-for"];
57
- if (xff) return String(xff).split(",")[0].trim();
58
- return (req.socket && req.socket.remoteAddress) || null;
52
+ // CORS audit events use the proxy-aware client IP only when the
53
+ // operator opts in via `trustProxy`. Default refuses forwarded
54
+ // headers same boundary as the rest of the v0.5.3 trustProxy sweep.
55
+ function _xffIpFor(trustProxy) {
56
+ return function (req) {
57
+ return requestHelpers.clientIp(req, { trustProxy: trustProxy });
58
+ };
59
59
  }
60
60
 
61
61
  var CorsError = defineClass("CorsError", { alwaysPermanent: true });
@@ -94,26 +94,17 @@ function _canonicalOrigin(input) {
94
94
  // supplied. Works for direct deployments (no proxy); operators behind
95
95
  // a TLS-terminating proxy that doesn't forward correct Host should set
96
96
  // opts.siteOrigin explicitly.
97
- function _inferRequestOrigin(req) {
97
+ function _inferRequestOrigin(req, trustProxy) {
98
98
  if (!req || !req.headers) return null;
99
99
  var host = req.headers.host;
100
100
  if (!host) return null;
101
- // X-Forwarded-Proto wins when present (operator behind a TLS-
102
- // terminator, intermediate set the header). Otherwise infer from
103
- // the socket req.socket.encrypted is set by node:tls.
104
- var fwdProto = req.headers["x-forwarded-proto"];
105
- var proto;
106
- if (typeof fwdProto === "string" && fwdProto.length > 0) {
107
- proto = fwdProto.split(",")[0].trim().toLowerCase();
108
- } else if (req.socket && req.socket.encrypted) {
109
- proto = "https";
110
- } else {
111
- proto = "http";
112
- }
101
+ // Protocol resolution honors the operator's trustProxy opt — without
102
+ // it, X-Forwarded-Proto is ignored as attacker-forgeable.
103
+ var proto = requestHelpers.requestProtocol(req, { trustProxy: trustProxy });
113
104
  return _canonicalOrigin(proto + "://" + host);
114
105
  }
115
106
 
116
- function _isSameOrigin(req, originHeader, configuredSiteOrigins) {
107
+ function _isSameOrigin(req, originHeader, configuredSiteOrigins, trustProxy) {
117
108
  // Origin: null + Sec-Fetch-Site: same-origin|none — browser opaqued
118
109
  // the Origin (typically because of Referrer-Policy: no-referrer on the
119
110
  // page) but is also explicitly telling us the request is same-origin.
@@ -133,8 +124,10 @@ function _isSameOrigin(req, originHeader, configuredSiteOrigins) {
133
124
  }
134
125
  return false;
135
126
  }
136
- // Fall back to inferring from the request itself.
137
- var reqOrigin = _inferRequestOrigin(req);
127
+ // Fall back to inferring from the request itself. trustProxy threads
128
+ // through so operators behind a TLS terminator with X-Forwarded-Proto
129
+ // can opt in to consult the header.
130
+ var reqOrigin = _inferRequestOrigin(req, trustProxy);
138
131
  return reqOrigin !== null && reqOrigin === canonOrigin;
139
132
  }
140
133
 
@@ -143,8 +136,11 @@ function create(opts) {
143
136
 
144
137
  validateOpts(opts, [
145
138
  "origins", "siteOrigin", "methods", "headers", "exposeHeaders",
146
- "credentials", "maxAgeSeconds", "refuseUnknown",
139
+ "credentials", "maxAgeSeconds", "refuseUnknown", "trustProxy",
147
140
  ], "middleware.cors");
141
+ var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
142
+ ? opts.trustProxy : false;
143
+ var _xffIp = _xffIpFor(trustProxy);
148
144
 
149
145
  var origins = opts.origins || [];
150
146
 
@@ -191,7 +187,7 @@ function create(opts) {
191
187
  // Same-origin POST/PUT/etc. carry an Origin header per the Fetch
192
188
  // spec but should not be subject to CORS allow-listing — they're
193
189
  // the operator's own site talking to itself.
194
- if (_isSameOrigin(req, origin, siteOrigins)) return next();
190
+ if (_isSameOrigin(req, origin, siteOrigins, trustProxy)) return next();
195
191
 
196
192
  var matched = _matchOrigin(origin, origins);
197
193
  if (!matched) {
@@ -217,13 +213,36 @@ function create(opts) {
217
213
 
218
214
  if (typeof res.setHeader === "function") {
219
215
  res.setHeader("Access-Control-Allow-Origin", matched);
220
- res.setHeader("Vary", "Origin");
216
+ // Append "Origin" to Vary instead of overwriting — compression /
217
+ // auth helpers may have set their own Vary tokens that the cache
218
+ // layer needs to keep.
219
+ requestHelpers.appendVary(res, "Origin");
221
220
  if (credentials) res.setHeader("Access-Control-Allow-Credentials", "true");
222
221
  res.setHeader("Access-Control-Expose-Headers", exposeHeaders);
223
222
  }
224
223
 
225
224
  if (req.method === "OPTIONS" && req.headers["access-control-request-method"]) {
226
- // Preflight
225
+ // Preflight. In refuseUnknown mode, validate the requested
226
+ // headers against the configured allow-list — refuse with 403
227
+ // if the client asks for a header we don't allow. Spec says
228
+ // browsers enforce, but server-side enforcement keeps the
229
+ // framework's strict-by-default posture consistent.
230
+ if (refuseUnknown) {
231
+ var requestedHdrs = req.headers["access-control-request-headers"];
232
+ if (requestedHdrs) {
233
+ var allowedSet = headers.toLowerCase().split(",").map(function (s) { return s.trim(); });
234
+ var asked = String(requestedHdrs).toLowerCase().split(",").map(function (s) { return s.trim(); }).filter(Boolean);
235
+ for (var ah = 0; ah < asked.length; ah++) {
236
+ if (allowedSet.indexOf(asked[ah]) === -1) {
237
+ if (typeof res.writeHead === "function") {
238
+ res.writeHead(403, { "Content-Type": "text/plain" });
239
+ res.end("CORS: requested header '" + asked[ah] + "' not in allow-list");
240
+ }
241
+ return;
242
+ }
243
+ }
244
+ }
245
+ }
227
246
  if (typeof res.setHeader === "function") {
228
247
  res.setHeader("Access-Control-Allow-Methods", methods);
229
248
  res.setHeader("Access-Control-Allow-Headers", headers);
@@ -98,13 +98,15 @@ function _parseCookieHeader(header) {
98
98
  return out;
99
99
  }
100
100
 
101
- function _isHttps(req) {
102
- if (req && req.socket && req.socket.encrypted) return true;
103
- var fwd = req && req.headers && req.headers["x-forwarded-proto"];
104
- if (typeof fwd === "string" && fwd.length > 0) {
105
- return fwd.split(",")[0].trim().toLowerCase() === "https";
106
- }
107
- return false;
101
+ // `_isHttps` defers to `requestHelpers.requestProtocol` so the
102
+ // per-middleware `trustProxy` opt gates whether X-Forwarded-Proto is
103
+ // consulted. Without trustProxy, an attacker could otherwise forge
104
+ // the header to force the Secure cookie attribute (and inversely,
105
+ // suppress it) on direct-to-server connections.
106
+ function _isHttpsFor(trustProxy) {
107
+ return function (req) {
108
+ return requestHelpers.requestProtocol(req, { trustProxy: trustProxy }) === "https";
109
+ };
108
110
  }
109
111
 
110
112
  function _formatSetCookie(name, value, opts) {
@@ -159,7 +161,11 @@ function create(opts) {
159
161
 
160
162
  validateOpts(opts, [
161
163
  "cookie", "tokenLookup", "fieldName", "headerName", "methods", "audit",
164
+ "trustProxy",
162
165
  ], "middleware.csrfProtect");
166
+ var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
167
+ ? opts.trustProxy : false;
168
+ var _isHttps = _isHttpsFor(trustProxy);
163
169
 
164
170
  // Tier A — exactly one issuance source.
165
171
  var hasCookie = opts.cookie != null && opts.cookie !== false;
@@ -52,10 +52,15 @@ var clusterStorage = require("../cluster-storage");
52
52
  var audit = lazyRequire(function () { return require("../audit"); });
53
53
  var logger = lazyRequire(function () { return require("../log").boot("rate-limit"); });
54
54
 
55
- function _clientIp(req) {
56
- var fwd = req.headers && req.headers["x-forwarded-for"];
57
- if (fwd) return String(fwd).split(",")[0].trim();
58
- return (req.socket && req.socket.remoteAddress) || "unknown";
55
+ // `_clientIp` defers to `requestHelpers.clientIp`, threading the
56
+ // per-middleware `trustProxy` opt. Default refuses forwarded headers
57
+ // (returning the socket address only) operators behind a sanitizing
58
+ // reverse proxy opt in via `trustProxy: true` (or a hop count).
59
+ function _clientIpFor(trustProxy) {
60
+ return function (req) {
61
+ var ip = requestHelpers.clientIp(req, { trustProxy: trustProxy });
62
+ return ip || "unknown";
63
+ };
59
64
  }
60
65
 
61
66
  // ---- Memory backend (token bucket) ----
@@ -221,12 +226,15 @@ function create(opts) {
221
226
  opts = opts || {};
222
227
  validateOpts(opts, [
223
228
  "keyFn", "statusOnLimit", "bodyOnLimit", "header", "skipPaths", "scope",
224
- "backend",
229
+ "backend", "trustProxy",
225
230
  // memory backend
226
231
  "burst", "refillPerSecond",
227
232
  // cluster backend
228
233
  "limit", "windowMs", "pruneIntervalMs",
229
234
  ], "middleware.rateLimit");
235
+ var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
236
+ ? opts.trustProxy : false;
237
+ var _clientIp = _clientIpFor(trustProxy);
230
238
  var keyFn = opts.keyFn || _clientIp;
231
239
  var statusOnLimit = opts.statusOnLimit || 429;
232
240
  var bodyOnLimit = opts.bodyOnLimit !== undefined ? opts.bodyOnLimit : "Too Many Requests";
@@ -35,6 +35,7 @@
35
35
  * }
36
36
  */
37
37
 
38
+ var requestHelpers = require("../request-helpers");
38
39
  var validateOpts = require("../validate-opts");
39
40
 
40
41
  var DEFAULT_PERMISSIONS = [
@@ -62,8 +63,10 @@ function create(opts) {
62
63
  validateOpts(opts, [
63
64
  "hsts", "contentTypeOptions", "frameOptions", "referrerPolicy",
64
65
  "permissionsPolicy", "coop", "coep", "corp",
65
- "originAgentCluster", "dnsPrefetchControl", "csp",
66
+ "originAgentCluster", "dnsPrefetchControl", "csp", "trustProxy",
66
67
  ], "middleware.securityHeaders");
68
+ var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
69
+ ? opts.trustProxy : false;
67
70
  var hsts = opts.hsts === undefined ? "max-age=63072000; includeSubDomains; preload" : opts.hsts;
68
71
  var ctOpts = opts.contentTypeOptions === undefined ? "nosniff" : opts.contentTypeOptions;
69
72
  var frameOpts = opts.frameOptions === undefined ? "DENY" : opts.frameOptions;
@@ -78,7 +81,14 @@ function create(opts) {
78
81
 
79
82
  return function securityHeaders(req, res, next) {
80
83
  if (typeof res.setHeader !== "function") return next();
81
- if (hsts) res.setHeader("Strict-Transport-Security", hsts);
84
+ // RFC 6797 §7.2: HSTS over plain HTTP is meaningless (UAs ignore
85
+ // it). Skip the header on non-TLS requests so dev-over-HTTP doesn't
86
+ // surface confusing "Strict-Transport-Security on http://" lines.
87
+ // requestProtocol respects trustProxy — operators behind a TLS
88
+ // terminator opt in to read X-Forwarded-Proto.
89
+ if (hsts && requestHelpers.requestProtocol(req, { trustProxy: trustProxy }) === "https") {
90
+ res.setHeader("Strict-Transport-Security", hsts);
91
+ }
82
92
  if (ctOpts) res.setHeader("X-Content-Type-Options", ctOpts);
83
93
  if (frameOpts) res.setHeader("X-Frame-Options", frameOpts);
84
94
  if (refPolicy) res.setHeader("Referrer-Policy", refPolicy);
@@ -56,7 +56,13 @@ function _keyToUrl(baseUrl, key) {
56
56
  }
57
57
  var b = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
58
58
  var k = key.startsWith("/") ? key.slice(1) : key;
59
- return b + "/" + k;
59
+ // URL-encode each path segment so reserved characters (?, #, %, space,
60
+ // unicode, etc.) round-trip safely and don't cross-pollute keys
61
+ // (e.g. `a%2Fb` and `a/b` would otherwise collide on the wire).
62
+ // Slashes between segments stay literal (operators use them as a
63
+ // namespace separator, matching S3 / GCS / Azure conventions).
64
+ var encoded = k.split("/").map(encodeURIComponent).join("/");
65
+ return b + "/" + encoded;
60
66
  }
61
67
 
62
68
  function create(config) {
@@ -112,6 +112,71 @@ function resolveActorWithOverride(callerOpts, baseOverride) {
112
112
  return extractActorContext(callerOpts && callerOpts.req, override);
113
113
  }
114
114
 
115
+ // ---- Proxy-trust primitives (v0.5.3) ----
116
+ //
117
+ // `X-Forwarded-For` and `X-Forwarded-Proto` are operator-trust headers —
118
+ // behind a sanitizing reverse proxy they carry the apparent origin /
119
+ // scheme; without one they're attacker-forgeable. Default is to NOT
120
+ // trust them; operators behind a proxy set `trustProxy: true` (or a
121
+ // hop count for multi-hop chains) per-middleware to opt in.
122
+ //
123
+ // clientIp(req, { trustProxy }) → string | null
124
+ //
125
+ // trustProxy false (default): socket.remoteAddress only
126
+ // trustProxy true: leftmost x-forwarded-for hop, else socket
127
+ // trustProxy <integer N>: Nth-from-rightmost xff hop (skip-N-trusted-hops)
128
+ //
129
+ // Middleware accepts `trustProxy` as an opt and threads it through;
130
+ // the framework refuses to silently pick up forwarded headers without
131
+ // the operator's explicit acknowledgement.
132
+
133
+ function clientIp(req, opts) {
134
+ if (!req) return null;
135
+ var trust = opts && opts.trustProxy;
136
+ if (trust && req.headers) {
137
+ var xff = req.headers["x-forwarded-for"];
138
+ if (xff) {
139
+ var hops = String(xff).split(",").map(function (s) { return s.trim(); });
140
+ if (trust === true) return hops[0];
141
+ if (typeof trust === "number" && trust >= 1 && hops.length >= trust) {
142
+ return hops[hops.length - trust];
143
+ }
144
+ }
145
+ }
146
+ if (req.socket && typeof req.socket.remoteAddress === "string") return req.socket.remoteAddress;
147
+ if (req.connection && typeof req.connection.remoteAddress === "string") return req.connection.remoteAddress;
148
+ return null;
149
+ }
150
+
151
+ function requestProtocol(req, opts) {
152
+ if (!req) return "http";
153
+ var trust = opts && opts.trustProxy;
154
+ if (trust && req.headers) {
155
+ var fwd = req.headers["x-forwarded-proto"];
156
+ if (typeof fwd === "string" && fwd.length > 0) {
157
+ return String(fwd).split(",")[0].trim().toLowerCase();
158
+ }
159
+ }
160
+ if (req.socket && req.socket.encrypted) return "https";
161
+ if (req.connection && req.connection.encrypted) return "https";
162
+ return "http";
163
+ }
164
+
165
+ // Append a token to a `Vary` response header without dropping prior
166
+ // values (compression middleware sets `Vary: Accept-Encoding`, an
167
+ // auth helper might set `Vary: Authorization`, etc.). Idempotent —
168
+ // re-adding an existing token is a no-op.
169
+ function appendVary(res, value) {
170
+ if (!res || typeof res.getHeader !== "function" || typeof res.setHeader !== "function") return;
171
+ var existing = res.getHeader("Vary");
172
+ if (existing == null || existing === "") { res.setHeader("Vary", value); return; }
173
+ var tokens = String(existing).split(",").map(function (s) { return s.trim(); }).filter(Boolean);
174
+ var lower = value.toLowerCase();
175
+ for (var i = 0; i < tokens.length; i++) if (tokens[i].toLowerCase() === lower) return;
176
+ tokens.push(value);
177
+ res.setHeader("Vary", tokens.join(", "));
178
+ }
179
+
115
180
  function resolveRoute(req) {
116
181
  if (req && typeof req.routePattern === "string" && req.routePattern.length > 0) {
117
182
  return req.routePattern;
@@ -201,4 +266,8 @@ module.exports = {
201
266
  extractActorContext: extractActorContext,
202
267
  resolveActorWithOverride: resolveActorWithOverride,
203
268
  parseQualityList: parseQualityList,
269
+ // v0.5.3 — proxy-trust primitives (default refuses forwarded headers)
270
+ clientIp: clientIp,
271
+ requestProtocol: requestProtocol,
272
+ appendVary: appendVary,
204
273
  };
package/lib/session.js CHANGED
@@ -128,7 +128,23 @@ async function verify(token) {
128
128
  var unsealed = cryptoField.unsealRow("_blamejs_sessions", row);
129
129
  var data = null;
130
130
  if (unsealed.data) {
131
- try { data = safeJson.parse(unsealed.data); } catch (_e) { data = null; }
131
+ try { data = safeJson.parse(unsealed.data); }
132
+ catch (e) {
133
+ // Decrypt-then-parse failure is rare but operationally important —
134
+ // it usually signals key-rotation skew, DB corruption, or
135
+ // tampering. Emit an audit event so ops can spot it before the
136
+ // operator notices empty-`data` flows. data stays null so the
137
+ // session remains usable for non-data flows.
138
+ data = null;
139
+ try {
140
+ audit.safeEmit({
141
+ action: "auth.session.data_unparseable",
142
+ outcome: "failure",
143
+ reason: (e && e.message) || String(e),
144
+ metadata: { hasUserId: !!unsealed.userId },
145
+ });
146
+ } catch (_ignored) { /* audit best-effort */ }
147
+ }
132
148
  }
133
149
  return {
134
150
  userId: unsealed.userId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",