@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/NOTICE +16 -0
  3. package/README.md +27 -18
  4. package/index.js +12 -0
  5. package/lib/archive.js +8 -7
  6. package/lib/audit.js +4 -0
  7. package/lib/auth/password.js +449 -4
  8. package/lib/bundler.js +8 -8
  9. package/lib/cache.js +105 -20
  10. package/lib/cli.js +598 -4
  11. package/lib/config-drift.js +309 -0
  12. package/lib/crypto-field.js +37 -0
  13. package/lib/crypto.js +8 -0
  14. package/lib/db-query.js +21 -2
  15. package/lib/db.js +32 -2
  16. package/lib/dual-control.js +475 -0
  17. package/lib/file-type.js +265 -0
  18. package/lib/framework-schema.js +38 -6
  19. package/lib/http-client-cookie-jar.js +117 -17
  20. package/lib/http-client.js +81 -3
  21. package/lib/internal-sha1-hibp.js +34 -0
  22. package/lib/mail.js +5 -4
  23. package/lib/middleware/csp-nonce.js +7 -4
  24. package/lib/middleware/index.js +2 -0
  25. package/lib/middleware/network-allowlist.js +199 -0
  26. package/lib/network-dns.js +564 -0
  27. package/lib/network-heartbeat.js +290 -0
  28. package/lib/network-nts.js +552 -0
  29. package/lib/network-proxy.js +246 -0
  30. package/lib/network-tls.js +326 -0
  31. package/lib/network.js +233 -0
  32. package/lib/ntp-check.js +50 -4
  33. package/lib/object-store/azure-blob.js +16 -42
  34. package/lib/pagination.js +136 -76
  35. package/lib/parsers/index.js +16 -2
  36. package/lib/parsers/safe-ini.js +273 -0
  37. package/lib/permissions.js +223 -9
  38. package/lib/pqc-agent.js +4 -4
  39. package/lib/retention.js +439 -0
  40. package/lib/security-assert.js +368 -0
  41. package/lib/session.js +138 -8
  42. package/lib/ssrf-guard.js +9 -0
  43. package/lib/vault/index.js +3 -3
  44. package/lib/vendor/MANIFEST.json +12 -0
  45. package/lib/vendor/common-passwords-top-10000.txt +10000 -0
  46. package/package.json +3 -2
  47. package/sbom.cyclonedx.json +61 -0
@@ -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
+ };
@@ -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
- * (next release) can use a single name reference.
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 yet
24
- * supported — operators on MySQL must wait for that adapter.
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 once dispatch lands.
31
- * - Install append-only triggers. Trigger syntax differs across
32
- * dialects and is being deferred to the next release.
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
- * deliberately deferred — operators wiring jars against trusted
11
- * domains don't need it; re-open if a real cross-eTLD bug surfaces.
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
- * Vault-encrypted persistence — every cookie value is sealed via
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
- * var jar = b.httpClient.cookieJar.create({ persist: "vault", vault: b.vault });
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
- * The jar's lifecycle is per-process — restart loses cookies regardless
24
- * of persist mode. File-backed and cluster-shared persistence are
25
- * deferred; operators wanting durability serialize via getAll() and
26
- * restore via setFromSerialized() at boot.
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: "memory",
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' or 'vault', got " +
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
  }
@@ -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 we get for free vs. what's
101
- // out of reach today:
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. We'll plumb it through when h3 lands.
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);