@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 +2 -0
- package/lib/break-glass.js +295 -11
- package/lib/db.js +1 -0
- package/lib/framework-schema.js +1 -0
- package/lib/middleware/bot-guard.js +12 -7
- package/lib/middleware/cors.js +46 -27
- package/lib/middleware/csrf-protect.js +13 -7
- package/lib/middleware/rate-limit.js +13 -5
- package/lib/middleware/security-headers.js +12 -2
- package/lib/object-store/http-put.js +7 -1
- package/lib/request-helpers.js +69 -0
- package/lib/session.js +17 -1
- package/package.json +1 -1
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
|
package/lib/break-glass.js
CHANGED
|
@@ -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"];
|
|
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
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
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
|
|
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].
|
|
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
|
|
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
|
-
|
|
404
|
-
|
|
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:
|
|
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
|
},
|
package/lib/framework-schema.js
CHANGED
|
@@ -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
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return (req
|
|
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); });
|
package/lib/middleware/cors.js
CHANGED
|
@@ -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
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
function
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/lib/request-helpers.js
CHANGED
|
@@ -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); }
|
|
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,
|