@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.
- package/CHANGELOG.md +7 -0
- package/NOTICE +16 -0
- package/README.md +9 -8
- package/index.js +12 -0
- package/lib/audit.js +4 -0
- package/lib/auth/password.js +449 -4
- 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.js +17 -2
- package/lib/dual-control.js +475 -0
- package/lib/file-type.js +265 -0
- package/lib/http-client.js +77 -0
- package/lib/internal-sha1-hibp.js +34 -0
- 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 +469 -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/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/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/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
|
|
@@ -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
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
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 —
|
package/lib/middleware/index.js
CHANGED
|
@@ -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
|
+
};
|