@blamejs/core 0.6.13 → 0.6.21
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 +8 -0
- package/NOTICE +16 -0
- package/README.md +27 -18
- package/index.js +12 -0
- package/lib/archive.js +8 -7
- package/lib/audit.js +4 -0
- package/lib/auth/password.js +449 -4
- package/lib/bundler.js +8 -8
- package/lib/cache.js +105 -20
- package/lib/cli.js +598 -4
- package/lib/config-drift.js +309 -0
- package/lib/crypto-field.js +37 -0
- package/lib/crypto.js +8 -0
- package/lib/db-query.js +21 -2
- package/lib/db.js +32 -2
- package/lib/dual-control.js +475 -0
- package/lib/file-type.js +265 -0
- package/lib/framework-schema.js +38 -6
- package/lib/http-client-cookie-jar.js +117 -17
- package/lib/http-client.js +81 -3
- package/lib/internal-sha1-hibp.js +34 -0
- package/lib/mail.js +5 -4
- package/lib/middleware/csp-nonce.js +7 -4
- package/lib/middleware/index.js +2 -0
- package/lib/middleware/network-allowlist.js +199 -0
- package/lib/network-dns.js +564 -0
- package/lib/network-heartbeat.js +290 -0
- package/lib/network-nts.js +552 -0
- package/lib/network-proxy.js +246 -0
- package/lib/network-tls.js +326 -0
- package/lib/network.js +233 -0
- package/lib/ntp-check.js +50 -4
- package/lib/object-store/azure-blob.js +16 -42
- package/lib/pagination.js +136 -76
- package/lib/parsers/index.js +16 -2
- package/lib/parsers/safe-ini.js +273 -0
- package/lib/permissions.js +223 -9
- package/lib/pqc-agent.js +4 -4
- package/lib/retention.js +439 -0
- package/lib/security-assert.js +368 -0
- package/lib/session.js +138 -8
- package/lib/ssrf-guard.js +9 -0
- package/lib/vault/index.js +3 -3
- package/lib/vendor/MANIFEST.json +12 -0
- package/lib/vendor/common-passwords-top-10000.txt +10000 -0
- package/package.json +3 -2
- package/sbom.cyclonedx.json +61 -0
package/lib/file-type.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* file-type — magic-byte content detection.
|
|
4
|
+
*
|
|
5
|
+
* MIME on a multipart upload comes from the CLIENT — a malicious
|
|
6
|
+
* uploader can label a polyglot HTML payload as "image/png" and the
|
|
7
|
+
* Content-Type header alone won't catch it. This primitive inspects
|
|
8
|
+
* the leading bytes of a buffer against a hardcoded magic-byte
|
|
9
|
+
* registry and returns the actual format independently of the
|
|
10
|
+
* advertised MIME.
|
|
11
|
+
*
|
|
12
|
+
* var detected = b.fileType.detect(buffer);
|
|
13
|
+
* // → { mime: "image/png", extension: "png", category: "image" }
|
|
14
|
+
* // OR null when no signature matches
|
|
15
|
+
*
|
|
16
|
+
* var ok = b.fileType.assertOneOf(buffer, ["image/png", "image/jpeg", "application/pdf"]);
|
|
17
|
+
* // → throws FileTypeError if the actual format isn't in the allowlist
|
|
18
|
+
*
|
|
19
|
+
* Coverage targets the formats most likely to flow through a typical
|
|
20
|
+
* web app's upload boundary: images (PNG/JPEG/GIF/WEBP/AVIF/HEIC),
|
|
21
|
+
* documents (PDF/DOCX/XLSX/PPTX), archives (ZIP/RAR/7Z/TAR/GZ),
|
|
22
|
+
* audio/video (MP3/MP4/WEBM), and a small set of executable formats
|
|
23
|
+
* to deny on upload (PE/ELF/Mach-O). Operators with format coverage
|
|
24
|
+
* outside this list either pass an `extra` registry to extend, or
|
|
25
|
+
* fall back to libmagic via an external sandbox process.
|
|
26
|
+
*
|
|
27
|
+
* Validation policy:
|
|
28
|
+
* - detect(buffer) → returns null on bad input rather than throwing
|
|
29
|
+
* (saved-for-later analysis often runs against partial reads)
|
|
30
|
+
* - assertOneOf(buffer, allowlist[, opts]) throws on mismatch.
|
|
31
|
+
* Operator opt: `allowEmpty: true` to permit zero-length buffers
|
|
32
|
+
* (default false — empty multipart parts are usually a mistake).
|
|
33
|
+
*
|
|
34
|
+
* Out of scope (operator brings their own):
|
|
35
|
+
* - Content disarm (CDR — strip Office macros, PDF JS, etc.).
|
|
36
|
+
* CDR is genuinely hard and format-specific; operators with that
|
|
37
|
+
* requirement reach for a sandbox like dangerzone or vmray.
|
|
38
|
+
* - Polyglot file detection (a single file that is BOTH valid PDF
|
|
39
|
+
* AND valid HTML). detect() returns the first signature match;
|
|
40
|
+
* true polyglot defense needs structural validation per format.
|
|
41
|
+
* - Filename-extension validation. The framework's view is:
|
|
42
|
+
* extensions are operator-controlled metadata, magic bytes are
|
|
43
|
+
* the wire truth.
|
|
44
|
+
*/
|
|
45
|
+
var { defineClass } = require("./framework-error");
|
|
46
|
+
|
|
47
|
+
var FileTypeError = defineClass("FileTypeError", { alwaysPermanent: true });
|
|
48
|
+
var _err = FileTypeError.factory;
|
|
49
|
+
|
|
50
|
+
// Signature registry. Each entry: { name, mime, extension, category,
|
|
51
|
+
// offset, magic: Buffer | [Buffer, ...], extra?: function(buffer)→bool }.
|
|
52
|
+
// Order matters — earlier entries win on ambiguous matches (e.g. ZIP
|
|
53
|
+
// shape catches OOXML, so OOXML-specific entries come FIRST).
|
|
54
|
+
var SIGNATURES = [
|
|
55
|
+
// ---- Office Open XML (DOCX/XLSX/PPTX) ----
|
|
56
|
+
// OOXML files are ZIP archives whose central directory contains
|
|
57
|
+
// [Content_Types].xml. The cheap shape-check is the ZIP local-file
|
|
58
|
+
// header (0x50 0x4B 0x03 0x04) PLUS the filename "[Content_Types].xml"
|
|
59
|
+
// appearing within the first 256 bytes — present for every
|
|
60
|
+
// well-formed OOXML produced by Office / LibreOffice / etc.
|
|
61
|
+
{ name: "docx", mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
62
|
+
extension: "docx", category: "document",
|
|
63
|
+
offset: 0, magic: Buffer.from([0x50, 0x4B, 0x03, 0x04]),
|
|
64
|
+
extra: function (buf) {
|
|
65
|
+
var head = buf.subarray(0, Math.min(buf.length, 4096)).toString("binary");
|
|
66
|
+
return head.indexOf("word/") !== -1 || head.indexOf("[Content_Types].xml") !== -1 && head.indexOf("word") !== -1;
|
|
67
|
+
} },
|
|
68
|
+
{ name: "xlsx", mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
69
|
+
extension: "xlsx", category: "document",
|
|
70
|
+
offset: 0, magic: Buffer.from([0x50, 0x4B, 0x03, 0x04]),
|
|
71
|
+
extra: function (buf) {
|
|
72
|
+
var head = buf.subarray(0, Math.min(buf.length, 4096)).toString("binary");
|
|
73
|
+
return head.indexOf("xl/") !== -1;
|
|
74
|
+
} },
|
|
75
|
+
{ name: "pptx", mime: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
76
|
+
extension: "pptx", category: "document",
|
|
77
|
+
offset: 0, magic: Buffer.from([0x50, 0x4B, 0x03, 0x04]),
|
|
78
|
+
extra: function (buf) {
|
|
79
|
+
var head = buf.subarray(0, Math.min(buf.length, 4096)).toString("binary");
|
|
80
|
+
return head.indexOf("ppt/") !== -1;
|
|
81
|
+
} },
|
|
82
|
+
// ---- Plain ZIP (after OOXML so OOXML wins) ----
|
|
83
|
+
{ name: "zip", mime: "application/zip", extension: "zip", category: "archive",
|
|
84
|
+
offset: 0, magic: [
|
|
85
|
+
Buffer.from([0x50, 0x4B, 0x03, 0x04]), // standard local file header
|
|
86
|
+
Buffer.from([0x50, 0x4B, 0x05, 0x06]), // empty archive
|
|
87
|
+
Buffer.from([0x50, 0x4B, 0x07, 0x08]), // spanned archive
|
|
88
|
+
] },
|
|
89
|
+
|
|
90
|
+
// ---- Images ----
|
|
91
|
+
{ name: "png", mime: "image/png", extension: "png", category: "image",
|
|
92
|
+
offset: 0, magic: Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) },
|
|
93
|
+
{ name: "jpeg", mime: "image/jpeg", extension: "jpg", category: "image",
|
|
94
|
+
offset: 0, magic: Buffer.from([0xFF, 0xD8, 0xFF]) },
|
|
95
|
+
{ name: "gif", mime: "image/gif", extension: "gif", category: "image",
|
|
96
|
+
offset: 0, magic: [Buffer.from("GIF87a", "ascii"), Buffer.from("GIF89a", "ascii")] },
|
|
97
|
+
{ name: "webp", mime: "image/webp", extension: "webp", category: "image",
|
|
98
|
+
offset: 0, magic: Buffer.from("RIFF", "ascii"),
|
|
99
|
+
extra: function (buf) {
|
|
100
|
+
return buf.length >= 12 && buf.subarray(8, 12).toString("ascii") === "WEBP";
|
|
101
|
+
} },
|
|
102
|
+
{ name: "bmp", mime: "image/bmp", extension: "bmp", category: "image",
|
|
103
|
+
offset: 0, magic: Buffer.from([0x42, 0x4D]) },
|
|
104
|
+
{ name: "tiff", mime: "image/tiff", extension: "tiff", category: "image",
|
|
105
|
+
offset: 0, magic: [
|
|
106
|
+
Buffer.from([0x49, 0x49, 0x2A, 0x00]), // little-endian
|
|
107
|
+
Buffer.from([0x4D, 0x4D, 0x00, 0x2A]), // big-endian
|
|
108
|
+
] },
|
|
109
|
+
{ name: "avif", mime: "image/avif", extension: "avif", category: "image",
|
|
110
|
+
offset: 4, magic: Buffer.from("ftypavif", "ascii") },
|
|
111
|
+
{ name: "heic", mime: "image/heic", extension: "heic", category: "image",
|
|
112
|
+
offset: 4, magic: [
|
|
113
|
+
Buffer.from("ftypheic", "ascii"),
|
|
114
|
+
Buffer.from("ftypheix", "ascii"),
|
|
115
|
+
Buffer.from("ftypmif1", "ascii"),
|
|
116
|
+
Buffer.from("ftypmsf1", "ascii"),
|
|
117
|
+
] },
|
|
118
|
+
|
|
119
|
+
// ---- Documents (non-OOXML) ----
|
|
120
|
+
{ name: "pdf", mime: "application/pdf", extension: "pdf", category: "document",
|
|
121
|
+
offset: 0, magic: Buffer.from("%PDF-", "ascii") },
|
|
122
|
+
{ name: "rtf", mime: "application/rtf", extension: "rtf", category: "document",
|
|
123
|
+
offset: 0, magic: Buffer.from("{\\rtf", "ascii") },
|
|
124
|
+
// CFB (Compound File Binary) — old Office (.doc/.xls/.ppt before 2007),
|
|
125
|
+
// also MSI installers.
|
|
126
|
+
{ name: "cfb", mime: "application/x-cfb", extension: "doc", category: "document",
|
|
127
|
+
offset: 0, magic: Buffer.from([0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]) },
|
|
128
|
+
|
|
129
|
+
// ---- Archives ----
|
|
130
|
+
{ name: "rar", mime: "application/vnd.rar", extension: "rar", category: "archive",
|
|
131
|
+
offset: 0, magic: [
|
|
132
|
+
Buffer.from([0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x00]), // RAR 1.5
|
|
133
|
+
Buffer.from([0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x01, 0x00]), // RAR 5
|
|
134
|
+
] },
|
|
135
|
+
{ name: "7z", mime: "application/x-7z-compressed", extension: "7z", category: "archive",
|
|
136
|
+
offset: 0, magic: Buffer.from([0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C]) },
|
|
137
|
+
{ name: "gz", mime: "application/gzip", extension: "gz", category: "archive",
|
|
138
|
+
offset: 0, magic: Buffer.from([0x1F, 0x8B]) },
|
|
139
|
+
{ name: "bz2", mime: "application/x-bzip2", extension: "bz2", category: "archive",
|
|
140
|
+
offset: 0, magic: Buffer.from("BZh", "ascii") },
|
|
141
|
+
{ name: "xz", mime: "application/x-xz", extension: "xz", category: "archive",
|
|
142
|
+
offset: 0, magic: Buffer.from([0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00]) },
|
|
143
|
+
{ name: "tar", mime: "application/x-tar", extension: "tar", category: "archive",
|
|
144
|
+
offset: 257, magic: Buffer.from("ustar", "ascii") },
|
|
145
|
+
|
|
146
|
+
// ---- Audio / video ----
|
|
147
|
+
{ name: "mp3", mime: "audio/mpeg", extension: "mp3", category: "audio",
|
|
148
|
+
offset: 0, magic: [
|
|
149
|
+
Buffer.from([0x49, 0x44, 0x33]), // ID3v2 tag
|
|
150
|
+
Buffer.from([0xFF, 0xFB]), // MPEG audio frame (no ID3)
|
|
151
|
+
Buffer.from([0xFF, 0xF3]),
|
|
152
|
+
Buffer.from([0xFF, 0xF2]),
|
|
153
|
+
] },
|
|
154
|
+
{ name: "mp4", mime: "video/mp4", extension: "mp4", category: "video",
|
|
155
|
+
offset: 4, magic: [
|
|
156
|
+
Buffer.from("ftypisom", "ascii"),
|
|
157
|
+
Buffer.from("ftypiso2", "ascii"),
|
|
158
|
+
Buffer.from("ftypmp42", "ascii"),
|
|
159
|
+
Buffer.from("ftypM4V ", "ascii"),
|
|
160
|
+
] },
|
|
161
|
+
{ name: "webm", mime: "video/webm", extension: "webm", category: "video",
|
|
162
|
+
offset: 0, magic: Buffer.from([0x1A, 0x45, 0xDF, 0xA3]) },
|
|
163
|
+
|
|
164
|
+
// ---- Executables (operators usually DENY these on upload) ----
|
|
165
|
+
{ name: "pe", mime: "application/x-msdownload", extension: "exe", category: "executable",
|
|
166
|
+
offset: 0, magic: Buffer.from([0x4D, 0x5A]) },
|
|
167
|
+
{ name: "elf", mime: "application/x-executable", extension: "elf", category: "executable",
|
|
168
|
+
offset: 0, magic: Buffer.from([0x7F, 0x45, 0x4C, 0x46]) },
|
|
169
|
+
{ name: "macho", mime: "application/x-mach-binary", extension: "macho", category: "executable",
|
|
170
|
+
offset: 0, magic: [
|
|
171
|
+
Buffer.from([0xFE, 0xED, 0xFA, 0xCE]), // 32-bit BE
|
|
172
|
+
Buffer.from([0xFE, 0xED, 0xFA, 0xCF]), // 64-bit BE
|
|
173
|
+
Buffer.from([0xCE, 0xFA, 0xED, 0xFE]), // 32-bit LE
|
|
174
|
+
Buffer.from([0xCF, 0xFA, 0xED, 0xFE]), // 64-bit LE
|
|
175
|
+
Buffer.from([0xCA, 0xFE, 0xBA, 0xBE]), // universal binary (also Java .class)
|
|
176
|
+
] },
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
function _matchesAt(buf, offset, magic) {
|
|
180
|
+
if (buf.length < offset + magic.length) return false;
|
|
181
|
+
for (var i = 0; i < magic.length; i++) {
|
|
182
|
+
if (buf[offset + i] !== magic[i]) return false;
|
|
183
|
+
}
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function _entryMatches(entry, buf) {
|
|
188
|
+
var magics = Array.isArray(entry.magic) ? entry.magic : [entry.magic];
|
|
189
|
+
var matched = false;
|
|
190
|
+
for (var i = 0; i < magics.length; i++) {
|
|
191
|
+
if (_matchesAt(buf, entry.offset || 0, magics[i])) { matched = true; break; }
|
|
192
|
+
}
|
|
193
|
+
if (!matched) return false;
|
|
194
|
+
if (typeof entry.extra === "function") {
|
|
195
|
+
try { return !!entry.extra(buf); }
|
|
196
|
+
catch (_e) { return false; }
|
|
197
|
+
}
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function detect(buf, opts) {
|
|
202
|
+
if (!Buffer.isBuffer(buf)) {
|
|
203
|
+
if (buf instanceof Uint8Array) buf = Buffer.from(buf);
|
|
204
|
+
else return null;
|
|
205
|
+
}
|
|
206
|
+
if (buf.length === 0) return null;
|
|
207
|
+
var registry = SIGNATURES;
|
|
208
|
+
if (opts && Array.isArray(opts.extra) && opts.extra.length > 0) {
|
|
209
|
+
// Operator-extended registry: extras come FIRST so an operator can
|
|
210
|
+
// override a built-in (e.g. tighten OOXML check) without forking.
|
|
211
|
+
registry = opts.extra.concat(SIGNATURES);
|
|
212
|
+
}
|
|
213
|
+
for (var i = 0; i < registry.length; i++) {
|
|
214
|
+
var entry = registry[i];
|
|
215
|
+
if (_entryMatches(entry, buf)) {
|
|
216
|
+
return { mime: entry.mime, extension: entry.extension, category: entry.category, name: entry.name };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function assertOneOf(buf, allowlist, opts) {
|
|
223
|
+
opts = opts || {};
|
|
224
|
+
if (!Buffer.isBuffer(buf) && !(buf instanceof Uint8Array)) {
|
|
225
|
+
throw _err("BAD_INPUT", "fileType.assertOneOf: input must be a Buffer or Uint8Array, got " + typeof buf);
|
|
226
|
+
}
|
|
227
|
+
if (Buffer.isBuffer(buf) === false) buf = Buffer.from(buf);
|
|
228
|
+
if (buf.length === 0) {
|
|
229
|
+
if (opts.allowEmpty === true) return null;
|
|
230
|
+
throw _err("EMPTY", "fileType.assertOneOf: input is zero bytes");
|
|
231
|
+
}
|
|
232
|
+
if (!Array.isArray(allowlist) || allowlist.length === 0) {
|
|
233
|
+
throw _err("BAD_OPT", "fileType.assertOneOf: allowlist must be a non-empty array");
|
|
234
|
+
}
|
|
235
|
+
var detected = detect(buf, opts);
|
|
236
|
+
if (!detected) {
|
|
237
|
+
throw _err("UNKNOWN_TYPE",
|
|
238
|
+
"fileType.assertOneOf: no signature matched the leading bytes (advertised MIME cannot be trusted alone)");
|
|
239
|
+
}
|
|
240
|
+
// allowlist entries match against `mime` OR `name` OR `category` —
|
|
241
|
+
// operators can pin "image/png" specifically OR "image" for the
|
|
242
|
+
// whole image bucket.
|
|
243
|
+
var allowed = false;
|
|
244
|
+
for (var i = 0; i < allowlist.length; i++) {
|
|
245
|
+
if (allowlist[i] === detected.mime ||
|
|
246
|
+
allowlist[i] === detected.name ||
|
|
247
|
+
allowlist[i] === detected.category) {
|
|
248
|
+
allowed = true; break;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (!allowed) {
|
|
252
|
+
throw _err("DISALLOWED_TYPE",
|
|
253
|
+
"fileType.assertOneOf: detected '" + detected.mime + "' (" + detected.name +
|
|
254
|
+
", category=" + detected.category + ") not in allowlist " + JSON.stringify(allowlist));
|
|
255
|
+
}
|
|
256
|
+
return detected;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
module.exports = {
|
|
260
|
+
detect: detect,
|
|
261
|
+
assertOneOf: assertOneOf,
|
|
262
|
+
FileTypeError: FileTypeError,
|
|
263
|
+
// Internal — exposed so tests can introspect the registry shape.
|
|
264
|
+
_SIGNATURES: SIGNATURES,
|
|
265
|
+
};
|
package/lib/framework-schema.js
CHANGED
|
@@ -16,20 +16,26 @@
|
|
|
16
16
|
* _blamejs_audit_log external-db name
|
|
17
17
|
*
|
|
18
18
|
* The mapping is exposed via tableName(local) so write-dispatch code
|
|
19
|
-
* (
|
|
19
|
+
* (cluster-storage.js) uses a single name reference and the
|
|
20
|
+
* dialect-aware ensureSchema fans out the DDL to either the local-
|
|
21
|
+
* SQLite (db.js's FRAMEWORK_SCHEMA) or the external-db backend.
|
|
20
22
|
*
|
|
21
23
|
* Dialects: Postgres + SQLite. Both support CREATE TABLE IF NOT EXISTS,
|
|
22
24
|
* CREATE INDEX IF NOT EXISTS, and the same column types modulo
|
|
23
|
-
* INTEGER/BIGINT and BLOB/BYTEA differences. MySQL is not
|
|
24
|
-
* supported — operators on MySQL must
|
|
25
|
+
* INTEGER/BIGINT and BLOB/BYTEA differences. MySQL is not currently
|
|
26
|
+
* supported — operators on MySQL must use one of the supported
|
|
27
|
+
* dialects until a MySQL adapter ships.
|
|
25
28
|
*
|
|
26
29
|
* What ensureSchema does NOT do:
|
|
27
30
|
* - Migrate existing audit_log rows from local SQLite into external-db.
|
|
28
31
|
* That migration belongs to a separate operator-driven tool.
|
|
29
32
|
* - Verify chain integrity in external-db. That happens at boot via
|
|
30
|
-
* the audit module's regular verify() path
|
|
31
|
-
* - Install append-only triggers.
|
|
32
|
-
*
|
|
33
|
+
* the audit module's regular verify() path on every read.
|
|
34
|
+
* - Install append-only triggers. The framework's tamper-evidence
|
|
35
|
+
* model is the audit chain's hash linkage + SLH-DSA-signed
|
|
36
|
+
* checkpoints — triggers would add a defense-in-depth layer
|
|
37
|
+
* but they're not load-bearing for the threat model. Operators
|
|
38
|
+
* who want triggers add them per their dialect's syntax.
|
|
33
39
|
*
|
|
34
40
|
* Public API:
|
|
35
41
|
* await frameworkSchema.ensureSchema({ externalDbBackend, dialect })
|
|
@@ -114,6 +120,11 @@ var LOCAL_TO_EXTERNAL = Object.freeze({
|
|
|
114
120
|
// values, BIGINT expiresAt for ttl. Indexed on expiresAt for the
|
|
115
121
|
// periodic prune query.
|
|
116
122
|
_blamejs_cache: "_blamejs_cache",
|
|
123
|
+
// _blamejs_cache_tags — junction table for tag-based cache
|
|
124
|
+
// invalidation on the cluster backend. Composite PK
|
|
125
|
+
// (cacheKey, tag) lets a single cacheKey carry many tags;
|
|
126
|
+
// index on tag makes invalidateTag(t) a single indexed scan.
|
|
127
|
+
_blamejs_cache_tags: "_blamejs_cache_tags",
|
|
117
128
|
// _blamejs_seeders — registry of applied seed files for b.seeders
|
|
118
129
|
// (lib/seeders.js). Composite PK (env, name) lets the same filename
|
|
119
130
|
// apply per env. Mirrors the local-SQLite shape in db.js
|
|
@@ -561,6 +572,26 @@ function _cacheDDL(dialect) {
|
|
|
561
572
|
};
|
|
562
573
|
}
|
|
563
574
|
|
|
575
|
+
// _blamejs_cache_tags — tag→cacheKey junction for cluster-backend
|
|
576
|
+
// tag invalidation. b.cache.invalidateTag(t) finds matching cacheKeys
|
|
577
|
+
// via the indexed `tag` column, deletes them from _blamejs_cache, and
|
|
578
|
+
// drops the junction rows. Cleared on cache.clear() and del() too.
|
|
579
|
+
function _cacheTagsDDL(_dialect) {
|
|
580
|
+
// Junction table is TEXT-only — no dialect-specific INT / BLOB needed.
|
|
581
|
+
var name = LOCAL_TO_EXTERNAL._blamejs_cache_tags;
|
|
582
|
+
return {
|
|
583
|
+
create:
|
|
584
|
+
"CREATE TABLE IF NOT EXISTS " + name + " (" +
|
|
585
|
+
" cacheKey TEXT NOT NULL," +
|
|
586
|
+
" tag TEXT NOT NULL," +
|
|
587
|
+
" PRIMARY KEY (cacheKey, tag)" +
|
|
588
|
+
")",
|
|
589
|
+
indexes: [
|
|
590
|
+
"CREATE INDEX IF NOT EXISTS idx_" + name + "_tag ON " + name + " (tag)",
|
|
591
|
+
],
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
564
595
|
// _blamejs_break_glass_policies — column-level break-glass policy
|
|
565
596
|
// registry. One row per (table) declares which columns are
|
|
566
597
|
// glass-locked + the operator's grant rules. Sealed columns hide
|
|
@@ -658,6 +689,7 @@ async function ensureSchema(opts) {
|
|
|
658
689
|
_sessionsDDL(dialect),
|
|
659
690
|
_jobsDDL(dialect),
|
|
660
691
|
_cacheDDL(dialect),
|
|
692
|
+
_cacheTagsDDL(dialect),
|
|
661
693
|
_seedersDDL(dialect),
|
|
662
694
|
_seedersLockDDL(dialect),
|
|
663
695
|
_breakGlassPoliciesDDL(dialect),
|
|
@@ -6,24 +6,41 @@
|
|
|
6
6
|
* (login → list → mutate → logout, OAuth code-exchange → userinfo, etc.)
|
|
7
7
|
* carry the right Cookie header without operators threading it by hand.
|
|
8
8
|
* RFC 6265 attribute coverage: Domain / Path / Expires / Max-Age /
|
|
9
|
-
* HttpOnly / Secure / SameSite. Public Suffix List awareness is
|
|
10
|
-
*
|
|
11
|
-
* domains don't need it;
|
|
9
|
+
* HttpOnly / Secure / SameSite. Public Suffix List (PSL) awareness is
|
|
10
|
+
* out of scope — operators wiring jars against trusted upstream
|
|
11
|
+
* domains don't need it; for cross-eTLD safety in untrusted contexts,
|
|
12
|
+
* use a per-domain jar and validate the host against an allowlist.
|
|
12
13
|
*
|
|
13
14
|
* var jar = b.httpClient.cookieJar.create(); // in-memory
|
|
14
15
|
* await b.httpClient.request({ url: loginUrl, method: "POST", body, jar });
|
|
15
16
|
* await b.httpClient.request({ url: meUrl, jar }); // session cookie attaches
|
|
16
17
|
*
|
|
17
|
-
*
|
|
18
|
-
* b.vault.seal before it lands in the jar's store, so a memory dump or
|
|
19
|
-
* core file doesn't expose plaintext values:
|
|
18
|
+
* Three persistence modes:
|
|
20
19
|
*
|
|
21
|
-
*
|
|
20
|
+
* memory — in-process Map. Restart loses everything.
|
|
21
|
+
* vault — every cookie value is sealed via b.vault.seal before it
|
|
22
|
+
* lands in the in-process Map, so a memory dump or core
|
|
23
|
+
* file doesn't expose plaintext values:
|
|
22
24
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* b.httpClient.cookieJar.create({ persist: "vault", vault: b.vault })
|
|
26
|
+
*
|
|
27
|
+
* file — on-disk persistence at opts.file (absolute path). Loaded
|
|
28
|
+
* at create() if the file exists; flushed (debounced via
|
|
29
|
+
* opts.flushDebounceMs, default 100ms) on every set / clear
|
|
30
|
+
* / setFromSerialized. Pass `vault` alongside `file` to
|
|
31
|
+
* seal the on-disk bytes; without vault the file is
|
|
32
|
+
* plaintext JSON (operator chose the threat model).
|
|
33
|
+
*
|
|
34
|
+
* b.httpClient.cookieJar.create({
|
|
35
|
+
* persist: "file",
|
|
36
|
+
* file: "/var/lib/myapp/jar.json",
|
|
37
|
+
* vault: b.vault, // optional but recommended
|
|
38
|
+
* })
|
|
39
|
+
*
|
|
40
|
+
* The file mode survives process restart. Cluster-shared persistence
|
|
41
|
+
* (multiple nodes sharing one jar) is out of scope; operators with
|
|
42
|
+
* that need wire a custom jar via the same shape as the returned
|
|
43
|
+
* object (setFromResponse / cookieHeaderFor / getAll / etc.).
|
|
27
44
|
*
|
|
28
45
|
* Outbound filtering follows RFC 6265 §5.4:
|
|
29
46
|
* - Domain: exact-host match by default; Domain attribute allows
|
|
@@ -43,6 +60,8 @@
|
|
|
43
60
|
* }
|
|
44
61
|
*/
|
|
45
62
|
|
|
63
|
+
var fs = require("node:fs");
|
|
64
|
+
var path = require("node:path");
|
|
46
65
|
var C = require("./constants");
|
|
47
66
|
var safeUrl = require("./safe-url");
|
|
48
67
|
var { defineClass } = require("./framework-error");
|
|
@@ -51,10 +70,11 @@ var CookieJarError = defineClass("CookieJarError", { alwaysPermanent: true });
|
|
|
51
70
|
var _err = CookieJarError.factory;
|
|
52
71
|
|
|
53
72
|
var DEFAULTS = Object.freeze({
|
|
54
|
-
persist:
|
|
73
|
+
persist: "memory",
|
|
74
|
+
flushDebounceMs: 100,
|
|
55
75
|
});
|
|
56
76
|
|
|
57
|
-
var VALID_PERSIST = new Set(["memory", "vault"]);
|
|
77
|
+
var VALID_PERSIST = new Set(["memory", "vault", "file"]);
|
|
58
78
|
var VALID_SAMESITE = new Set(["Strict", "Lax", "None"]);
|
|
59
79
|
|
|
60
80
|
// ---- Set-Cookie parser ----
|
|
@@ -130,7 +150,7 @@ function create(opts) {
|
|
|
130
150
|
opts = opts || {};
|
|
131
151
|
var persist = opts.persist === undefined ? DEFAULTS.persist : opts.persist;
|
|
132
152
|
if (!VALID_PERSIST.has(persist)) {
|
|
133
|
-
throw _err("BAD_OPT", "cookieJar.create: persist must be 'memory'
|
|
153
|
+
throw _err("BAD_OPT", "cookieJar.create: persist must be 'memory' | 'vault' | 'file', got " +
|
|
134
154
|
JSON.stringify(persist));
|
|
135
155
|
}
|
|
136
156
|
var vault = opts.vault || null;
|
|
@@ -140,6 +160,22 @@ function create(opts) {
|
|
|
140
160
|
"cookieJar.create: persist: 'vault' requires opts.vault with seal/unseal (pass b.vault)");
|
|
141
161
|
}
|
|
142
162
|
}
|
|
163
|
+
var filePath = null;
|
|
164
|
+
if (persist === "file") {
|
|
165
|
+
if (typeof opts.file !== "string" || opts.file.length === 0) {
|
|
166
|
+
throw _err("BAD_OPT",
|
|
167
|
+
"cookieJar.create: persist: 'file' requires opts.file (absolute path)");
|
|
168
|
+
}
|
|
169
|
+
filePath = opts.file;
|
|
170
|
+
// Refuse relative paths so a process running in a different cwd
|
|
171
|
+
// doesn't accidentally serialize to a sibling directory.
|
|
172
|
+
if (!path.isAbsolute(filePath)) {
|
|
173
|
+
throw _err("BAD_OPT",
|
|
174
|
+
"cookieJar.create: opts.file must be an absolute path, got " + JSON.stringify(filePath));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
var flushDebounceMs = (typeof opts.flushDebounceMs === "number" && opts.flushDebounceMs >= 0)
|
|
178
|
+
? opts.flushDebounceMs : DEFAULTS.flushDebounceMs;
|
|
143
179
|
var clock = typeof opts.clock === "function" ? opts.clock : Date.now;
|
|
144
180
|
|
|
145
181
|
// Storage map keyed by `<domain>|<path>|<name>` so a (domain, path)
|
|
@@ -377,14 +413,78 @@ function create(opts) {
|
|
|
377
413
|
return rows;
|
|
378
414
|
}
|
|
379
415
|
|
|
416
|
+
// ---- File persistence ----
|
|
417
|
+
// When persist === "file", load on construct + flush on every write
|
|
418
|
+
// (debounced). On-disk format is JSON of getAll() output. If a vault
|
|
419
|
+
// is also passed, the file is sealed via vault.seal so the on-disk
|
|
420
|
+
// bytes are encrypted at rest; otherwise plaintext (operator chose
|
|
421
|
+
// the threat model by passing or omitting vault).
|
|
422
|
+
var flushTimer = null;
|
|
423
|
+
function _flushSync() {
|
|
424
|
+
if (!filePath) return;
|
|
425
|
+
var rows = getAll();
|
|
426
|
+
var serialized = JSON.stringify(rows);
|
|
427
|
+
var blob = vault ? vault.seal(serialized) : serialized;
|
|
428
|
+
fs.writeFileSync(filePath, blob);
|
|
429
|
+
}
|
|
430
|
+
function _scheduleFlush() {
|
|
431
|
+
if (!filePath) return;
|
|
432
|
+
if (flushTimer) return;
|
|
433
|
+
flushTimer = setTimeout(function () {
|
|
434
|
+
flushTimer = null;
|
|
435
|
+
try { _flushSync(); } catch (_e) { /* operator can call flush() to retry */ }
|
|
436
|
+
}, flushDebounceMs);
|
|
437
|
+
if (flushTimer.unref) flushTimer.unref();
|
|
438
|
+
}
|
|
439
|
+
function flush() {
|
|
440
|
+
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
|
441
|
+
_flushSync();
|
|
442
|
+
}
|
|
443
|
+
function close() {
|
|
444
|
+
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
|
445
|
+
if (filePath) try { _flushSync(); } catch (_e) { /* best-effort */ }
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Wrap mutating entrypoints so each write schedules a flush. The
|
|
449
|
+
// wrappers go on the returned object — the underlying function
|
|
450
|
+
// declarations stay intact so other internal callers reach them.
|
|
451
|
+
var setFromResponseAndFlush = function (reqUrl, hdr) {
|
|
452
|
+
setFromResponse(reqUrl, hdr); _scheduleFlush();
|
|
453
|
+
};
|
|
454
|
+
var clearAndFlush = function (filter) {
|
|
455
|
+
var n = clear(filter); _scheduleFlush(); return n;
|
|
456
|
+
};
|
|
457
|
+
var setFromSerializedAndFlush = function (rows) {
|
|
458
|
+
setFromSerialized(rows); _scheduleFlush();
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
// Initial load from file. Missing file is fine (first run).
|
|
462
|
+
if (filePath && fs.existsSync(filePath)) {
|
|
463
|
+
try {
|
|
464
|
+
var raw = fs.readFileSync(filePath, "utf8");
|
|
465
|
+
var serialized = vault ? vault.unseal(raw) : raw;
|
|
466
|
+
if (serialized && serialized.length > 0) {
|
|
467
|
+
var rows = JSON.parse(serialized);
|
|
468
|
+
setFromSerialized(rows);
|
|
469
|
+
}
|
|
470
|
+
} catch (e) {
|
|
471
|
+
throw _err("LOAD_FAILED",
|
|
472
|
+
"cookieJar.create: failed to load persist file '" + filePath + "': " +
|
|
473
|
+
(e.message || String(e)));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
380
477
|
return {
|
|
381
|
-
setFromResponse: setFromResponse,
|
|
478
|
+
setFromResponse: filePath ? setFromResponseAndFlush : setFromResponse,
|
|
382
479
|
cookieHeaderFor: cookieHeaderFor,
|
|
383
480
|
getAll: getAll,
|
|
384
|
-
clear: clear,
|
|
481
|
+
clear: filePath ? clearAndFlush : clear,
|
|
385
482
|
size: size,
|
|
386
|
-
setFromSerialized: setFromSerialized,
|
|
483
|
+
setFromSerialized: filePath ? setFromSerializedAndFlush : setFromSerialized,
|
|
484
|
+
flush: flush,
|
|
485
|
+
close: close,
|
|
387
486
|
persist: persist,
|
|
487
|
+
file: filePath,
|
|
388
488
|
_storeForTest: _storeForTest,
|
|
389
489
|
};
|
|
390
490
|
}
|
package/lib/http-client.js
CHANGED
|
@@ -73,6 +73,7 @@ var safeAsync = require("./safe-async");
|
|
|
73
73
|
var safeBuffer = require("./safe-buffer");
|
|
74
74
|
var safeUrl = require("./safe-url");
|
|
75
75
|
var ssrfGuard = require("./ssrf-guard");
|
|
76
|
+
var networkProxy = require("./network-proxy");
|
|
76
77
|
var { FrameworkError } = require("./framework-error");
|
|
77
78
|
|
|
78
79
|
// Per-origin transport cache. Entry is either the resolved transport
|
|
@@ -97,8 +98,8 @@ var { FrameworkError } = require("./framework-error");
|
|
|
97
98
|
// node's TLS layer — see TLS_SESSION_RESUMPTION_NOTES below).
|
|
98
99
|
var _transports = new Map();
|
|
99
100
|
|
|
100
|
-
// TLS session resumption notes — what
|
|
101
|
-
//
|
|
101
|
+
// TLS session resumption notes — what's automatic vs. what's not
|
|
102
|
+
// exposed by Node's public API:
|
|
102
103
|
//
|
|
103
104
|
// keepAlive Agent (h1) / long-lived ClientHttp2Session (h2) means
|
|
104
105
|
// the WARM-CONNECTION case is zero-handshake — better than 0-RTT.
|
|
@@ -114,7 +115,8 @@ var _transports = new Map();
|
|
|
114
115
|
//
|
|
115
116
|
// QUIC/h3 changes this calculus: 0-RTT is a first-class feature
|
|
116
117
|
// built into the protocol, with replay protection at the QUIC
|
|
117
|
-
// layer.
|
|
118
|
+
// layer. The framework's `b.httpClient` is HTTP/1.1 + HTTP/2 only;
|
|
119
|
+
// operators wanting h3 wire their own client.
|
|
118
120
|
|
|
119
121
|
// Pool tuning for the HTTP-client transport cache. Keep-alive is
|
|
120
122
|
// shorter than the standalone pqc-agent default (1s vs 30s) because
|
|
@@ -670,6 +672,71 @@ function _requestSingle(opts) {
|
|
|
670
672
|
return Promise.reject(e);
|
|
671
673
|
}
|
|
672
674
|
|
|
675
|
+
// Optional outbound destination allowlist. When opts.allowedHosts
|
|
676
|
+
// is set, only URLs whose hostname is on the list are permitted.
|
|
677
|
+
// Layer above safeUrl (scheme/userinfo gate) and above ssrfGuard
|
|
678
|
+
// (IP-class gate) — operators with strict egress policies pin the
|
|
679
|
+
// outbound destinations the app is allowed to talk to so a
|
|
680
|
+
// compromised process can't reach arbitrary upstreams.
|
|
681
|
+
//
|
|
682
|
+
// Entry forms (each entry is a string OR an object):
|
|
683
|
+
// "api.partner.com" — exact host match
|
|
684
|
+
// ".partner.com" — suffix match: "api.partner.com" yes,
|
|
685
|
+
// "evilpartner.com" no
|
|
686
|
+
// "*.partner.com" — same as ".partner.com" (DNS-glob shape
|
|
687
|
+
// operators expect from firewall configs)
|
|
688
|
+
// { host: "api.x.com", methods: ["GET","HEAD"] }
|
|
689
|
+
// — method-restricted entry; methods omitted
|
|
690
|
+
// = any method
|
|
691
|
+
//
|
|
692
|
+
// A disallowed call rejects with HOST_DISALLOWED AND emits an
|
|
693
|
+
// audit event when opts.audit is wired (operator gets a structured
|
|
694
|
+
// signal that the application tried to reach somewhere it shouldn't).
|
|
695
|
+
if (Array.isArray(opts.allowedHosts) && opts.allowedHosts.length > 0) {
|
|
696
|
+
var host = u.hostname.toLowerCase();
|
|
697
|
+
var method = (opts.method || "GET").toUpperCase();
|
|
698
|
+
var ok = false;
|
|
699
|
+
for (var ai = 0; ai < opts.allowedHosts.length; ai++) {
|
|
700
|
+
var entry = opts.allowedHosts[ai];
|
|
701
|
+
var allow, allowedMethods = null;
|
|
702
|
+
if (typeof entry === "object" && entry !== null) {
|
|
703
|
+
allow = String(entry.host || "").toLowerCase();
|
|
704
|
+
if (Array.isArray(entry.methods) && entry.methods.length > 0) {
|
|
705
|
+
allowedMethods = entry.methods.map(function (m) { return String(m).toUpperCase(); });
|
|
706
|
+
}
|
|
707
|
+
} else {
|
|
708
|
+
allow = String(entry || "").toLowerCase();
|
|
709
|
+
}
|
|
710
|
+
if (allow.length === 0) continue;
|
|
711
|
+
// Normalise "*.x.com" to ".x.com" for the suffix match path.
|
|
712
|
+
if (allow.charAt(0) === "*" && allow.charAt(1) === ".") allow = allow.slice(1);
|
|
713
|
+
var matched = false;
|
|
714
|
+
if (allow.charAt(0) === ".") {
|
|
715
|
+
if (host === allow.slice(1) || host.endsWith(allow)) matched = true;
|
|
716
|
+
} else if (host === allow) {
|
|
717
|
+
matched = true;
|
|
718
|
+
}
|
|
719
|
+
if (!matched) continue;
|
|
720
|
+
if (allowedMethods !== null && allowedMethods.indexOf(method) === -1) continue;
|
|
721
|
+
ok = true;
|
|
722
|
+
break;
|
|
723
|
+
}
|
|
724
|
+
if (!ok) {
|
|
725
|
+
if (opts.audit && typeof opts.audit.safeEmit === "function") {
|
|
726
|
+
try {
|
|
727
|
+
opts.audit.safeEmit({
|
|
728
|
+
action: "system.httpclient.host_denied",
|
|
729
|
+
outcome: "denied",
|
|
730
|
+
resource: { kind: "outbound.http", id: host },
|
|
731
|
+
metadata: { method: method, url: opts.url, allowedHostsCount: opts.allowedHosts.length },
|
|
732
|
+
});
|
|
733
|
+
} catch (_e) { /* audit best-effort */ }
|
|
734
|
+
}
|
|
735
|
+
return Promise.reject(_makeError(opts.errorClass, "HOST_DISALLOWED",
|
|
736
|
+
"host '" + host + "' not in allowedHosts (method=" + method + ")", true));
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
673
740
|
// Attach jar-derived Cookie header BEFORE the request fires; record
|
|
674
741
|
// Set-Cookie response headers AFTER. Both halves run when opts.jar
|
|
675
742
|
// is set; redirect-following naturally re-runs both paths per hop
|
|
@@ -702,6 +769,17 @@ function _requestSingle(opts) {
|
|
|
702
769
|
}, u, opts);
|
|
703
770
|
}
|
|
704
771
|
|
|
772
|
+
var proxyAgent = null;
|
|
773
|
+
try { proxyAgent = networkProxy.agentFor(u); } catch (_e) { proxyAgent = null; }
|
|
774
|
+
if (proxyAgent) {
|
|
775
|
+
return _requestH1({
|
|
776
|
+
kind: "h1",
|
|
777
|
+
lib: u.protocol === "https:" ? https : http,
|
|
778
|
+
agent: proxyAgent,
|
|
779
|
+
lookup: undefined,
|
|
780
|
+
}, u, opts);
|
|
781
|
+
}
|
|
782
|
+
|
|
705
783
|
return _getTransport(u, opts, ips).then(function (transport) {
|
|
706
784
|
if (transport.kind === "h2") return _requestH2(transport, u, opts);
|
|
707
785
|
return _requestH1(transport, u, opts);
|