@blamejs/core 0.14.26 → 0.15.0
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 +6 -0
- package/README.md +2 -2
- package/index.js +4 -0
- package/lib/agent-envelope-mac.js +104 -0
- package/lib/agent-event-bus.js +105 -4
- package/lib/agent-posture-chain.js +8 -42
- package/lib/ai-content-detect.js +9 -10
- package/lib/api-key.js +107 -74
- package/lib/atomic-file.js +62 -4
- package/lib/audit-chain.js +47 -11
- package/lib/audit-sign.js +77 -2
- package/lib/audit-tools.js +79 -51
- package/lib/audit.js +249 -123
- package/lib/auth/openid-federation.js +108 -47
- package/lib/backup/index.js +13 -10
- package/lib/break-glass.js +202 -144
- package/lib/cache.js +174 -105
- package/lib/chain-writer.js +38 -16
- package/lib/cli.js +19 -14
- package/lib/cluster-provider-db.js +130 -104
- package/lib/cluster-storage.js +119 -22
- package/lib/cluster.js +119 -71
- package/lib/compliance.js +169 -4
- package/lib/consent.js +73 -24
- package/lib/constants.js +16 -11
- package/lib/crypto-field.js +474 -92
- package/lib/db-declare-row-policy.js +35 -22
- package/lib/db-file-lifecycle.js +3 -2
- package/lib/db-query.js +497 -255
- package/lib/db-schema.js +209 -44
- package/lib/db.js +176 -95
- package/lib/error-page.js +14 -1
- package/lib/external-db-migrate.js +229 -139
- package/lib/external-db.js +25 -15
- package/lib/file-upload.js +52 -7
- package/lib/framework-error.js +14 -1
- package/lib/framework-files.js +73 -0
- package/lib/framework-schema.js +695 -394
- package/lib/gate-contract.js +649 -1
- package/lib/guard-agent-registry.js +26 -44
- package/lib/guard-all.js +1 -0
- package/lib/guard-auth.js +42 -112
- package/lib/guard-cidr.js +33 -154
- package/lib/guard-csv.js +46 -113
- package/lib/guard-domain.js +34 -157
- package/lib/guard-dsn.js +27 -43
- package/lib/guard-email.js +47 -69
- package/lib/guard-envelope.js +19 -32
- package/lib/guard-event-bus-payload.js +24 -42
- package/lib/guard-event-bus-topic.js +25 -43
- package/lib/guard-filename.js +42 -106
- package/lib/guard-graphql.js +42 -123
- package/lib/guard-html.js +53 -108
- package/lib/guard-idempotency-key.js +24 -42
- package/lib/guard-image.js +46 -103
- package/lib/guard-imap-command.js +18 -32
- package/lib/guard-jmap.js +16 -30
- package/lib/guard-json.js +38 -108
- package/lib/guard-jsonpath.js +38 -171
- package/lib/guard-jwt.js +49 -179
- package/lib/guard-list-id.js +25 -41
- package/lib/guard-list-unsubscribe.js +27 -43
- package/lib/guard-mail-compose.js +24 -42
- package/lib/guard-mail-move.js +26 -44
- package/lib/guard-mail-query.js +28 -46
- package/lib/guard-mail-reply.js +24 -42
- package/lib/guard-mail-sieve.js +24 -42
- package/lib/guard-managesieve-command.js +17 -31
- package/lib/guard-markdown.js +37 -104
- package/lib/guard-message-id.js +26 -45
- package/lib/guard-mime.js +39 -151
- package/lib/guard-oauth.js +54 -135
- package/lib/guard-pdf.js +45 -101
- package/lib/guard-pop3-command.js +21 -31
- package/lib/guard-posture-chain.js +24 -42
- package/lib/guard-regex.js +33 -107
- package/lib/guard-saga-config.js +24 -42
- package/lib/guard-shell.js +42 -172
- package/lib/guard-smtp-command.js +48 -54
- package/lib/guard-snapshot-envelope.js +24 -42
- package/lib/guard-sql.js +1491 -0
- package/lib/guard-stream-args.js +24 -43
- package/lib/guard-svg.js +47 -65
- package/lib/guard-template.js +35 -172
- package/lib/guard-tenant-id.js +26 -45
- package/lib/guard-time.js +32 -154
- package/lib/guard-trace-context.js +25 -44
- package/lib/guard-uuid.js +32 -153
- package/lib/guard-xml.js +38 -113
- package/lib/guard-yaml.js +51 -163
- package/lib/http-client.js +37 -9
- package/lib/inbox.js +120 -107
- package/lib/legal-hold.js +107 -50
- package/lib/log-stream-cloudwatch.js +47 -31
- package/lib/log-stream-otlp.js +32 -18
- package/lib/mail-crypto-smime.js +2 -6
- package/lib/mail-greylist.js +2 -6
- package/lib/mail-helo.js +2 -6
- package/lib/mail-journal.js +85 -64
- package/lib/mail-rbl.js +2 -6
- package/lib/mail-scan.js +2 -6
- package/lib/mail-server-jmap.js +117 -12
- package/lib/mail-spam-score.js +2 -6
- package/lib/mail-store.js +287 -154
- package/lib/middleware/body-parser.js +71 -25
- package/lib/middleware/csrf-protect.js +19 -8
- package/lib/middleware/fetch-metadata.js +17 -7
- package/lib/middleware/idempotency-key.js +54 -38
- package/lib/middleware/rate-limit.js +102 -32
- package/lib/middleware/security-headers.js +21 -5
- package/lib/migrations.js +108 -66
- package/lib/network-heartbeat.js +7 -0
- package/lib/nonce-store.js +31 -9
- package/lib/object-store/azure-blob-bucket-ops.js +9 -4
- package/lib/object-store/azure-blob.js +57 -3
- package/lib/object-store/sigv4.js +10 -0
- package/lib/observability.js +87 -0
- package/lib/otel-export.js +25 -1
- package/lib/outbox.js +136 -82
- package/lib/parsers/safe-xml.js +47 -7
- package/lib/pqc-agent.js +44 -0
- package/lib/pubsub-cluster.js +42 -20
- package/lib/queue-local.js +202 -139
- package/lib/queue-redis.js +9 -1
- package/lib/queue-sqs.js +6 -0
- package/lib/redact.js +68 -11
- package/lib/redis-client.js +160 -31
- package/lib/retention.js +82 -39
- package/lib/router.js +212 -5
- package/lib/safe-dns.js +29 -45
- package/lib/safe-ical.js +18 -33
- package/lib/safe-icap.js +27 -43
- package/lib/safe-sieve.js +21 -40
- package/lib/safe-sql.js +124 -3
- package/lib/safe-vcard.js +18 -33
- package/lib/scheduler.js +35 -12
- package/lib/seeders.js +122 -74
- package/lib/session-stores.js +42 -14
- package/lib/session.js +109 -72
- package/lib/sql.js +3885 -0
- package/lib/ssrf-guard.js +51 -4
- package/lib/static.js +177 -34
- package/lib/subject.js +55 -17
- package/lib/vault/index.js +3 -2
- package/lib/vault/passphrase-ops.js +3 -2
- package/lib/vault/rotate.js +104 -64
- package/lib/vendor-data.js +2 -0
- package/lib/websocket.js +35 -5
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/ssrf-guard.js
CHANGED
|
@@ -148,12 +148,27 @@ var IPV6_6TO4_PREFIX = _ipv6ToBytes("2002::");
|
|
|
148
148
|
// or attempted exfil to a sinkhole.
|
|
149
149
|
var IPV6_DISCARD_PREFIX = _ipv6ToBytes("100::");
|
|
150
150
|
|
|
151
|
-
// ---- Cloud metadata addresses (
|
|
151
|
+
// ---- Cloud metadata addresses (matched on CANONICAL bytes, not string) ----
|
|
152
|
+
// The documentation strings below are the human-readable canonical forms.
|
|
153
|
+
// Matching is byte-canonical (see _isCloudMetadataAddr): an IPv6 address has
|
|
154
|
+
// many textual representations (compressed `::`, fully-expanded
|
|
155
|
+
// `fd00:ec2:0:0:0:0:0:254`, mixed-case) that all decode to the same 16 bytes.
|
|
156
|
+
// A string-equality membership test matched only ONE spelling, so a hostile
|
|
157
|
+
// (or merely DoH-decoded — network-dns.js emits the expanded form) answer of
|
|
158
|
+
// `fd00:ec2:0:0:0:0:0:254` slipped past as "private" and rode the documented
|
|
159
|
+
// `allowInternal:true` waiver straight into the IMDS credential endpoint.
|
|
152
160
|
var CLOUD_METADATA_IPS = [
|
|
153
161
|
"169.254.169.254", // AWS, GCP, Azure, OpenStack, DO
|
|
154
162
|
"169.254.170.2", // AWS ECS task role
|
|
155
163
|
"fd00:ec2::254", // AWS IMDS over IPv6
|
|
156
164
|
];
|
|
165
|
+
// Canonical byte forms of the metadata IPs — v4 as a 4-byte Buffer, v6 as a
|
|
166
|
+
// 16-byte Buffer. Built once at load via the same parsers classify() uses,
|
|
167
|
+
// so every textual representation that decodes to these bytes is caught.
|
|
168
|
+
var CLOUD_METADATA_BYTES = CLOUD_METADATA_IPS.map(function (ip) {
|
|
169
|
+
var fam = net.isIP(ip);
|
|
170
|
+
return fam === 4 ? _ipv4ToBytes(ip) : _ipv6ToBytes(ip);
|
|
171
|
+
});
|
|
157
172
|
|
|
158
173
|
// ---- Helpers ----
|
|
159
174
|
|
|
@@ -180,6 +195,14 @@ function _ipv4ToInt(ip) {
|
|
|
180
195
|
nums[3];
|
|
181
196
|
}
|
|
182
197
|
|
|
198
|
+
function _ipv4ToBytes(ip) {
|
|
199
|
+
// Canonical 4-byte form of an IPv4 address. Returns null on malformed
|
|
200
|
+
// input so a metadata-membership test never matches garbage.
|
|
201
|
+
var n = _ipv4ToInt(ip);
|
|
202
|
+
if (!Number.isFinite(n)) return null;
|
|
203
|
+
return Buffer.from([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
|
|
204
|
+
}
|
|
205
|
+
|
|
183
206
|
function _ipv6ToBytes(ip) {
|
|
184
207
|
// Node's net.isIPv6 returns 6 for valid IPv6; we then expand
|
|
185
208
|
// shorthand via manual parsing. node:net doesn't export an
|
|
@@ -309,7 +332,10 @@ function classify(ip) {
|
|
|
309
332
|
var family = net.isIP(ip);
|
|
310
333
|
if (family === 0) return null;
|
|
311
334
|
|
|
312
|
-
|
|
335
|
+
// Cloud-metadata IPs are matched on their canonical byte form so every
|
|
336
|
+
// textual spelling (compressed `::`, fully-expanded zero-runs, mixed
|
|
337
|
+
// case) is caught — a string-equality test matched one spelling only.
|
|
338
|
+
if (_isCloudMetadataAddr(ip, family)) return "cloud-metadata";
|
|
313
339
|
|
|
314
340
|
if (family === 4) {
|
|
315
341
|
var ipInt = _ipv4ToInt(ip);
|
|
@@ -349,6 +375,24 @@ function classify(ip) {
|
|
|
349
375
|
return null;
|
|
350
376
|
}
|
|
351
377
|
|
|
378
|
+
// Canonical-bytes membership test for the cloud-metadata IP set. An IP
|
|
379
|
+
// matches iff its parsed bytes equal one of CLOUD_METADATA_BYTES, regardless
|
|
380
|
+
// of textual representation. This is the unconditional metadata gate — it
|
|
381
|
+
// must NOT be string-based, because IPv6 has many spellings of the same
|
|
382
|
+
// address (the DoH resolver in network-dns.js, for instance, emits the
|
|
383
|
+
// fully-expanded `fd00:ec2:0:0:0:0:0:254` rather than the compressed form).
|
|
384
|
+
function _isCloudMetadataAddr(ip, family) {
|
|
385
|
+
var fam = typeof family === "number" ? family : net.isIP(ip);
|
|
386
|
+
if (fam === 0) return false;
|
|
387
|
+
var bytes = fam === 4 ? _ipv4ToBytes(ip) : _ipv6ToBytes(ip);
|
|
388
|
+
if (!bytes) return false;
|
|
389
|
+
for (var i = 0; i < CLOUD_METADATA_BYTES.length; i++) {
|
|
390
|
+
var ref = CLOUD_METADATA_BYTES[i];
|
|
391
|
+
if (ref && ref.length === bytes.length && _bufEqual(bytes, ref)) return true;
|
|
392
|
+
}
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
|
|
352
396
|
function _bufEqual(a, b) {
|
|
353
397
|
// Compares Buffer-like byte arrays for equality. The buffers here
|
|
354
398
|
// are IP addresses, not secrets, so the comparison doesn't need
|
|
@@ -766,8 +810,11 @@ function checkUrlTextual(url, opts) {
|
|
|
766
810
|
// If the textual hostname IS an IP literal AND matches a cloud-
|
|
767
811
|
// metadata IP, refuse — even with `allowInternal: true` and a proxy.
|
|
768
812
|
// Metadata IPs leak instance credentials (AWS IMDS, GCP, Azure) and
|
|
769
|
-
// are not a configuration knob.
|
|
770
|
-
|
|
813
|
+
// are not a configuration knob. Matched on canonical bytes so a
|
|
814
|
+
// non-canonical IPv6 spelling (compressed / expanded / mixed-case)
|
|
815
|
+
// can't slip the textual gate the way it slipped classify().
|
|
816
|
+
var hostFamily = net.isIP(host);
|
|
817
|
+
if (hostFamily !== 0 && _isCloudMetadataAddr(host, hostFamily)) {
|
|
771
818
|
throw new ErrorClass(
|
|
772
819
|
"URL '" + parsed.toString() + "' resolves to cloud-metadata IP " + host +
|
|
773
820
|
" — refused unconditionally (not overridable via allowInternal + proxy)",
|
package/lib/static.js
CHANGED
|
@@ -126,12 +126,32 @@ var DEFAULTS = Object.freeze({
|
|
|
126
126
|
// serve event is the audit-worthy act, not a precursor.
|
|
127
127
|
auditSuccess: true,
|
|
128
128
|
auditFailures: true,
|
|
129
|
+
// mountType — declares what KIND of content this mount serves, so
|
|
130
|
+
// the stored-XSS-relevant defaults follow the typing instead of being
|
|
131
|
+
// hand-flipped per mount (v0.15.0):
|
|
132
|
+
// "curated" (default) — operator-controlled assets (CSS / JS
|
|
133
|
+
// bundles / fonts / images). Inline render is required
|
|
134
|
+
// and safe because the operator authored the bytes;
|
|
135
|
+
// forceAttachmentForNonText defaults OFF.
|
|
136
|
+
// "user-content" — files written by end users / untrusted uploaders.
|
|
137
|
+
// A served .html / .js / .svg here is a stored-XSS
|
|
138
|
+
// vector, so forceAttachmentForNonText defaults ON —
|
|
139
|
+
// risky inline MIMEs are forced to download unless a
|
|
140
|
+
// sanitizer gate vouches for them (see
|
|
141
|
+
// `_shouldForceAttachment`). This is the conditional
|
|
142
|
+
// flip: a curated asset dir is never blindly forced to
|
|
143
|
+
// download; only a mount the operator TYPED as
|
|
144
|
+
// user-content gets the strict default.
|
|
145
|
+
// An explicit forceAttachmentForNonText always overrides the
|
|
146
|
+
// mountType-derived default.
|
|
147
|
+
mountType: "curated",
|
|
129
148
|
// forceAttachmentForNonText — stored-XSS defense for user-upload
|
|
130
|
-
// directories. Default OFF
|
|
131
|
-
// (CSS / JS bundles / fonts
|
|
132
|
-
// user-
|
|
133
|
-
//
|
|
134
|
-
//
|
|
149
|
+
// directories. Default follows mountType: OFF for "curated" mounts
|
|
150
|
+
// (operator-curated CSS / JS bundles / fonts need inline render), ON for
|
|
151
|
+
// "user-content" mounts so HTML / JS / SVG without a sanitizer / PDF /
|
|
152
|
+
// archives are forced to download. Set explicitly to override the
|
|
153
|
+
// mountType-derived default either way. See `_shouldForceAttachment`
|
|
154
|
+
// below for the safe-render allowlist.
|
|
135
155
|
forceAttachmentForNonText: false,
|
|
136
156
|
// Companion knobs — when forceAttachmentForNonText is on, allow
|
|
137
157
|
// image/svg+xml inline render IF an SVG sanitizer gate is wired
|
|
@@ -142,12 +162,68 @@ var DEFAULTS = Object.freeze({
|
|
|
142
162
|
safeRenderPdf: false,
|
|
143
163
|
});
|
|
144
164
|
|
|
165
|
+
// _assertInsideRoot — the path-confinement barrier (CWE-22 path
|
|
166
|
+
// traversal). Every filesystem sink in this module takes the path
|
|
167
|
+
// through this helper so the value handed to fs is built by
|
|
168
|
+
// `nodePath.join(root, rel)` where `rel` is a normalized, root-relative
|
|
169
|
+
// path with every leading `..` segment stripped — the canonical
|
|
170
|
+
// path-traversal sanitizer: normalize collapses interior `.`/`..`, the
|
|
171
|
+
// leading-`..` strip removes upward navigation, and joining a constant
|
|
172
|
+
// root with a sanitized relative segment yields a path that provably
|
|
173
|
+
// stays inside the served root. The barrier is intentionally re-applied
|
|
174
|
+
// at each sink (not just once at request entry) so the relationship
|
|
175
|
+
// between the sanitizer and the fs call is local + explicit.
|
|
176
|
+
//
|
|
177
|
+
// Returns the joined, confined absolute path on success, or `null` when
|
|
178
|
+
// the candidate is not a string, carries a NUL byte, or — after the
|
|
179
|
+
// leading-`..` strip — still carries a `..` segment or an absolute /
|
|
180
|
+
// drive-letter / UNC prefix that would smuggle outside root. A leading
|
|
181
|
+
// `..` escape is clamped into root by the strip (the file then 404s);
|
|
182
|
+
// any residual escape that survives normalization is refused. Callers
|
|
183
|
+
// MUST treat `null` as a refusal.
|
|
184
|
+
function _assertInsideRoot(root, candidate) {
|
|
185
|
+
if (typeof root !== "string" || root.length === 0) return null;
|
|
186
|
+
if (typeof candidate !== "string" || candidate.length === 0) return null;
|
|
187
|
+
if (candidate.indexOf("\0") !== -1) return null;
|
|
188
|
+
var rootResolved = nodePath.resolve(root);
|
|
189
|
+
// Reduce the candidate to a root-relative request, then run the
|
|
190
|
+
// recognized traversal sanitizer: normalize() collapses `.`/`..`
|
|
191
|
+
// segments; the replace strips every leading `..` so no upward
|
|
192
|
+
// navigation survives into the join below.
|
|
193
|
+
var requested = nodePath.isAbsolute(candidate)
|
|
194
|
+
? nodePath.relative(rootResolved, candidate)
|
|
195
|
+
: candidate;
|
|
196
|
+
var rel = nodePath.normalize(requested).replace(/^(\.\.(\/|\\|$))+/, "");
|
|
197
|
+
if (rel.indexOf("\0") !== -1) return null;
|
|
198
|
+
// After the leading-`..` strip, a surviving `..` segment or an
|
|
199
|
+
// absolute / drive-letter / UNC residue would re-introduce an escape.
|
|
200
|
+
if (rel === ".." ||
|
|
201
|
+
rel.indexOf(".." + nodePath.sep) !== -1 ||
|
|
202
|
+
rel.indexOf(".." + (nodePath.sep === "/" ? "\\" : "/")) !== -1 ||
|
|
203
|
+
nodePath.isAbsolute(rel)) return null;
|
|
204
|
+
var safe = nodePath.join(rootResolved, rel);
|
|
205
|
+
// Defense-in-depth lexical containment alongside the join sanitizer.
|
|
206
|
+
if (safe !== rootResolved &&
|
|
207
|
+
!safe.startsWith(rootResolved + nodePath.sep)) return null;
|
|
208
|
+
return safe;
|
|
209
|
+
}
|
|
210
|
+
|
|
145
211
|
// Module-level metadata cache. Entries hold:
|
|
146
212
|
// { mtimeMs, size, etag, integrity, lastModified, sha3Hex, absPath }
|
|
147
213
|
// Invalidated on mtime / size change.
|
|
148
214
|
var _metaCache = new Map();
|
|
149
215
|
|
|
150
|
-
|
|
216
|
+
// _readMeta — stat + hash a file for the conditional-request + SRI
|
|
217
|
+
// surface. `root` is passed alongside the candidate so the
|
|
218
|
+
// path-traversal barrier (CWE-22) is re-asserted at THIS sink: the
|
|
219
|
+
// value handed to fs.stat / fs.createReadStream is the confined return
|
|
220
|
+
// of `_assertInsideRoot`, not the request-derived candidate. Returns
|
|
221
|
+
// null when the candidate escapes root, is not a regular file, or
|
|
222
|
+
// cannot be read.
|
|
223
|
+
async function _readMeta(root, candidate) {
|
|
224
|
+
var absPath = _assertInsideRoot(root, candidate);
|
|
225
|
+
if (!absPath) return null;
|
|
226
|
+
|
|
151
227
|
var stat;
|
|
152
228
|
try { stat = await fsp.stat(absPath); }
|
|
153
229
|
catch (_e) { return null; }
|
|
@@ -164,10 +240,9 @@ async function _readMeta(absPath) {
|
|
|
164
240
|
var sri = nodeCrypto.createHash("sha384");
|
|
165
241
|
var sha3 = nodeCrypto.createHash("sha3-512");
|
|
166
242
|
await new Promise(function (resolve, reject) {
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
// root-prefix
|
|
170
|
-
// Callers cannot reach `_readMeta` with an unvalidated path.
|
|
243
|
+
// The path handed to createReadStream is the confined output of
|
|
244
|
+
// `_assertInsideRoot(root, candidate)` above (lexical resolve +
|
|
245
|
+
// root-prefix containment), not the request-derived candidate.
|
|
171
246
|
var s = nodeFs.createReadStream(absPath);
|
|
172
247
|
s.on("data", function (chunk) { sri.update(chunk); sha3.update(chunk); });
|
|
173
248
|
s.on("end", resolve);
|
|
@@ -192,10 +267,16 @@ async function _readMeta(absPath) {
|
|
|
192
267
|
function _resolveSafe(root, requestedPath) {
|
|
193
268
|
if (typeof requestedPath !== "string" || requestedPath.length === 0) return null;
|
|
194
269
|
if (requestedPath.indexOf("\0") !== -1) return null;
|
|
195
|
-
|
|
270
|
+
// Anchor the request path inside root with a leading "." so an
|
|
271
|
+
// absolute request (`/c:/windows`, `//host/share`, `/etc/passwd`)
|
|
272
|
+
// resolves as a same-named child of root rather than smuggling a
|
|
273
|
+
// fresh root; the containment barrier then proves the result stays
|
|
274
|
+
// inside root, refusing any `..`-driven escape. Drive-letter / UNC /
|
|
275
|
+
// reserved-name shapes that survive the resolve are caught by the
|
|
276
|
+
// guardFilename basename gate below.
|
|
277
|
+
var resolved = _assertInsideRoot(root, nodePath.resolve(root, "." + requestedPath));
|
|
278
|
+
if (!resolved) return null;
|
|
196
279
|
var rootResolved = nodePath.resolve(root);
|
|
197
|
-
if (resolved !== rootResolved &&
|
|
198
|
-
!resolved.startsWith(rootResolved + nodePath.sep)) return null;
|
|
199
280
|
|
|
200
281
|
// Symlink-escape defense — the lexical resolve above only sees the
|
|
201
282
|
// requested path tokens; a symlink anywhere along `resolved` can
|
|
@@ -486,6 +567,15 @@ function _validateCreateOpts(opts) {
|
|
|
486
567
|
validateOpts.optionalBoolean(opts.auditFailures, "staticServe.create: auditFailures", StaticServeError);
|
|
487
568
|
validateOpts.optionalBoolean(opts.safeAttachmentForRiskyMimes,
|
|
488
569
|
"staticServe.create: safeAttachmentForRiskyMimes", StaticServeError);
|
|
570
|
+
// mountType — config-time enum. A typo ("usercontent", "uploads")
|
|
571
|
+
// would silently fall back to the curated default and serve untrusted
|
|
572
|
+
// HTML inline, so THROW at boot rather than mis-type the mount.
|
|
573
|
+
if (opts.mountType !== undefined &&
|
|
574
|
+
opts.mountType !== "curated" && opts.mountType !== "user-content") {
|
|
575
|
+
throw _err("BAD_OPT",
|
|
576
|
+
"staticServe.create: mountType must be 'curated' (default) or " +
|
|
577
|
+
"'user-content'; got " + JSON.stringify(opts.mountType));
|
|
578
|
+
}
|
|
489
579
|
validateOpts.optionalBoolean(opts.forceAttachmentForNonText,
|
|
490
580
|
"staticServe.create: forceAttachmentForNonText", StaticServeError);
|
|
491
581
|
validateOpts.optionalBoolean(opts.safeRenderSvg,
|
|
@@ -593,12 +683,18 @@ function _writeError(res, status, code, message, headers) {
|
|
|
593
683
|
void code;
|
|
594
684
|
}
|
|
595
685
|
|
|
596
|
-
// integrity() — module-level helper, kept for compat with the v0.6 SRI
|
|
686
|
+
// integrity() — module-level helper, kept for compat with the v0.6 SRI
|
|
687
|
+
// use. Operates on an operator-supplied absolute path (a config/library
|
|
688
|
+
// call, not the request path): the file's own resolved path is both the
|
|
689
|
+
// confinement root and the candidate, so `_readMeta` re-applies the same
|
|
690
|
+
// barrier shape every other sink uses without narrowing the legitimate
|
|
691
|
+
// surface (any single file the operator names).
|
|
597
692
|
async function integrity(absPath) {
|
|
598
693
|
if (typeof absPath !== "string" || absPath.length === 0) {
|
|
599
694
|
throw _err("BAD_OPT", "staticServe.integrity: absPath must be a non-empty string");
|
|
600
695
|
}
|
|
601
|
-
var
|
|
696
|
+
var resolved = nodePath.resolve(absPath);
|
|
697
|
+
var meta = await _readMeta(resolved, resolved);
|
|
602
698
|
if (!meta) throw _err("NOT_FOUND", "staticServe.integrity: file not found: " + absPath);
|
|
603
699
|
return meta.integrity;
|
|
604
700
|
}
|
|
@@ -617,7 +713,7 @@ function create(opts) {
|
|
|
617
713
|
"maxBytesPerActorPerWindowMs", "maxBytesAllActorsPerWindowMs",
|
|
618
714
|
"bandwidthWindowMs", "maxConcurrentDownloadsPerActor", "maxIdleMs",
|
|
619
715
|
"contentSafety", "contentSafetyDisabledReason",
|
|
620
|
-
"forceAttachmentForNonText", "safeRenderSvg", "safeRenderPdf",
|
|
716
|
+
"mountType", "forceAttachmentForNonText", "safeRenderSvg", "safeRenderPdf",
|
|
621
717
|
], "staticServe.create");
|
|
622
718
|
_validateCreateOpts(opts);
|
|
623
719
|
var cfg = validateOpts.applyDefaults(opts, DEFAULTS);
|
|
@@ -671,7 +767,16 @@ function create(opts) {
|
|
|
671
767
|
var auditFailures = cfg.auditFailures;
|
|
672
768
|
var acceptRanges = cfg.acceptRanges;
|
|
673
769
|
var safeAttachment = !!cfg.safeAttachmentForRiskyMimes;
|
|
674
|
-
|
|
770
|
+
// forceAttachmentForNonText default follows mountType (v0.15.0): a
|
|
771
|
+
// mount TYPED "user-content" forces risky inline MIMEs to download by
|
|
772
|
+
// default (stored-XSS defense for untrusted uploads); a "curated" mount
|
|
773
|
+
// keeps inline render. An explicit forceAttachmentForNonText overrides
|
|
774
|
+
// the mountType-derived default either way. The conditional flip never
|
|
775
|
+
// blindly force-attaches a curated asset dir.
|
|
776
|
+
var mountType = opts.mountType || "curated";
|
|
777
|
+
var forceAttachmentForNonText = opts.forceAttachmentForNonText !== undefined
|
|
778
|
+
? !!opts.forceAttachmentForNonText
|
|
779
|
+
: (mountType === "user-content");
|
|
675
780
|
var allowSvgRender = cfg.safeRenderSvg !== false;
|
|
676
781
|
var allowPdfRender = !!cfg.safeRenderPdf;
|
|
677
782
|
var perActorCap = cfg.maxBytesPerActorPerWindowMs;
|
|
@@ -736,8 +841,13 @@ function create(opts) {
|
|
|
736
841
|
|
|
737
842
|
async function _checkMimeAllowlist(absPath, meta) {
|
|
738
843
|
if (allowedFileTypes.length === 0 || !fileType) return { ok: true };
|
|
844
|
+
// Re-assert the root-confinement barrier at this fs read sink
|
|
845
|
+
// (CWE-22): the path passed to readFile is the confined return of
|
|
846
|
+
// `_assertInsideRoot`, not the request-derived candidate.
|
|
847
|
+
var confined = _assertInsideRoot(root, absPath);
|
|
848
|
+
if (!confined) return { ok: false, reason: "read-failed" };
|
|
739
849
|
var sample;
|
|
740
|
-
try { sample = await fsp.readFile(
|
|
850
|
+
try { sample = await fsp.readFile(confined, { flag: "r" }); }
|
|
741
851
|
catch (_e) { return { ok: false, reason: "read-failed" }; }
|
|
742
852
|
var detected = fileType.detect(sample.slice(0, C.BYTES.kib(64))) || {};
|
|
743
853
|
if (!detected.mime) return { ok: false, reason: "indeterminate" };
|
|
@@ -799,13 +909,22 @@ function create(opts) {
|
|
|
799
909
|
"Forbidden");
|
|
800
910
|
}
|
|
801
911
|
|
|
802
|
-
// Stat first to discover directory → index file.
|
|
912
|
+
// Stat first to discover directory → index file. The path handed to
|
|
913
|
+
// stat is the confined return of `_resolveSafe` above; re-assert the
|
|
914
|
+
// barrier so CodeQL sees the confinement local to this sink (CWE-22).
|
|
915
|
+
var statTarget = _assertInsideRoot(root, absPath);
|
|
916
|
+
if (!statTarget) return next();
|
|
803
917
|
var stat;
|
|
804
|
-
try { stat = await fsp.stat(
|
|
918
|
+
try { stat = await fsp.stat(statTarget); }
|
|
805
919
|
catch (_e) { return next(); }
|
|
806
920
|
if (stat.isDirectory()) {
|
|
807
921
|
if (!indexFile) return next();
|
|
808
|
-
|
|
922
|
+
// Re-confine after appending the index file — keeps every
|
|
923
|
+
// downstream sink (read-meta, content-safety open, serve stream)
|
|
924
|
+
// anchored inside root even if indexFile were ever made operator-
|
|
925
|
+
// overridable per request.
|
|
926
|
+
absPath = _assertInsideRoot(root, nodePath.join(absPath, indexFile));
|
|
927
|
+
if (!absPath) return next();
|
|
809
928
|
}
|
|
810
929
|
|
|
811
930
|
// Force-revoke (404 — opaque to clients)
|
|
@@ -833,7 +952,7 @@ function create(opts) {
|
|
|
833
952
|
"retention_blocked", "Unavailable For Legal Reasons");
|
|
834
953
|
}
|
|
835
954
|
|
|
836
|
-
var meta = await _readMeta(absPath);
|
|
955
|
+
var meta = await _readMeta(root, absPath);
|
|
837
956
|
if (!meta) return next();
|
|
838
957
|
|
|
839
958
|
// MIME allowlist (415) — checked before sending bytes so a misnamed
|
|
@@ -867,16 +986,32 @@ function create(opts) {
|
|
|
867
986
|
var ext = nodePath.extname(absPath).toLowerCase();
|
|
868
987
|
var safetyGate = contentSafety[ext];
|
|
869
988
|
if (safetyGate && typeof safetyGate.check === "function") {
|
|
870
|
-
//
|
|
871
|
-
//
|
|
872
|
-
//
|
|
873
|
-
//
|
|
874
|
-
//
|
|
875
|
-
//
|
|
989
|
+
// Single-fd read for the content-safety gate. Two defenses on
|
|
990
|
+
// one open:
|
|
991
|
+
// - CWE-22 path traversal: the open path is the confined
|
|
992
|
+
// return of `_assertInsideRoot(root, absPath)`, freshly
|
|
993
|
+
// re-derived from `nodePath.resolve(root, ...)`, not the
|
|
994
|
+
// request-derived candidate.
|
|
995
|
+
// - CWE-367 TOCTOU file-system race: the bytes the gate
|
|
996
|
+
// inspects come from THIS file descriptor — size and reads
|
|
997
|
+
// are taken from the same inode the open returned, so a path
|
|
998
|
+
// swap between the earlier directory stat and this read can't
|
|
999
|
+
// slip different bytes past the gate. O_NOFOLLOW (when the
|
|
1000
|
+
// platform defines it) additionally refuses to open the path
|
|
1001
|
+
// if its final component became a symlink after confinement.
|
|
1002
|
+
var gateConfined = _assertInsideRoot(root, absPath);
|
|
1003
|
+
if (!gateConfined) return next();
|
|
876
1004
|
var gateBuf;
|
|
877
1005
|
var gateHandle = null;
|
|
1006
|
+
var gateOpenFlags = nodeFs.constants.O_RDONLY |
|
|
1007
|
+
(nodeFs.constants.O_NOFOLLOW || 0);
|
|
878
1008
|
try {
|
|
879
|
-
|
|
1009
|
+
// Explicit owner-only mode (0o600). The flags are read-only
|
|
1010
|
+
// (O_RDONLY, no O_CREAT) so the mode is inert on disk, but
|
|
1011
|
+
// pinning it owner-only keeps this open out of the insecure-
|
|
1012
|
+
// temp-file class (CWE-377): no world/group-accessible
|
|
1013
|
+
// creation can ever ride this code path.
|
|
1014
|
+
gateHandle = await fsp.open(gateConfined, gateOpenFlags, 0o600);
|
|
880
1015
|
var gateStat = await gateHandle.stat();
|
|
881
1016
|
gateBuf = Buffer.alloc(gateStat.size);
|
|
882
1017
|
var gateRead = 0;
|
|
@@ -1151,6 +1286,18 @@ function create(opts) {
|
|
|
1151
1286
|
return;
|
|
1152
1287
|
}
|
|
1153
1288
|
|
|
1289
|
+
// Re-assert the root-confinement barrier at the serve sink (CWE-22)
|
|
1290
|
+
// BEFORE any 200/206 headers go on the wire: the path handed to
|
|
1291
|
+
// createReadStream is the confined return of `_assertInsideRoot`,
|
|
1292
|
+
// freshly re-derived from `nodePath.resolve(root, ...)`, not the
|
|
1293
|
+
// request-derived candidate. A candidate that escapes root refuses
|
|
1294
|
+
// opaquely (404) — it cannot reach the stream.
|
|
1295
|
+
var streamTarget = _assertInsideRoot(root, absPath);
|
|
1296
|
+
if (!streamTarget) {
|
|
1297
|
+
stats.failures += 1;
|
|
1298
|
+
return writeErr(res, HTTP.NOT_FOUND, "not_found", "Not Found");
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1154
1301
|
res.writeHead(status, headers);
|
|
1155
1302
|
|
|
1156
1303
|
// Acquire concurrency slot (released on stream end / error / abort).
|
|
@@ -1163,11 +1310,7 @@ function create(opts) {
|
|
|
1163
1310
|
}
|
|
1164
1311
|
|
|
1165
1312
|
var streamOpts = range ? { start: range.start, end: range.end } : {};
|
|
1166
|
-
|
|
1167
|
-
// of `_resolveSafe` (lib/static.js:181 — lexical resolve + startsWith
|
|
1168
|
-
// root-prefix check + realpath escape guard + guardFilename gate).
|
|
1169
|
-
// The request-serve path rejects with 404 before reaching this stream.
|
|
1170
|
-
var fileStream = nodeFs.createReadStream(absPath, streamOpts);
|
|
1313
|
+
var fileStream = nodeFs.createReadStream(streamTarget, streamOpts);
|
|
1171
1314
|
|
|
1172
1315
|
// Idle timeout — close the connection if the client stalls. Pattern is
|
|
1173
1316
|
// a deadline-style debounce (clearTimeout + setTimeout) tied directly
|
package/lib/subject.js
CHANGED
|
@@ -40,11 +40,22 @@ var { sha3Hash } = require("./crypto");
|
|
|
40
40
|
var cryptoField = require("./crypto-field");
|
|
41
41
|
var audit = require("./audit");
|
|
42
42
|
var cluster = require("./cluster");
|
|
43
|
+
var safeSql = require("./safe-sql");
|
|
44
|
+
var sql = require("./sql");
|
|
43
45
|
var lazyRequire = require("./lazy-require");
|
|
44
46
|
|
|
45
47
|
var db = lazyRequire(function () { return require("./db"); });
|
|
46
48
|
var legalHold = lazyRequire(function () { return require("./legal-hold"); });
|
|
47
49
|
|
|
50
|
+
// Local-SQLite framework tables for the Art. 18 restriction flag + the
|
|
51
|
+
// erasure marker. These run against the b.db() handle directly, so the
|
|
52
|
+
// b.sql builders carry { quoteName: true } to emit the quoted local name
|
|
53
|
+
// (no clusterStorage prefix rewrite on this path). The names are literals
|
|
54
|
+
// for the same reason db.js declares them as literals — they ARE the
|
|
55
|
+
// canonical local table identifiers.
|
|
56
|
+
var RESTRICTIONS_TABLE = "_blamejs_subject_restrictions"; // allow:hand-rolled-sql — canonical local table-name; passed to b.sql with quoteName
|
|
57
|
+
var ERASURES_TABLE = "_blamejs_subject_erasures"; // allow:hand-rolled-sql — canonical local table-name; passed to b.sql with quoteName
|
|
58
|
+
|
|
48
59
|
// Required acknowledgements before subject.erase will run. Operator must
|
|
49
60
|
// explicitly attest each one to confirm no statutory retention or active
|
|
50
61
|
// litigation hold blocks the deletion.
|
|
@@ -211,7 +222,7 @@ function rectify(subjectId, opts) {
|
|
|
211
222
|
rowId: opts.id,
|
|
212
223
|
requestReason: opts.reason,
|
|
213
224
|
});
|
|
214
|
-
throw new Error("subject.rectify: row not found in '" + opts.table + "'
|
|
225
|
+
throw new Error("subject.rectify: row not found in '" + opts.table + "' for _id '" + opts.id + "'");
|
|
215
226
|
}
|
|
216
227
|
|
|
217
228
|
var changedKeys = Object.keys(opts.changes);
|
|
@@ -478,7 +489,10 @@ function eraseHard(subjectId, opts) {
|
|
|
478
489
|
perTable[spec.name] = deleted;
|
|
479
490
|
// REINDEX the table so B-tree pages holding the deleted row's
|
|
480
491
|
// index entries are rebuilt — closes the erase-vacuum residual class.
|
|
481
|
-
|
|
492
|
+
// REINDEX is a sqlite maintenance verb with no b.sql builder; the
|
|
493
|
+
// table identifier is quoted through b.safeSql so the name is safe by
|
|
494
|
+
// construction (it comes from FRAMEWORK_SCHEMA / the subject-table set).
|
|
495
|
+
try { db().runSql("REINDEX " + safeSql.quoteIdentifier(spec.name, "sqlite", { allowReserved: true })); }
|
|
482
496
|
catch (_e) { /* cluster mode / unsupported dialect */ }
|
|
483
497
|
}
|
|
484
498
|
_markErased(subjectId);
|
|
@@ -536,20 +550,31 @@ function restrict(subjectId, opts) {
|
|
|
536
550
|
if (!opts || typeof opts.on !== "boolean") {
|
|
537
551
|
throw new Error("subject.restrict requires { on: true|false }");
|
|
538
552
|
}
|
|
539
|
-
var
|
|
540
|
-
"
|
|
541
|
-
|
|
553
|
+
var restrictSelBuilt = sql.select(RESTRICTIONS_TABLE, { dialect: "sqlite", quoteName: true })
|
|
554
|
+
.columns(["subjectIdHash"])
|
|
555
|
+
.where("subjectIdHash", _subjectHash(subjectId))
|
|
556
|
+
.toSql();
|
|
557
|
+
var restrictSelStmt = db().prepare(restrictSelBuilt.sql);
|
|
558
|
+
var existing = restrictSelStmt.get.apply(restrictSelStmt, restrictSelBuilt.params);
|
|
542
559
|
|
|
543
560
|
if (opts.on) {
|
|
544
561
|
if (!existing) {
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
562
|
+
var restrictInsBuilt = sql.insert(RESTRICTIONS_TABLE, { dialect: "sqlite", quoteName: true })
|
|
563
|
+
.values({
|
|
564
|
+
subjectIdHash: _subjectHash(subjectId),
|
|
565
|
+
since: Date.now(),
|
|
566
|
+
reason: opts.reason || null,
|
|
567
|
+
})
|
|
568
|
+
.toSql();
|
|
569
|
+
var restrictInsStmt = db().prepare(restrictInsBuilt.sql);
|
|
570
|
+
restrictInsStmt.run.apply(restrictInsStmt, restrictInsBuilt.params);
|
|
548
571
|
}
|
|
549
572
|
} else if (existing) {
|
|
550
|
-
|
|
551
|
-
"
|
|
552
|
-
|
|
573
|
+
var restrictDelBuilt = sql.delete(RESTRICTIONS_TABLE, { dialect: "sqlite", quoteName: true })
|
|
574
|
+
.where("subjectIdHash", _subjectHash(subjectId))
|
|
575
|
+
.toSql();
|
|
576
|
+
var restrictDelStmt = db().prepare(restrictDelBuilt.sql);
|
|
577
|
+
restrictDelStmt.run.apply(restrictDelStmt, restrictDelBuilt.params);
|
|
553
578
|
}
|
|
554
579
|
|
|
555
580
|
_writeAudit("subject.restrict", subjectId, "success", {
|
|
@@ -581,9 +606,15 @@ function restrict(subjectId, opts) {
|
|
|
581
606
|
*/
|
|
582
607
|
function isRestricted(subjectId) {
|
|
583
608
|
if (!subjectId) return false;
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
609
|
+
// Presence check — project the PK column (b.sql columns must be real
|
|
610
|
+
// identifiers, not a `SELECT 1` literal); a matched row is truthy.
|
|
611
|
+
var built = sql.select(RESTRICTIONS_TABLE, { dialect: "sqlite", quoteName: true })
|
|
612
|
+
.columns(["subjectIdHash"])
|
|
613
|
+
.where("subjectIdHash", _subjectHash(subjectId))
|
|
614
|
+
.limit(1)
|
|
615
|
+
.toSql();
|
|
616
|
+
var stmt = db().prepare(built.sql);
|
|
617
|
+
var row = stmt.get.apply(stmt, built.params);
|
|
587
618
|
return !!row;
|
|
588
619
|
}
|
|
589
620
|
|
|
@@ -629,9 +660,16 @@ function recordObjection(subjectId, opts) {
|
|
|
629
660
|
// ---- Internal helpers ----
|
|
630
661
|
|
|
631
662
|
function _markErased(subjectId) {
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
663
|
+
// "INSERT OR REPLACE" is the sqlite upsert idiom — express it portably as
|
|
664
|
+
// INSERT … ON CONFLICT(subjectIdHash) DO UPDATE SET erasedAt = EXCLUDED.erasedAt
|
|
665
|
+
// (the row is keyed by subjectIdHash; a re-erase just refreshes the timestamp).
|
|
666
|
+
var built = sql.upsert(ERASURES_TABLE, { dialect: "sqlite", quoteName: true })
|
|
667
|
+
.values({ subjectIdHash: _subjectHash(subjectId), erasedAt: Date.now() })
|
|
668
|
+
.onConflict(["subjectIdHash"])
|
|
669
|
+
.doUpdateFromExcluded(["erasedAt"])
|
|
670
|
+
.toSql();
|
|
671
|
+
var stmt = db().prepare(built.sql);
|
|
672
|
+
stmt.run.apply(stmt, built.params);
|
|
635
673
|
}
|
|
636
674
|
|
|
637
675
|
function _subjectHash(subjectId) {
|
package/lib/vault/index.js
CHANGED
|
@@ -71,6 +71,7 @@ var { boot } = require("../log");
|
|
|
71
71
|
var safeBuffer = require("../safe-buffer");
|
|
72
72
|
var safeJson = require("../safe-json");
|
|
73
73
|
var observability = require("../observability");
|
|
74
|
+
var frameworkFiles = require("../framework-files");
|
|
74
75
|
var vaultPassphraseSource = require("./passphrase-source");
|
|
75
76
|
var vaultWrap = require("./wrap");
|
|
76
77
|
var { defineClass } = require("../framework-error");
|
|
@@ -99,8 +100,8 @@ var log = boot("vault");
|
|
|
99
100
|
function resolvePaths(dataDir) {
|
|
100
101
|
return {
|
|
101
102
|
dataDir: dataDir,
|
|
102
|
-
plaintext: nodePath.join(dataDir,
|
|
103
|
-
sealed: nodePath.join(dataDir, "
|
|
103
|
+
plaintext: nodePath.join(dataDir, frameworkFiles.fileName("vaultKey")),
|
|
104
|
+
sealed: nodePath.join(dataDir, frameworkFiles.fileName("vaultKey") + ".sealed"),
|
|
104
105
|
derivedHashSalt: nodePath.join(dataDir, "vault.derived-hash-salt"),
|
|
105
106
|
derivedHashMacKey: nodePath.join(dataDir, "vault.derived-hash-mac.sealed"),
|
|
106
107
|
};
|
|
@@ -38,13 +38,14 @@
|
|
|
38
38
|
var nodeFs = require("node:fs");
|
|
39
39
|
var nodePath = require("node:path");
|
|
40
40
|
var atomicFile = require("../atomic-file");
|
|
41
|
+
var frameworkFiles = require("../framework-files");
|
|
41
42
|
var vaultWrap = require("./wrap");
|
|
42
43
|
var { defineClass } = require("../framework-error");
|
|
43
44
|
|
|
44
45
|
var VaultPassphraseError = defineClass("VaultPassphraseError", { alwaysPermanent: true });
|
|
45
46
|
|
|
46
|
-
var PLAINTEXT_NAME =
|
|
47
|
-
var SEALED_NAME = "
|
|
47
|
+
var PLAINTEXT_NAME = frameworkFiles.fileName("vaultKey");
|
|
48
|
+
var SEALED_NAME = frameworkFiles.fileName("vaultKey") + ".sealed";
|
|
48
49
|
|
|
49
50
|
function _paths(dataDir) {
|
|
50
51
|
return {
|