@blamejs/core 0.6.13 → 0.6.20

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.
@@ -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
+ };
@@ -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
@@ -670,6 +671,71 @@ function _requestSingle(opts) {
670
671
  return Promise.reject(e);
671
672
  }
672
673
 
674
+ // Optional outbound destination allowlist. When opts.allowedHosts
675
+ // is set, only URLs whose hostname is on the list are permitted.
676
+ // Layer above safeUrl (scheme/userinfo gate) and above ssrfGuard
677
+ // (IP-class gate) — operators with strict egress policies pin the
678
+ // outbound destinations the app is allowed to talk to so a
679
+ // compromised process can't reach arbitrary upstreams.
680
+ //
681
+ // Entry forms (each entry is a string OR an object):
682
+ // "api.partner.com" — exact host match
683
+ // ".partner.com" — suffix match: "api.partner.com" yes,
684
+ // "evilpartner.com" no
685
+ // "*.partner.com" — same as ".partner.com" (DNS-glob shape
686
+ // operators expect from firewall configs)
687
+ // { host: "api.x.com", methods: ["GET","HEAD"] }
688
+ // — method-restricted entry; methods omitted
689
+ // = any method
690
+ //
691
+ // A disallowed call rejects with HOST_DISALLOWED AND emits an
692
+ // audit event when opts.audit is wired (operator gets a structured
693
+ // signal that the application tried to reach somewhere it shouldn't).
694
+ if (Array.isArray(opts.allowedHosts) && opts.allowedHosts.length > 0) {
695
+ var host = u.hostname.toLowerCase();
696
+ var method = (opts.method || "GET").toUpperCase();
697
+ var ok = false;
698
+ for (var ai = 0; ai < opts.allowedHosts.length; ai++) {
699
+ var entry = opts.allowedHosts[ai];
700
+ var allow, allowedMethods = null;
701
+ if (typeof entry === "object" && entry !== null) {
702
+ allow = String(entry.host || "").toLowerCase();
703
+ if (Array.isArray(entry.methods) && entry.methods.length > 0) {
704
+ allowedMethods = entry.methods.map(function (m) { return String(m).toUpperCase(); });
705
+ }
706
+ } else {
707
+ allow = String(entry || "").toLowerCase();
708
+ }
709
+ if (allow.length === 0) continue;
710
+ // Normalise "*.x.com" to ".x.com" for the suffix match path.
711
+ if (allow.charAt(0) === "*" && allow.charAt(1) === ".") allow = allow.slice(1);
712
+ var matched = false;
713
+ if (allow.charAt(0) === ".") {
714
+ if (host === allow.slice(1) || host.endsWith(allow)) matched = true;
715
+ } else if (host === allow) {
716
+ matched = true;
717
+ }
718
+ if (!matched) continue;
719
+ if (allowedMethods !== null && allowedMethods.indexOf(method) === -1) continue;
720
+ ok = true;
721
+ break;
722
+ }
723
+ if (!ok) {
724
+ if (opts.audit && typeof opts.audit.safeEmit === "function") {
725
+ try {
726
+ opts.audit.safeEmit({
727
+ action: "system.httpclient.host_denied",
728
+ outcome: "denied",
729
+ resource: { kind: "outbound.http", id: host },
730
+ metadata: { method: method, url: opts.url, allowedHostsCount: opts.allowedHosts.length },
731
+ });
732
+ } catch (_e) { /* audit best-effort */ }
733
+ }
734
+ return Promise.reject(_makeError(opts.errorClass, "HOST_DISALLOWED",
735
+ "host '" + host + "' not in allowedHosts (method=" + method + ")", true));
736
+ }
737
+ }
738
+
673
739
  // Attach jar-derived Cookie header BEFORE the request fires; record
674
740
  // Set-Cookie response headers AFTER. Both halves run when opts.jar
675
741
  // is set; redirect-following naturally re-runs both paths per hop
@@ -702,6 +768,17 @@ function _requestSingle(opts) {
702
768
  }, u, opts);
703
769
  }
704
770
 
771
+ var proxyAgent = null;
772
+ try { proxyAgent = networkProxy.agentFor(u); } catch (_e) { proxyAgent = null; }
773
+ if (proxyAgent) {
774
+ return _requestH1({
775
+ kind: "h1",
776
+ lib: u.protocol === "https:" ? https : http,
777
+ agent: proxyAgent,
778
+ lookup: undefined,
779
+ }, u, opts);
780
+ }
781
+
705
782
  return _getTransport(u, opts, ips).then(function (transport) {
706
783
  if (transport.kind === "h2") return _requestH2(transport, u, opts);
707
784
  return _requestH1(transport, u, opts);
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ /**
3
+ * internal-sha1-hibp — SHA-1 hex digest, RESTRICTED to the
4
+ * HaveIBeenPwned k-anonymity API caller (lib/auth/password.js policy).
5
+ *
6
+ * SHA-1 is broken for collision-resistance and trivially extendable;
7
+ * the framework MUST NOT use it for storage, signing, message
8
+ * authentication, key derivation, fingerprinting, or any other
9
+ * security-relevant path. The HIBP API mandates SHA-1 for backwards
10
+ * compatibility with leaked-password corpora; that's the only reason
11
+ * this exists.
12
+ *
13
+ * Why not export from b.crypto:
14
+ * - Public exports invite cargo-cult use. A future contributor
15
+ * would search `b.crypto.sha1*` and slot it into "I just need
16
+ * a quick hash" code — re-introducing SHA-1 into a crypto-
17
+ * relevant path the framework spent every other primitive
18
+ * keeping out.
19
+ * - The single legitimate caller (auth/password.js) requires it
20
+ * for HIBP interop only. Restricting it to that module via a
21
+ * filename + comment-block gate keeps the surface honest.
22
+ *
23
+ * If a SECOND legitimate use case for SHA-1 ever emerges (operator
24
+ * needs to interop with another SHA-1-mandated API), this module
25
+ * stays internal — the new caller imports it directly. Public
26
+ * `b.crypto.sha1*` is permanently off the table.
27
+ */
28
+ var nodeCrypto = require("node:crypto");
29
+
30
+ function sha1Hex(data) {
31
+ return nodeCrypto.createHash("sha1").update(data).digest("hex");
32
+ }
33
+
34
+ module.exports = { sha1Hex: sha1Hex };
@@ -248,10 +248,13 @@ function create(opts) {
248
248
  var property = (typeof opts.property === "string" && opts.property.length > 0) ? opts.property : "cspNonce";
249
249
  var always = !!opts.always;
250
250
 
251
- // Placeholder for the cacheable-render pattern. Per-instance random
252
- // by default — multiple cspNonce instances in the same process get
253
- // different placeholders, and the literal string never appears in
254
- // source so operator content can't accidentally collide.
251
+ // Token string used by the cacheable-render pattern: templates render
252
+ // with `cspNonce: nonceMw.PLACEHOLDER`, the rendered HTML is cached,
253
+ // then nonceMw.substitute(html, req) swaps in the per-request nonce
254
+ // at serve time. Per-instance random by default — multiple cspNonce
255
+ // instances in the same process get different placeholders, and the
256
+ // literal string never appears in source so operator content can't
257
+ // accidentally collide.
255
258
  //
256
259
  // Operators with caches that persist across process restarts (Redis,
257
260
  // cluster backend) pass `opts.placeholder` to pin a stable token —
@@ -34,6 +34,7 @@ module.exports = {
34
34
  requestLog: require("./request-log").create,
35
35
  apiEncrypt: require("./api-encrypt"),
36
36
  dbRoleFor: require("./db-role-for").create,
37
+ networkAllowlist: require("./network-allowlist").create,
37
38
 
38
39
  // Module exports for advanced use (constants, raw factory access)
39
40
  _modules: {
@@ -54,5 +55,6 @@ module.exports = {
54
55
  requestLog: require("./request-log"),
55
56
  apiEncrypt: require("./api-encrypt"),
56
57
  dbRoleFor: require("./db-role-for"),
58
+ networkAllowlist: require("./network-allowlist"),
57
59
  },
58
60
  };
@@ -0,0 +1,199 @@
1
+ "use strict";
2
+ /**
3
+ * network-allowlist — restrict an opt-named set of paths to operator-
4
+ * approved CIDR ranges.
5
+ *
6
+ * Path-based admin gates (perms.require, requireAuth) prevent UNAUTHORIZED
7
+ * users from reaching sensitive routes. They do NOT prevent the route
8
+ * from being REACHABLE from the public internet — a credential leak +
9
+ * a path-only gate is full compromise. Operators with a clear admin/
10
+ * non-admin network split want a network-layer fence on top of the
11
+ * application-layer gate.
12
+ *
13
+ * The cleanest place for that fence is the reverse proxy / NACL. This
14
+ * middleware is the in-process equivalent for operators who don't have
15
+ * separate infrastructure for it (small deploys, single-process apps,
16
+ * the wiki example default).
17
+ *
18
+ * var fence = b.middleware.networkAllowlist({
19
+ * paths: ["/admin", "/admin/", "/healthz/internal"],
20
+ * allowedCidrs: ["10.0.0.0/8", "192.168.0.0/16", "::1/128"],
21
+ * trustProxy: true, // honour x-forwarded-for; default false
22
+ * denyStatus: 404, // default — reveal nothing about the gate
23
+ * denyBody: "Not Found", // default
24
+ * audit: b.audit, // default: null — emits network.gate.denied
25
+ * });
26
+ *
27
+ * router.use(fence);
28
+ *
29
+ * Behaviour:
30
+ * - The middleware is path-scoped: requests whose pathname doesn't
31
+ * start with any of `paths` pass through unchanged. Hot-path-cheap.
32
+ * - Requests on a gated path get their client IP resolved through
33
+ * b.requestHelpers.clientIp(req, { trustProxy }) — same trust
34
+ * model as every other middleware that reads client IP.
35
+ * - The IP is checked against the CIDR allowlist using
36
+ * b.ssrfGuard.cidrContains. A miss returns denyStatus + denyBody
37
+ * and audits the rejection. Default 404 hides the gate's
38
+ * existence from probes.
39
+ *
40
+ * Validation policy:
41
+ * - opts: throw at create() time on bad shape (not-array paths /
42
+ * allowedCidrs, non-CIDR strings, denyStatus outside 4xx/5xx).
43
+ * - Per-request: a request that looks malformed (no socket, no
44
+ * headers) gets denied — fail closed.
45
+ */
46
+
47
+ var lazyRequire = require("../lazy-require");
48
+ var requestHelpers = require("../request-helpers");
49
+ var ssrfGuard = require("../ssrf-guard");
50
+ var validateOpts = require("../validate-opts");
51
+ var { defineClass } = require("../framework-error");
52
+
53
+ var audit = lazyRequire(function () { return require("../audit"); });
54
+
55
+ var NetworkAllowlistError = defineClass("NetworkAllowlistError", { alwaysPermanent: true });
56
+ var _err = NetworkAllowlistError.factory;
57
+
58
+ function _validateCidr(cidr) {
59
+ // ssrfGuard.cidrContains tolerates any string at runtime (returns
60
+ // false on garbage), but the operator means business at config time
61
+ // — a typo'd CIDR silently disables that allow entry. Verify the
62
+ // shape now: <ipv4-or-ipv6>/<prefix-length>.
63
+ if (typeof cidr !== "string" || cidr.length === 0) return false;
64
+ var slash = cidr.indexOf("/");
65
+ if (slash < 1 || slash >= cidr.length - 1) return false;
66
+ var prefix = parseInt(cidr.slice(slash + 1), 10);
67
+ if (!isFinite(prefix) || prefix < 0) return false;
68
+ // Smoke-test the implementation against a known value — if it
69
+ // throws, the cidr is malformed.
70
+ try { ssrfGuard.cidrContains(cidr, "127.0.0.1"); return true; }
71
+ catch (_e) { return false; }
72
+ }
73
+
74
+ function create(opts) {
75
+ opts = opts || {};
76
+ validateOpts(opts, [
77
+ "paths", "allowedCidrs", "deniedCidrs", "trustProxy",
78
+ "denyStatus", "denyBody", "audit",
79
+ ], "middleware.networkAllowlist");
80
+
81
+ if (!Array.isArray(opts.paths) || opts.paths.length === 0) {
82
+ throw _err("BAD_OPT", "paths must be a non-empty array of pathname prefixes");
83
+ }
84
+ for (var pi = 0; pi < opts.paths.length; pi++) {
85
+ if (typeof opts.paths[pi] !== "string" || opts.paths[pi].charAt(0) !== "/") {
86
+ throw _err("BAD_OPT",
87
+ "paths[" + pi + "] must be a string starting with '/', got " + JSON.stringify(opts.paths[pi]));
88
+ }
89
+ }
90
+ if (!Array.isArray(opts.allowedCidrs) || opts.allowedCidrs.length === 0) {
91
+ throw _err("BAD_OPT", "allowedCidrs must be a non-empty array of CIDR strings");
92
+ }
93
+ for (var ci = 0; ci < opts.allowedCidrs.length; ci++) {
94
+ if (!_validateCidr(opts.allowedCidrs[ci])) {
95
+ throw _err("BAD_OPT",
96
+ "allowedCidrs[" + ci + "] is not a valid CIDR, got " + JSON.stringify(opts.allowedCidrs[ci]));
97
+ }
98
+ }
99
+ // deniedCidrs takes precedence over allowedCidrs (deny-then-allow)
100
+ // — useful when an operator wants "10.0.0.0/8 except 10.0.99.0/24".
101
+ // Default empty (no deny rules).
102
+ var deniedCidrs = Array.isArray(opts.deniedCidrs) ? opts.deniedCidrs.slice() : [];
103
+ for (var di = 0; di < deniedCidrs.length; di++) {
104
+ if (!_validateCidr(deniedCidrs[di])) {
105
+ throw _err("BAD_OPT",
106
+ "deniedCidrs[" + di + "] is not a valid CIDR, got " + JSON.stringify(deniedCidrs[di]));
107
+ }
108
+ }
109
+
110
+ var paths = opts.paths.slice();
111
+ var allowedCidrs = opts.allowedCidrs.slice();
112
+ var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
113
+ ? opts.trustProxy : false;
114
+ var denyStatus = typeof opts.denyStatus === "number" ? opts.denyStatus : 404;
115
+ if (denyStatus < 400 || denyStatus >= 600 || Math.floor(denyStatus) !== denyStatus) {
116
+ throw _err("BAD_OPT", "denyStatus must be a 4xx or 5xx integer, got " + denyStatus);
117
+ }
118
+ var denyBody = typeof opts.denyBody === "string" ? opts.denyBody : "Not Found";
119
+ var auditOn = opts.audit !== false && opts.audit != null;
120
+ var auditInstance = opts.audit === true ? null : opts.audit; // null → use lazy-required default
121
+
122
+ function _emitDeny(req, ip, route) {
123
+ if (!auditOn) return;
124
+ var sink = auditInstance || audit();
125
+ try {
126
+ sink.safeEmit({
127
+ action: "network.gate.denied",
128
+ outcome: "denied",
129
+ actor: requestHelpers.extractActorContext(req),
130
+ resource: { kind: "http.path", id: route },
131
+ reason: "ip-not-in-allowlist",
132
+ metadata: { clientIp: ip, allowedCidrs: allowedCidrs },
133
+ });
134
+ } catch (_e) { /* audit best-effort */ }
135
+ }
136
+
137
+ function _matchesPath(pathname) {
138
+ for (var i = 0; i < paths.length; i++) {
139
+ var prefix = paths[i];
140
+ if (pathname === prefix) return true;
141
+ // Boundary-aware: "/admin" matches "/admin" + "/admin/..." but
142
+ // not "/administer" — prevents accidental shadowing of
143
+ // similarly-named public routes.
144
+ if (pathname.length > prefix.length &&
145
+ pathname.indexOf(prefix) === 0 &&
146
+ (prefix.charAt(prefix.length - 1) === "/" || pathname.charAt(prefix.length) === "/")) {
147
+ return true;
148
+ }
149
+ }
150
+ return false;
151
+ }
152
+
153
+ return function networkAllowlist(req, res, next) {
154
+ var pathname = req.pathname || (req.url || "").split("?")[0];
155
+ if (!_matchesPath(pathname)) return next();
156
+
157
+ var ip = requestHelpers.clientIp(req, { trustProxy: trustProxy });
158
+ if (!ip) {
159
+ // Fail closed: a request we can't even derive an IP for shouldn't
160
+ // bypass the gate.
161
+ _emitDeny(req, "<unknown>", pathname);
162
+ res.writeHead(denyStatus, { "Content-Type": "text/plain" });
163
+ res.end(denyBody);
164
+ return;
165
+ }
166
+
167
+ // Deny-then-allow precedence: an explicit deny entry beats any
168
+ // allow entry that would otherwise match. Operators use this for
169
+ // "10.0.0.0/8 EXCEPT 10.0.99.0/24" patterns.
170
+ for (var dii = 0; dii < deniedCidrs.length; dii++) {
171
+ try {
172
+ if (ssrfGuard.cidrContains(deniedCidrs[dii], ip)) {
173
+ _emitDeny(req, ip, pathname);
174
+ res.writeHead(denyStatus, { "Content-Type": "text/plain" });
175
+ res.end(denyBody);
176
+ return;
177
+ }
178
+ } catch (_e) { /* skip malformed at runtime — caught at config */ }
179
+ }
180
+ var allowed = false;
181
+ for (var i = 0; i < allowedCidrs.length; i++) {
182
+ try {
183
+ if (ssrfGuard.cidrContains(allowedCidrs[i], ip)) { allowed = true; break; }
184
+ } catch (_e) { /* skip malformed at runtime — caught at config */ }
185
+ }
186
+ if (!allowed) {
187
+ _emitDeny(req, ip, pathname);
188
+ res.writeHead(denyStatus, { "Content-Type": "text/plain" });
189
+ res.end(denyBody);
190
+ return;
191
+ }
192
+ return next();
193
+ };
194
+ }
195
+
196
+ module.exports = {
197
+ create: create,
198
+ NetworkAllowlistError: NetworkAllowlistError,
199
+ };