@blamejs/core 0.8.0 → 0.8.4
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 +24 -0
- package/lib/audit-sign.js +1 -1
- package/lib/audit.js +62 -2
- package/lib/auth/jwt.js +13 -0
- package/lib/auth/lockout.js +16 -3
- package/lib/auth/oauth.js +15 -1
- package/lib/auth/password.js +22 -2
- package/lib/auth/sd-jwt-vc-issuer.js +2 -2
- package/lib/auth/sd-jwt-vc.js +7 -2
- package/lib/break-glass.js +53 -14
- package/lib/cache-redis.js +1 -1
- package/lib/cache.js +6 -1
- package/lib/cli.js +3 -3
- package/lib/cluster.js +24 -1
- package/lib/compliance-ai-act-logging.js +7 -3
- package/lib/compliance.js +10 -2
- package/lib/config-drift.js +2 -2
- package/lib/crypto-field.js +21 -1
- package/lib/crypto.js +82 -1
- package/lib/db.js +35 -4
- package/lib/dev.js +30 -3
- package/lib/dual-control.js +19 -1
- package/lib/external-db.js +10 -0
- package/lib/file-upload.js +30 -3
- package/lib/flag.js +1 -1
- package/lib/guard-all.js +33 -16
- package/lib/guard-csv.js +16 -2
- package/lib/guard-html.js +35 -0
- package/lib/guard-svg.js +20 -0
- package/lib/http-client.js +57 -11
- package/lib/inbox.js +34 -10
- package/lib/log-stream-syslog.js +8 -0
- package/lib/log-stream.js +1 -1
- package/lib/mail.js +40 -0
- package/lib/middleware/attach-user.js +25 -2
- package/lib/middleware/bearer-auth.js +71 -6
- package/lib/middleware/body-parser.js +13 -0
- package/lib/middleware/cors.js +10 -0
- package/lib/middleware/csrf-protect.js +34 -3
- package/lib/middleware/dpop.js +3 -3
- package/lib/middleware/host-allowlist.js +1 -1
- package/lib/middleware/require-aal.js +2 -2
- package/lib/middleware/trace-propagate.js +1 -1
- package/lib/mtls-ca.js +23 -29
- package/lib/mtls-engine-default.js +21 -1
- package/lib/network-tls.js +21 -6
- package/lib/object-store/sigv4-bucket-ops.js +41 -0
- package/lib/observability-otlp-exporter.js +35 -2
- package/lib/outbox.js +3 -3
- package/lib/permissions.js +10 -1
- package/lib/pqc-agent.js +22 -1
- package/lib/pubsub.js +8 -4
- package/lib/redact.js +26 -1
- package/lib/retention.js +26 -0
- package/lib/router.js +1 -0
- package/lib/scheduler.js +57 -1
- package/lib/session.js +3 -3
- package/lib/ssrf-guard.js +19 -4
- package/lib/static.js +12 -0
- package/lib/totp.js +16 -0
- package/lib/ws-client.js +158 -9
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/lib/crypto.js
CHANGED
|
@@ -164,7 +164,26 @@ function verify(data, signature, publicKeyPem) {
|
|
|
164
164
|
function encrypt(plaintext, publicKeys) {
|
|
165
165
|
var mlkemPubPem = typeof publicKeys === "string" ? publicKeys : publicKeys.publicKey;
|
|
166
166
|
var ecPubPem = typeof publicKeys === "string" ? null : publicKeys.ecPublicKey;
|
|
167
|
-
if (!ecPubPem)
|
|
167
|
+
if (!ecPubPem) {
|
|
168
|
+
// Operator passed only an ML-KEM public key — silently dropping
|
|
169
|
+
// the P-384 hybrid leg means the operator's defense-in-depth
|
|
170
|
+
// posture (classical ECDH backstop on top of PQC KEM) is gone
|
|
171
|
+
// without any signal. Emit on next tick (crypto must not import
|
|
172
|
+
// audit synchronously — audit imports crypto for chain hashing).
|
|
173
|
+
// Operators who genuinely want KEM-only should call
|
|
174
|
+
// encryptMlkemOnly explicitly so this audit doesn't fire.
|
|
175
|
+
setImmediate(function () {
|
|
176
|
+
try {
|
|
177
|
+
var auditMod = require("./audit"); // allow:inline-require — circular-load defense (audit imports crypto)
|
|
178
|
+
auditMod.safeEmit({
|
|
179
|
+
action: "system.crypto.hybrid_disabled",
|
|
180
|
+
outcome: "success",
|
|
181
|
+
metadata: { reason: "no-ec-public-key", note: "encrypt() received only mlkem; ecPublicKey absent — call encryptMlkemOnly explicitly to silence" },
|
|
182
|
+
});
|
|
183
|
+
} catch (_e) { /* drop-silent — best-effort */ }
|
|
184
|
+
});
|
|
185
|
+
return encryptMlkemOnly(plaintext, mlkemPubPem);
|
|
186
|
+
}
|
|
168
187
|
|
|
169
188
|
var mlkemPub = nodeCrypto.createPublicKey(mlkemPubPem);
|
|
170
189
|
var kem = nodeCrypto.encapsulate(mlkemPub);
|
|
@@ -518,6 +537,65 @@ function decryptEnvelopeAsCertPeer(envelope, opts) {
|
|
|
518
537
|
// Operator-audit accessor — exposes every supported KEM hybrid for
|
|
519
538
|
// compliance audit visibility ("which envelopes does this deploy
|
|
520
539
|
// accept on decrypt?").
|
|
540
|
+
// ---- Certificate fingerprint helpers ----
|
|
541
|
+
//
|
|
542
|
+
// Operators pinning peer-cert fingerprints (mtls bootstrap, webhook
|
|
543
|
+
// verification, certificate transparency cross-checks) want a stable
|
|
544
|
+
// SHA3-512 hash of the DER bytes plus a colon-separated hex form that
|
|
545
|
+
// matches what most operator tooling renders for X.509 fingerprints.
|
|
546
|
+
// hashCertFingerprint accepts either a Buffer (DER) or a PEM string;
|
|
547
|
+
// if PEM, the BEGIN/END envelope is stripped and the base64 body is
|
|
548
|
+
// decoded before hashing. The hash is the framework's standard SHA3-
|
|
549
|
+
// 512 (not SHA-256 — operators using OpenSSL's `-sha256` defaults can
|
|
550
|
+
// keep their own SHA-256 hashes, this primitive is the framework-
|
|
551
|
+
// canonical form). Returns { hex, colon } so callers can compare
|
|
552
|
+
// against either rendering.
|
|
553
|
+
function _pemToDer(pemOrDer) {
|
|
554
|
+
if (Buffer.isBuffer(pemOrDer)) return pemOrDer;
|
|
555
|
+
if (typeof pemOrDer !== "string") {
|
|
556
|
+
throw new TypeError("crypto.hashCertFingerprint: input must be a Buffer (DER) or a PEM-encoded string");
|
|
557
|
+
}
|
|
558
|
+
var match = pemOrDer.match(/-----BEGIN [A-Z0-9 ]+-----([\s\S]+?)-----END [A-Z0-9 ]+-----/);
|
|
559
|
+
if (!match) {
|
|
560
|
+
throw new TypeError("crypto.hashCertFingerprint: PEM input lacks BEGIN/END markers");
|
|
561
|
+
}
|
|
562
|
+
return Buffer.from(match[1].replace(/\s+/g, ""), "base64");
|
|
563
|
+
}
|
|
564
|
+
function hashCertFingerprint(pemOrDer) {
|
|
565
|
+
var der = _pemToDer(pemOrDer);
|
|
566
|
+
var digest = hash(der, "sha3-512");
|
|
567
|
+
var hex = digest.toString("hex");
|
|
568
|
+
// Colon-separated, uppercase — matches openssl x509 -fingerprint
|
|
569
|
+
// output style (which is SHA-1 by default, but the rendering shape
|
|
570
|
+
// operators expect is the same).
|
|
571
|
+
var colon = hex.toUpperCase().match(/.{2}/g).join(":");
|
|
572
|
+
return { hex: hex, colon: colon };
|
|
573
|
+
}
|
|
574
|
+
// Compares a peer's PEM/DER cert against an allowlist of pinned
|
|
575
|
+
// fingerprints. Allowlist entries may be the colon form, the lower-
|
|
576
|
+
// case hex form, or both — every comparison runs through
|
|
577
|
+
// timingSafeEqual to avoid leaking which entry matched.
|
|
578
|
+
function isCertRevoked(pemOrDer, denyList) {
|
|
579
|
+
if (!Array.isArray(denyList)) {
|
|
580
|
+
throw new TypeError("crypto.isCertRevoked: denyList must be an array of fingerprint strings");
|
|
581
|
+
}
|
|
582
|
+
var fp = hashCertFingerprint(pemOrDer);
|
|
583
|
+
var fpHex = Buffer.from(fp.hex, "hex");
|
|
584
|
+
var fpColon = Buffer.from(fp.colon);
|
|
585
|
+
for (var i = 0; i < denyList.length; i++) {
|
|
586
|
+
var entry = denyList[i];
|
|
587
|
+
if (typeof entry !== "string" || entry.length === 0) continue;
|
|
588
|
+
var normalized = entry.indexOf(":") !== -1 ? entry.toUpperCase() : entry.toLowerCase();
|
|
589
|
+
var normalizedBuf = entry.indexOf(":") !== -1 ? Buffer.from(normalized) : Buffer.from(normalized, "hex");
|
|
590
|
+
var compareBuf = entry.indexOf(":") !== -1 ? fpColon : fpHex;
|
|
591
|
+
if (normalizedBuf.length === compareBuf.length &&
|
|
592
|
+
nodeCrypto.timingSafeEqual(normalizedBuf, compareBuf)) {
|
|
593
|
+
return true;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return false;
|
|
597
|
+
}
|
|
598
|
+
|
|
521
599
|
var SUPPORTED_KEM_ALGORITHMS = Object.freeze([
|
|
522
600
|
{ id: "ml-kem-1024", envelopeId: C.KEM_IDS.ML_KEM_1024, description: "ML-KEM-1024 KEM-only (legacy single-component)" },
|
|
523
601
|
{ id: "ml-kem-1024-p384", envelopeId: C.KEM_IDS.ML_KEM_1024_P384, description: "ML-KEM-1024 + ECDH P-384 hybrid (framework default)" },
|
|
@@ -532,6 +610,9 @@ module.exports = {
|
|
|
532
610
|
kdf: kdf,
|
|
533
611
|
// Comparison
|
|
534
612
|
timingSafeEqual: timingSafeEqual,
|
|
613
|
+
// Cert fingerprint helpers
|
|
614
|
+
hashCertFingerprint: hashCertFingerprint,
|
|
615
|
+
isCertRevoked: isCertRevoked,
|
|
535
616
|
// Random
|
|
536
617
|
generateBytes: generateBytes,
|
|
537
618
|
generateToken: generateToken,
|
package/lib/db.js
CHANGED
|
@@ -585,7 +585,21 @@ function decryptToTmp() {
|
|
|
585
585
|
}
|
|
586
586
|
var packed = fs.readFileSync(encPath);
|
|
587
587
|
if (packed.length < 26) return; // too short to be a valid envelope
|
|
588
|
-
|
|
588
|
+
// AAD binds the envelope to this deployment's data dir so two
|
|
589
|
+
// installs sharing the same operator passphrase can't swap each
|
|
590
|
+
// other's db.enc files. Backwards-compat: if the AAD-bound decrypt
|
|
591
|
+
// fails, retry without AAD for envelopes written by pre-AAD
|
|
592
|
+
// versions (one-release transition window).
|
|
593
|
+
var aad = _dbEncAad(dataDir);
|
|
594
|
+
try {
|
|
595
|
+
atomicFile.writeSync(dbPath, decryptPacked(packed, encKey, aad));
|
|
596
|
+
} catch (_e) {
|
|
597
|
+
atomicFile.writeSync(dbPath, decryptPacked(packed, encKey));
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function _dbEncAad(dir) {
|
|
602
|
+
return Buffer.from("blamejs.db-enc.v1\0" + (dir || ""), "utf8");
|
|
589
603
|
}
|
|
590
604
|
|
|
591
605
|
function encryptToDisk() {
|
|
@@ -593,7 +607,7 @@ function encryptToDisk() {
|
|
|
593
607
|
// Force WAL checkpoint so the .db file holds all committed transactions.
|
|
594
608
|
try { runSql(database, "PRAGMA wal_checkpoint(TRUNCATE)"); } catch (_e) { /* best effort */ }
|
|
595
609
|
if (!fs.existsSync(dbPath)) return;
|
|
596
|
-
atomicFile.writeSync(encPath, encryptPacked(fs.readFileSync(dbPath), encKey));
|
|
610
|
+
atomicFile.writeSync(encPath, encryptPacked(fs.readFileSync(dbPath), encKey, _dbEncAad(dataDir)));
|
|
597
611
|
}
|
|
598
612
|
|
|
599
613
|
// Remove the plaintext DB + WAL/SHM sidecar files. On Windows these can't be
|
|
@@ -691,6 +705,23 @@ async function init(opts) {
|
|
|
691
705
|
// dominates with audit-chain emissions and cascade fan-out.
|
|
692
706
|
runSql(database, "PRAGMA secure_delete=ON");
|
|
693
707
|
|
|
708
|
+
// Boot-time integrity check — refuse to boot on B-tree corruption.
|
|
709
|
+
// SQLite normally surfaces corruption only when a query stumbles on
|
|
710
|
+
// a bad page; that's a "first failure during request handling"
|
|
711
|
+
// surface, not a clean fail-closed boot. integrity_check is fast on
|
|
712
|
+
// the freshly-decrypted-into-tmpfs file (<1 second on a typical
|
|
713
|
+
// multi-MB DB) and the result is "ok" or a list of issues.
|
|
714
|
+
if (opts.skipBootIntegrityCheck !== true) {
|
|
715
|
+
var ic = database.prepare("PRAGMA integrity_check").all();
|
|
716
|
+
var icIssues = ic.map(function (r) { return r && r.integrity_check; })
|
|
717
|
+
.filter(function (s) { return s && s !== "ok"; });
|
|
718
|
+
if (icIssues.length > 0) {
|
|
719
|
+
throw new DbError("db/integrity-check-failed",
|
|
720
|
+
"PRAGMA integrity_check at boot reported " + icIssues.length +
|
|
721
|
+
" issue(s): " + icIssues.slice(0, 3).join("; "));
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
694
725
|
// PRAGMA integrity_check — refuse boot on B-tree corruption (per
|
|
695
726
|
// audit-batch finding). SQLite returns "ok" for a healthy database;
|
|
696
727
|
// any other result means corruption. Catching it at boot beats
|
|
@@ -1264,7 +1295,7 @@ module.exports = {
|
|
|
1264
1295
|
catch (_e) { /* drop-silent */ }
|
|
1265
1296
|
if (auditOn) {
|
|
1266
1297
|
try { audit.safeEmit({
|
|
1267
|
-
action: "system.db.integrity_ok", outcome: "
|
|
1298
|
+
action: "system.db.integrity_ok", outcome: "success", metadata: {},
|
|
1268
1299
|
}); } catch (_e) { /* drop-silent */ }
|
|
1269
1300
|
}
|
|
1270
1301
|
return;
|
|
@@ -1274,7 +1305,7 @@ module.exports = {
|
|
|
1274
1305
|
catch (_e) { /* drop-silent */ }
|
|
1275
1306
|
if (auditOn) {
|
|
1276
1307
|
try { audit.safeEmit({
|
|
1277
|
-
action: "system.db.integrity_corrupt", outcome: "
|
|
1308
|
+
action: "system.db.integrity_corrupt", outcome: "failure",
|
|
1278
1309
|
metadata: { issueCount: issues.length },
|
|
1279
1310
|
}); } catch (_e) { /* drop-silent */ }
|
|
1280
1311
|
}
|
package/lib/dev.js
CHANGED
|
@@ -48,13 +48,24 @@
|
|
|
48
48
|
*/
|
|
49
49
|
|
|
50
50
|
var path = require("path");
|
|
51
|
-
var childProcess = require("child_process");
|
|
52
51
|
var fs = require("fs");
|
|
52
|
+
var lazyRequire = require("./lazy-require");
|
|
53
53
|
var logModule = require("./log");
|
|
54
54
|
var nb = require("./numeric-bounds");
|
|
55
|
+
var safeEnv = require("./parsers/safe-env");
|
|
55
56
|
var validateOpts = require("./validate-opts");
|
|
56
57
|
var { FrameworkError } = require("./framework-error");
|
|
57
58
|
|
|
59
|
+
// child_process is required ONLY when dev.create() is actually
|
|
60
|
+
// called — not at module load. The dev primitive spawns subprocesses
|
|
61
|
+
// (nodemon-style restart loop) by design; isolating the dependency
|
|
62
|
+
// behind lazy-load means a production process that never calls
|
|
63
|
+
// b.dev.create() never loads child_process, and supply-chain scanners
|
|
64
|
+
// inspecting a deployed bundle don't see it as a top-level dep of an
|
|
65
|
+
// otherwise hermetic framework. Production deployments additionally
|
|
66
|
+
// refuse to construct dev.create() — see _refuseInProduction below.
|
|
67
|
+
var childProcess = lazyRequire(function () { return require("child_process"); });
|
|
68
|
+
|
|
58
69
|
class DevError extends FrameworkError {
|
|
59
70
|
constructor(code, message) {
|
|
60
71
|
super(message, code);
|
|
@@ -123,9 +134,25 @@ function create(opts) {
|
|
|
123
134
|
var env = opts.env || process.env;
|
|
124
135
|
var cwd = opts.cwd || process.cwd();
|
|
125
136
|
|
|
126
|
-
//
|
|
137
|
+
// dev.create() is intended for development-mode use only —
|
|
138
|
+
// restarting subprocesses on file change is a feature operators
|
|
139
|
+
// run on their laptop, never in production. Refusing here means a
|
|
140
|
+
// mis-configured production deployment that accidentally wires the
|
|
141
|
+
// dev primitive crashes loudly at boot rather than spawning shells
|
|
142
|
+
// on every save. Operators with a legitimate cross-cutting need
|
|
143
|
+
// (e.g. a CI runner that uses dev() to drive end-to-end tests)
|
|
144
|
+
// explicitly opt in via opts.allowProduction with an audited reason.
|
|
145
|
+
if (safeEnv.readVar("NODE_ENV") === "production" && !opts.allowProduction) {
|
|
146
|
+
throw new DevError("dev/refused-in-production",
|
|
147
|
+
"b.dev.create: dev mode refuses to load when NODE_ENV=production. " +
|
|
148
|
+
"Set opts.allowProduction:true with an audited reason if a non-dev " +
|
|
149
|
+
"context legitimately needs subprocess spawn-on-watch behaviour.");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Test seams. childProcess is lazily-required; calling spawn here
|
|
153
|
+
// pulls in node's child_process module on first dev.create() use.
|
|
127
154
|
var spawnFn = opts._spawn || function (cmd, sargs, sopts) {
|
|
128
|
-
return childProcess.spawn(cmd, sargs, sopts);
|
|
155
|
+
return childProcess().spawn(cmd, sargs, sopts);
|
|
129
156
|
};
|
|
130
157
|
var watchFn = opts._watch || function (dir, wopts, listener) {
|
|
131
158
|
return fs.watch(dir, wopts, listener);
|
package/lib/dual-control.js
CHANGED
|
@@ -186,7 +186,25 @@ function create(opts) {
|
|
|
186
186
|
if (!approverRoles) return true;
|
|
187
187
|
if (!actor || !Array.isArray(actor.roles)) return false;
|
|
188
188
|
for (var i = 0; i < approverRoles.length; i++) {
|
|
189
|
-
|
|
189
|
+
var required = approverRoles[i];
|
|
190
|
+
// Wildcard match — actor's "security:*" satisfies a required
|
|
191
|
+
// "security:officer" (matching the b.permissions.match
|
|
192
|
+
// semantics elsewhere in the framework). Without this, an
|
|
193
|
+
// operator with a wildcard-shaped role can't approve dual-
|
|
194
|
+
// control flows even when b.permissions would consider the
|
|
195
|
+
// role assignment satisfied.
|
|
196
|
+
for (var j = 0; j < actor.roles.length; j++) {
|
|
197
|
+
var actorRole = actor.roles[j];
|
|
198
|
+
if (actorRole === required) return true;
|
|
199
|
+
if (typeof actorRole === "string" &&
|
|
200
|
+
actorRole.length > 0 &&
|
|
201
|
+
actorRole.charAt(actorRole.length - 1) === "*") {
|
|
202
|
+
var prefix = actorRole.slice(0, -1);
|
|
203
|
+
if (typeof required === "string" && required.indexOf(prefix) === 0) {
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
190
208
|
}
|
|
191
209
|
return false;
|
|
192
210
|
}
|
package/lib/external-db.js
CHANGED
|
@@ -570,6 +570,16 @@ function _buildSessionGucsStatements(sessionGucs) {
|
|
|
570
570
|
// Postgres SET accepts on/off/true/false — render true/false.
|
|
571
571
|
literal = value ? "true" : "false";
|
|
572
572
|
} else if (typeof value === "string") {
|
|
573
|
+
// Cap the value length so an operator-controlled tenant_id of
|
|
574
|
+
// 100 KB doesn't hit Postgres' SET LOCAL parser with payload
|
|
575
|
+
// that bloats query logs and consumes max_stack_depth. The cap
|
|
576
|
+
// is generous for legitimate tenant identifiers but rejects
|
|
577
|
+
// amplification.
|
|
578
|
+
if (value.length > C.BYTES.kib(4)) {
|
|
579
|
+
throw _err("INVALID_SESSION_GUCS",
|
|
580
|
+
"sessionGucs['" + name + "']: value exceeds 4 KiB cap (got " +
|
|
581
|
+
value.length + " chars)", true);
|
|
582
|
+
}
|
|
573
583
|
literal = "'" + value.replace(/'/g, "''") + "'";
|
|
574
584
|
} else if (value === null || value === undefined) {
|
|
575
585
|
throw _err("INVALID_SESSION_GUCS",
|
package/lib/file-upload.js
CHANGED
|
@@ -763,7 +763,7 @@ function create(opts) {
|
|
|
763
763
|
return { paths: paths, totalBytes: totalBytes, totalHashHex: totalHashHex };
|
|
764
764
|
}
|
|
765
765
|
|
|
766
|
-
function _checkAllowedFileType(firstChunkBody) {
|
|
766
|
+
function _checkAllowedFileType(firstChunkBody, claimedMime) {
|
|
767
767
|
if (!allowedFileTypes || allowedFileTypes.length === 0) return;
|
|
768
768
|
if (!fileType) return; // create() guards this; defensive
|
|
769
769
|
var detected = fileType.detect(firstChunkBody);
|
|
@@ -787,6 +787,30 @@ function create(opts) {
|
|
|
787
787
|
"fileUpload.finalize: detected MIME '" + detectedMime +
|
|
788
788
|
"' not in allowedFileTypes (" + allowedFileTypes.join(", ") + ")");
|
|
789
789
|
}
|
|
790
|
+
// Cross-check claimed MIME (Content-Type header) against detected
|
|
791
|
+
// magic bytes. When both exist and disagree, refuse — downstream
|
|
792
|
+
// renderers / storage layers that trust metadata.contentType
|
|
793
|
+
// (CDN cache routing, MIME-sniff fallbacks, attachment-disposition
|
|
794
|
+
// decisions) will mis-handle the file. The strict-MIME check is
|
|
795
|
+
// load-bearing for image-pipelines that pass to image processors:
|
|
796
|
+
// a "image/png" claim with PDF magic bytes lands in image-rendering
|
|
797
|
+
// code-paths that will exec PDF parsers with surprising semantics.
|
|
798
|
+
if (claimedMime && typeof claimedMime === "string" && claimedMime.indexOf("/") !== -1) {
|
|
799
|
+
var claimedNormalized = claimedMime.split(";")[0].trim().toLowerCase();
|
|
800
|
+
if (claimedNormalized && claimedNormalized !== detectedMime) {
|
|
801
|
+
// Wildcard / family acceptance: "image/jpeg" claim + "image/jpg"
|
|
802
|
+
// detect (synonyms) is OK; "image/png" claim + "image/jpeg"
|
|
803
|
+
// detect is NOT.
|
|
804
|
+
var claimedFamily = claimedNormalized.split("/")[0];
|
|
805
|
+
var detectedFamily = detectedMime.split("/")[0];
|
|
806
|
+
if (claimedFamily !== detectedFamily) {
|
|
807
|
+
throw _err("MIME_CLAIM_MISMATCH",
|
|
808
|
+
"fileUpload.finalize: claimed Content-Type '" + claimedNormalized +
|
|
809
|
+
"' disagrees with detected magic-byte MIME '" + detectedMime +
|
|
810
|
+
"'. Refusing to proceed with mis-typed file.");
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
790
814
|
}
|
|
791
815
|
|
|
792
816
|
function _streamFromChunkPaths(paths /* totalBytes */) {
|
|
@@ -842,8 +866,11 @@ function create(opts) {
|
|
|
842
866
|
firstChunk = pieces[0];
|
|
843
867
|
}
|
|
844
868
|
|
|
845
|
-
// MIME allowlist gate (if configured).
|
|
846
|
-
|
|
869
|
+
// MIME allowlist gate (if configured). Pass the operator-supplied
|
|
870
|
+
// contentType from upload metadata so the cross-check can refuse
|
|
871
|
+
// claimed-vs-detected mismatches.
|
|
872
|
+
var claimedMime = (meta && meta.metadata && meta.metadata.contentType) || null;
|
|
873
|
+
try { _checkAllowedFileType(firstChunk, claimedMime); }
|
|
847
874
|
catch (e) {
|
|
848
875
|
_emitObs("fileUpload.mime_rejected", 1);
|
|
849
876
|
_emitAudit("fileUpload.finalize", {
|
package/lib/flag.js
CHANGED
package/lib/guard-all.js
CHANGED
|
@@ -116,8 +116,16 @@ var SHARED_POSTURES = Object.freeze(["hipaa", "pci-dss", "gdpr", "soc2"]);
|
|
|
116
116
|
|
|
117
117
|
function _verifyParity() {
|
|
118
118
|
var failures = [];
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
// Walk both registries — content guards (with MIME_TYPES + EXTENSIONS)
|
|
120
|
+
// and standalone guards (filename / domain / uuid / cidr / time /
|
|
121
|
+
// mime / jwt / oauth / graphql / shell / regex / jsonpath / template /
|
|
122
|
+
// image / pdf / auth). Standalone guards skip the MIME/EXTENSION
|
|
123
|
+
// checks but every guard MUST declare the shared profile + posture
|
|
124
|
+
// vocabulary so b.guardAll.allGuards() returns a uniform surface.
|
|
125
|
+
var allGuards = GUARDS.concat(STANDALONE_GUARDS);
|
|
126
|
+
for (var i = 0; i < allGuards.length; i += 1) {
|
|
127
|
+
var g = allGuards[i];
|
|
128
|
+
var isContent = i < GUARDS.length;
|
|
121
129
|
if (!g || typeof g !== "object") {
|
|
122
130
|
failures.push("guard at index " + i + " is not an exported module object");
|
|
123
131
|
continue;
|
|
@@ -126,11 +134,13 @@ function _verifyParity() {
|
|
|
126
134
|
failures.push("guard at index " + i + ": missing NAME export");
|
|
127
135
|
continue;
|
|
128
136
|
}
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
137
|
+
if (isContent) {
|
|
138
|
+
if (!Array.isArray(g.MIME_TYPES) || g.MIME_TYPES.length === 0) {
|
|
139
|
+
failures.push(g.NAME + ": missing or empty MIME_TYPES export");
|
|
140
|
+
}
|
|
141
|
+
if (!Array.isArray(g.EXTENSIONS) || g.EXTENSIONS.length === 0) {
|
|
142
|
+
failures.push(g.NAME + ": missing or empty EXTENSIONS export");
|
|
143
|
+
}
|
|
134
144
|
}
|
|
135
145
|
if (typeof g.gate !== "function") {
|
|
136
146
|
failures.push(g.NAME + ": missing gate(opts) function");
|
|
@@ -146,27 +156,34 @@ function _verifyParity() {
|
|
|
146
156
|
}
|
|
147
157
|
});
|
|
148
158
|
}
|
|
149
|
-
// Detect duplicate NAMEs
|
|
150
|
-
//
|
|
159
|
+
// Detect duplicate NAMEs across the full registry (both content +
|
|
160
|
+
// standalone) so a future guard with a NAME collision surfaces at
|
|
161
|
+
// boot instead of silently overriding _byName lookups. MIME / EXT
|
|
162
|
+
// collision detection stays scoped to content guards (standalone
|
|
163
|
+
// guards have no MIME/EXTENSIONS).
|
|
151
164
|
var nameSeen = Object.create(null);
|
|
152
165
|
var mimeSeen = Object.create(null);
|
|
153
166
|
var extSeen = Object.create(null);
|
|
154
|
-
for (var j = 0; j <
|
|
155
|
-
var gg =
|
|
167
|
+
for (var j = 0; j < allGuards.length; j += 1) {
|
|
168
|
+
var gg = allGuards[j];
|
|
156
169
|
if (gg && gg.NAME) {
|
|
157
|
-
if (nameSeen[gg.NAME]) failures.push("duplicate NAME " + JSON.stringify(gg.NAME)
|
|
170
|
+
if (nameSeen[gg.NAME]) failures.push("duplicate NAME " + JSON.stringify(gg.NAME) +
|
|
171
|
+
" across the full guard registry");
|
|
158
172
|
nameSeen[gg.NAME] = true;
|
|
159
173
|
}
|
|
160
|
-
|
|
161
|
-
|
|
174
|
+
}
|
|
175
|
+
for (var jc = 0; jc < GUARDS.length; jc += 1) {
|
|
176
|
+
var ggc = GUARDS[jc];
|
|
177
|
+
if (ggc && Array.isArray(ggc.MIME_TYPES)) {
|
|
178
|
+
ggc.MIME_TYPES.forEach(function (m) {
|
|
162
179
|
var k = String(m).toLowerCase();
|
|
163
180
|
if (mimeSeen[k]) failures.push("duplicate MIME_TYPE " + JSON.stringify(k) +
|
|
164
181
|
" across multiple guards");
|
|
165
182
|
mimeSeen[k] = true;
|
|
166
183
|
});
|
|
167
184
|
}
|
|
168
|
-
if (
|
|
169
|
-
|
|
185
|
+
if (ggc && Array.isArray(ggc.EXTENSIONS)) {
|
|
186
|
+
ggc.EXTENSIONS.forEach(function (e) {
|
|
170
187
|
var k = String(e).toLowerCase();
|
|
171
188
|
if (extSeen[k]) failures.push("duplicate EXTENSION " + JSON.stringify(k) +
|
|
172
189
|
" across multiple guards");
|
package/lib/guard-csv.js
CHANGED
|
@@ -360,7 +360,20 @@ function _detectIssues(text, opts) {
|
|
|
360
360
|
}
|
|
361
361
|
|
|
362
362
|
if (opts.formulaInjectionPolicy !== "audit-only" && opts.formulaInjectionPolicy !== "allow") {
|
|
363
|
-
|
|
363
|
+
// Strip ZWSP / RTLO / LRM / RLM / BOM at cell-start before the
|
|
364
|
+
// formula scan. Without this, a cell beginning with U+200B (zero-
|
|
365
|
+
// width space), U+202E (RTLO), U+200E/F (LTR/RTL marks), or U+FEFF
|
|
366
|
+
// (BOM) followed by `=` slips past the start-anchor check (the `^`
|
|
367
|
+
// sits before the codepoint, not after) and the formula reaches
|
|
368
|
+
// the spreadsheet evaluator. Browsers + Excel + Sheets all strip
|
|
369
|
+
// these silently — operator users see "=SUM(...)" rendered, the
|
|
370
|
+
// file shipped a hidden bidi prefix that bypassed the scanner.
|
|
371
|
+
// U+200B-200F (ZWSP / ZWNJ / ZWJ / LRM / RLM) +
|
|
372
|
+
// U+202A-202E (LRE / RLE / PDF / LRO / RLO) +
|
|
373
|
+
// U+2066-2069 (LRI / RLI / FSI / PDI) +
|
|
374
|
+
// U+FEFF (BOM) // allow:dynamic-regex — explicit codepoints, no operator input
|
|
375
|
+
var stripped = text.replace(new RegExp("^[\\u200B-\\u200F\\u202A-\\u202E\\u2066-\\u2069\\uFEFF]+"), "");
|
|
376
|
+
var formulaMatch = _firstMatch(stripped, FORMULA_SCAN_RE);
|
|
364
377
|
if (formulaMatch) {
|
|
365
378
|
issues.push({
|
|
366
379
|
kind: "formula-prefix-cell", severity: "critical",
|
|
@@ -368,7 +381,8 @@ function _detectIssues(text, opts) {
|
|
|
368
381
|
location: formulaMatch.index,
|
|
369
382
|
snippet: "cell beginning with formula trigger " +
|
|
370
383
|
JSON.stringify(formulaMatch.char.slice(-1)) +
|
|
371
|
-
" at byte " + formulaMatch.index
|
|
384
|
+
" at byte " + formulaMatch.index +
|
|
385
|
+
(stripped.length !== text.length ? " (after stripping leading bidi/zero-width prefix)" : ""),
|
|
372
386
|
});
|
|
373
387
|
}
|
|
374
388
|
}
|
package/lib/guard-html.js
CHANGED
|
@@ -402,6 +402,30 @@ function escapeAttr(value) {
|
|
|
402
402
|
.replace(/=/g, "=");
|
|
403
403
|
}
|
|
404
404
|
|
|
405
|
+
// HTML5 named entities that decode to ASCII codepoints — focused on
|
|
406
|
+
// the entries browsers honor inside URL contexts (whitespace, control
|
|
407
|
+
// chars, scheme-significant punctuation). The full WHATWG named-
|
|
408
|
+
// character-reference table is ~2,231 entries; this is the
|
|
409
|
+
// security-load-bearing subset documented in scheme-bypass writeups
|
|
410
|
+
// (CVE-2026-30838 class). High-codepoint named entities (e.g. mathematical
|
|
411
|
+
// symbols) don't affect URL scheme parsing, so they're omitted.
|
|
412
|
+
var NAMED_ENTITY_ASCII = {
|
|
413
|
+
// Whitespace + control chars browsers strip inside URL schemes
|
|
414
|
+
Tab: "\t", NewLine: "\n",
|
|
415
|
+
// Scheme-significant punctuation
|
|
416
|
+
colon: ":", semi: ";", period: ".", sol: "/", bsol: "\\",
|
|
417
|
+
num: "#", excl: "!", quest: "?", lpar: "(", rpar: ")",
|
|
418
|
+
lsqb: "[", rsqb: "]", lcub: "{", rcub: "}",
|
|
419
|
+
// Quotes / brackets
|
|
420
|
+
quot: "\"", apos: "'", lt: "<", gt: ">",
|
|
421
|
+
// Misc ASCII
|
|
422
|
+
amp: "&", commat: "@", dollar: "$", percnt: "%",
|
|
423
|
+
ast: "*", plus: "+", lowbar: "_", hyphen: "-",
|
|
424
|
+
// Whitespace markers (codepoints in the ASCII / Latin-1 range that
|
|
425
|
+
// browsers treat as URL-strippable)
|
|
426
|
+
nbsp: " ",
|
|
427
|
+
};
|
|
428
|
+
|
|
405
429
|
// _normalizeUrl — peel off entity-encoded leading whitespace and
|
|
406
430
|
// HTML/URL-encoded scheme prefix tricks, then return the lowercased
|
|
407
431
|
// scheme. Returns "" if no scheme.
|
|
@@ -415,6 +439,17 @@ function _extractScheme(rawUrl) {
|
|
|
415
439
|
s = s.replace(/&#(\d+);/g, function (_m, d) {
|
|
416
440
|
return String.fromCharCode(parseInt(d, 10));
|
|
417
441
|
});
|
|
442
|
+
// Decode HTML5 named entities that browsers honor inside URL
|
|
443
|
+
// contexts. Without this, payloads like `java	script:alert(1)`
|
|
444
|
+
// bypass the scheme allowlist (the literal `	` between `java`
|
|
445
|
+
// and `script:` doesn't match any denied scheme; the browser then
|
|
446
|
+
// decodes the entity, strips the tab, and executes javascript:).
|
|
447
|
+
s = s.replace(/&([A-Za-z][A-Za-z0-9]+);/g, function (m, name) {
|
|
448
|
+
if (Object.prototype.hasOwnProperty.call(NAMED_ENTITY_ASCII, name)) {
|
|
449
|
+
return NAMED_ENTITY_ASCII[name];
|
|
450
|
+
}
|
|
451
|
+
return m;
|
|
452
|
+
});
|
|
418
453
|
// Strip embedded whitespace + control chars + zero-widths the
|
|
419
454
|
// URL parser would tolerate.
|
|
420
455
|
s = s.replace(C0_CTRL_RE_G, "").replace(ZW_RE_G, "");
|
package/lib/guard-svg.js
CHANGED
|
@@ -352,6 +352,20 @@ function _resolveOpts(opts) {
|
|
|
352
352
|
});
|
|
353
353
|
}
|
|
354
354
|
|
|
355
|
+
// HTML5 named-entity ASCII subset — same shape as guard-html.
|
|
356
|
+
// Browsers honor these inside URL contexts; without decoding them,
|
|
357
|
+
// `java	script:` and friends bypass the scheme allowlist.
|
|
358
|
+
var SVG_NAMED_ENTITY_ASCII = {
|
|
359
|
+
Tab: "\t", NewLine: "\n",
|
|
360
|
+
colon: ":", semi: ";", period: ".", sol: "/", bsol: "\\",
|
|
361
|
+
num: "#", excl: "!", quest: "?", lpar: "(", rpar: ")",
|
|
362
|
+
lsqb: "[", rsqb: "]", lcub: "{", rcub: "}",
|
|
363
|
+
quot: "\"", apos: "'", lt: "<", gt: ">",
|
|
364
|
+
amp: "&", commat: "@", dollar: "$", percnt: "%",
|
|
365
|
+
ast: "*", plus: "+", lowbar: "_", hyphen: "-",
|
|
366
|
+
nbsp: " ",
|
|
367
|
+
};
|
|
368
|
+
|
|
355
369
|
function _extractScheme(rawUrl) {
|
|
356
370
|
var s = String(rawUrl || "").trim();
|
|
357
371
|
s = s.replace(/&#x([0-9a-f]+);/gi, function (_m, h) {
|
|
@@ -360,6 +374,12 @@ function _extractScheme(rawUrl) {
|
|
|
360
374
|
s = s.replace(/&#(\d+);/g, function (_m, d) {
|
|
361
375
|
return String.fromCharCode(parseInt(d, 10));
|
|
362
376
|
});
|
|
377
|
+
s = s.replace(/&([A-Za-z][A-Za-z0-9]+);/g, function (m, name) {
|
|
378
|
+
if (Object.prototype.hasOwnProperty.call(SVG_NAMED_ENTITY_ASCII, name)) {
|
|
379
|
+
return SVG_NAMED_ENTITY_ASCII[name];
|
|
380
|
+
}
|
|
381
|
+
return m;
|
|
382
|
+
});
|
|
363
383
|
s = s.replace(C0_CTRL_RE_G, "").replace(ZW_RE_G, "");
|
|
364
384
|
var m = s.match(/^([A-Za-z][A-Za-z0-9+.-]*):/);
|
|
365
385
|
return m ? m[1].toLowerCase() : "";
|