@blamejs/core 0.8.37 → 0.8.39
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 +3 -0
- package/index.js +1 -0
- package/lib/audit.js +1 -0
- package/lib/auth/ato-kill-switch.js +112 -0
- package/lib/config-drift.js +64 -4
- package/lib/middleware/body-parser.js +64 -1
- package/lib/network.js +2 -0
- package/lib/ssrf-guard.js +55 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,9 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.8.x
|
|
10
10
|
|
|
11
|
+
- v0.8.39 (2026-05-07) — operator enhancements (1/2): `b.configDrift.verifyVendorIntegrity()` re-hashes every file listed in `lib/vendor/MANIFEST.json` at boot and refuses on mismatch; `b.network.allowlist.create({allow, deny})` composes on `b.ssrfGuard` to gate per-call outbound URLs against an operator CIDR/host allow set; `b.auth.atoKillSwitch.trigger({userId, reason})` is a composite ATO incident-response workflow that destroys every session for the user, applies `b.auth.lockout`, and optionally flips `b.auth.accessLock` mode in one audited call.
|
|
12
|
+
- v0.8.38 (2026-05-07) — multipart parser refuses obsolete line folding (RFC 9112 §5.2 obs-fold) and CR/LF/NUL bytes in part-header values (RFC 9110 §5.5). Adds RFC 5987 / 8187 `filename*=UTF-8''…` extended-parameter support; the decoded value takes precedence over a legacy `filename=` companion.
|
|
13
|
+
|
|
11
14
|
- **0.8.37** (2026-05-08) — D-L family SQL/schema hygiene. **`_blamejs_audit_purge_anchor.scope` CHECK constraint** — `scope IN ('audit', 'consent')`. Pre-v0.8.37 a typo silently created a parallel anchor; the chain verifier walked the wrong anchor and missed tampering. **`personalDataCategories` vocabulary validation** — operator-supplied categories validated against the GDPR Article 9 special-category vocabulary + the framework's general categories at `db.init`. Unknown categories don't refuse (operators have legitimate custom labels) but emit a `db.personal_data_category_unknown` audit row so typos surface in regulator reviews. Allowed: `name`, `email`, `phone`, `address`, `ip`, `id-document`, `biometric`, `health`, `genetic`, `sexual-orientation`, `racial-or-ethnic-origin`, `political-opinion`, `religious-belief`, `trade-union-membership`, `criminal-record`, `financial`, `location`, `behavioral`, `device-id`, `child-data`, `education`, `employment`, `operator-defined`.
|
|
12
15
|
|
|
13
16
|
- **0.8.36** (2026-05-08) — HTTP/web G-class LOW cleanup + scope-aware bearer challenge. **WS handshake (RFC 6455 §4.1)** — `Sec-WebSocket-Key` validated as base64 of 16 random bytes (`/^[A-Za-z0-9+/]{22}==$/`). Pre-v0.8.36 only the presence was checked; truncated / arbitrary-token values flowed through. **Permissions-Policy default** — `fullscreen` flipped to `()` (deny) instead of `(self)`; operators wanting fullscreen pass an explicit override. **`b.middleware.bearerAuth` insufficient_scope (RFC 6750 §3)** — new `requiredScopes: ["scope1", "scope2"]` opt enforces operator-declared scopes. Token's `user.scope` (string, space-separated) or `user.scopes` (array) is checked; missing scopes refuse with HTTP 403 + `WWW-Authenticate: Bearer error="insufficient_scope", scope="..."`. **`b.requestHelpers.parseListHeader({strictToken: true})`** — RFC 9110 §5.6.2 token-grammar enforcement. Refuses non-token entries (anything outside `!#$%&'*+-.^_\\`|~` + alnum). **Multipart boundary validation (RFC 2046 §5.1.1)** — `_parseMultipart` refuses boundaries longer than 70 chars OR violating the `bcharsnospace` grammar. Closes the quadratic-match risk on pathological boundaries.
|
package/index.js
CHANGED
|
@@ -162,6 +162,7 @@ var auth = {
|
|
|
162
162
|
acr: require("./lib/auth/acr-vocabulary"),
|
|
163
163
|
authTime: require("./lib/auth/auth-time-tracker"),
|
|
164
164
|
accessLock: require("./lib/auth/access-lock"),
|
|
165
|
+
atoKillSwitch: require("./lib/auth/ato-kill-switch"),
|
|
165
166
|
};
|
|
166
167
|
var template = require("./lib/template");
|
|
167
168
|
var render = require("./lib/render");
|
package/lib/audit.js
CHANGED
|
@@ -247,6 +247,7 @@ var FRAMEWORK_NAMESPACES = [
|
|
|
247
247
|
"fdx", // b.fdx (fdx.bound / fdx.consent_receipt_issued)
|
|
248
248
|
"tcpa10dlc", // b.tcpa10dlc (tcpa10dlc.consent_recorded / consent_revoked)
|
|
249
249
|
"iabmspa", // b.iabMspa (iabmspa.processing_refused)
|
|
250
|
+
"vendor", // b.configDrift.verifyVendorIntegrity (vendor.integrity.verified / tampered)
|
|
250
251
|
];
|
|
251
252
|
var registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
|
|
252
253
|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.auth.atoKillSwitch — composite primitive for account-takeover
|
|
4
|
+
* incident response. Composes `b.session.destroyAllForUser` +
|
|
5
|
+
* `b.auth.lockout.lock` + (optionally) `b.auth.accessLock` mode flip
|
|
6
|
+
* into a single operator-callable workflow.
|
|
7
|
+
*
|
|
8
|
+
* Trigger conditions are operator territory (SOC alert, fraud signal,
|
|
9
|
+
* user self-report, IDS rule); this primitive is the deterministic
|
|
10
|
+
* cleanup path once the trigger fires:
|
|
11
|
+
*
|
|
12
|
+
* 1. destroy every session for the user across the cluster
|
|
13
|
+
* 2. lock the user out of new logins (b.auth.lockout)
|
|
14
|
+
* 3. emit an audit row with reason / actor for downstream forensics
|
|
15
|
+
*
|
|
16
|
+
* await b.auth.atoKillSwitch.trigger({
|
|
17
|
+
* userId: "u_42",
|
|
18
|
+
* reason: "fraud-signal: 14 failed MFA from new geo",
|
|
19
|
+
* actor: { id: req.user && req.user.id, role: req.user && req.user.role },
|
|
20
|
+
* lockout: true, // default true
|
|
21
|
+
* accessLock: "locked", // optional — flip the global access-lock mode
|
|
22
|
+
* });
|
|
23
|
+
*
|
|
24
|
+
* Returns `{ sessionsDestroyed, lockoutApplied, accessLockMode }`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
var lazyRequire = require("../lazy-require");
|
|
28
|
+
var validateOpts = require("../validate-opts");
|
|
29
|
+
var { defineClass } = require("../framework-error");
|
|
30
|
+
|
|
31
|
+
var session = lazyRequire(function () { return require("../session"); });
|
|
32
|
+
var lockout = lazyRequire(function () { return require("./lockout"); });
|
|
33
|
+
var accessLock = lazyRequire(function () { return require("./access-lock"); });
|
|
34
|
+
var audit = lazyRequire(function () { return require("../audit"); });
|
|
35
|
+
|
|
36
|
+
var AtoKillSwitchError = defineClass("AtoKillSwitchError", { alwaysPermanent: true });
|
|
37
|
+
|
|
38
|
+
async function trigger(opts) {
|
|
39
|
+
opts = opts || {};
|
|
40
|
+
validateOpts(opts, [
|
|
41
|
+
"userId", "reason", "actor", "lockout", "accessLock",
|
|
42
|
+
], "auth.atoKillSwitch.trigger");
|
|
43
|
+
|
|
44
|
+
validateOpts.requireNonEmptyString(opts.userId, "userId", AtoKillSwitchError, "auth-ato-kill-switch/missing-user-id");
|
|
45
|
+
validateOpts.requireNonEmptyString(opts.reason, "reason", AtoKillSwitchError, "auth-ato-kill-switch/missing-reason");
|
|
46
|
+
var doLockout = opts.lockout !== false;
|
|
47
|
+
var accessLockMode = typeof opts.accessLock === "string" ? opts.accessLock : null;
|
|
48
|
+
|
|
49
|
+
var sessionsDestroyed = 0;
|
|
50
|
+
try {
|
|
51
|
+
sessionsDestroyed = await session().destroyAllForUser(opts.userId);
|
|
52
|
+
} catch (e) {
|
|
53
|
+
audit().safeEmit({
|
|
54
|
+
action: "auth.ato_kill_switch.partial",
|
|
55
|
+
outcome: "failure",
|
|
56
|
+
metadata: {
|
|
57
|
+
userId: opts.userId,
|
|
58
|
+
step: "destroy-sessions",
|
|
59
|
+
reason: e && e.message,
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
throw e;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
var lockoutApplied = false;
|
|
66
|
+
if (doLockout) {
|
|
67
|
+
try {
|
|
68
|
+
await lockout().lock(opts.userId, {
|
|
69
|
+
reason: "ato-kill-switch:" + opts.reason,
|
|
70
|
+
});
|
|
71
|
+
lockoutApplied = true;
|
|
72
|
+
} catch (_e) { /* lockout is best-effort; sessions already destroyed */ }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
var modeApplied = null;
|
|
76
|
+
if (accessLockMode !== null) {
|
|
77
|
+
try {
|
|
78
|
+
var lock = accessLock();
|
|
79
|
+
if (lock && typeof lock.set === "function") {
|
|
80
|
+
await lock.set(accessLockMode, {
|
|
81
|
+
actor: opts.actor || null,
|
|
82
|
+
reason: "ato-kill-switch:" + opts.reason,
|
|
83
|
+
});
|
|
84
|
+
modeApplied = accessLockMode;
|
|
85
|
+
}
|
|
86
|
+
} catch (_e) { /* operator may not have wired global accessLock; fine */ }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
audit().safeEmit({
|
|
90
|
+
action: "auth.ato_kill_switch.triggered",
|
|
91
|
+
outcome: "success",
|
|
92
|
+
metadata: {
|
|
93
|
+
userId: opts.userId,
|
|
94
|
+
reason: opts.reason,
|
|
95
|
+
actor: opts.actor || null,
|
|
96
|
+
sessionsDestroyed: sessionsDestroyed,
|
|
97
|
+
lockoutApplied: lockoutApplied,
|
|
98
|
+
accessLockMode: modeApplied,
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
sessionsDestroyed: sessionsDestroyed,
|
|
104
|
+
lockoutApplied: lockoutApplied,
|
|
105
|
+
accessLockMode: modeApplied,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = {
|
|
110
|
+
trigger: trigger,
|
|
111
|
+
AtoKillSwitchError: AtoKillSwitchError,
|
|
112
|
+
};
|
package/lib/config-drift.js
CHANGED
|
@@ -292,10 +292,70 @@ function create(opts) {
|
|
|
292
292
|
};
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
+
// verifyVendorIntegrity — at-boot integrity check over `lib/vendor/*`.
|
|
296
|
+
// MANIFEST.json carries a sha256 digest per bundled file; we re-hash
|
|
297
|
+
// each one and refuse on mismatch. Catches a half-applied vendor
|
|
298
|
+
// refresh, a corrupted install, or an attacker who modified a
|
|
299
|
+
// vendored cjs without updating the manifest. Returns
|
|
300
|
+
// `{ ok, mismatches: [{ path, expected, actual }] }` and emits
|
|
301
|
+
// `vendor.integrity.{verified,tampered}` audit on each call.
|
|
302
|
+
function verifyVendorIntegrity(opts) {
|
|
303
|
+
opts = opts || {};
|
|
304
|
+
var libVendorDir = opts.libVendorDir || path.join(process.cwd(), "lib", "vendor");
|
|
305
|
+
var manifestPath = opts.manifestPath || path.join(libVendorDir, "MANIFEST.json");
|
|
306
|
+
var raw;
|
|
307
|
+
try { raw = fs.readFileSync(manifestPath, "utf8"); }
|
|
308
|
+
catch (_e) {
|
|
309
|
+
throw _err("VENDOR_MANIFEST_MISSING",
|
|
310
|
+
"vendor MANIFEST.json missing at " + manifestPath, true);
|
|
311
|
+
}
|
|
312
|
+
var manifest = safeJson.parse(raw);
|
|
313
|
+
if (!manifest || typeof manifest.packages !== "object") {
|
|
314
|
+
throw _err("VENDOR_MANIFEST_SHAPE",
|
|
315
|
+
"vendor MANIFEST.json missing `packages` map", true);
|
|
316
|
+
}
|
|
317
|
+
var mismatches = [];
|
|
318
|
+
var checkedCount = 0;
|
|
319
|
+
Object.keys(manifest.packages).forEach(function (pkgName) {
|
|
320
|
+
var pkg = manifest.packages[pkgName];
|
|
321
|
+
var files = (pkg && pkg.files) || {};
|
|
322
|
+
var hashes = (pkg && pkg.hashes) || {};
|
|
323
|
+
Object.keys(files).forEach(function (kind) {
|
|
324
|
+
var rel = files[kind];
|
|
325
|
+
var expected = hashes[kind];
|
|
326
|
+
if (typeof rel !== "string" || typeof expected !== "string") return;
|
|
327
|
+
var abs = path.isAbsolute(rel) ? rel : path.join(process.cwd(), rel);
|
|
328
|
+
var actual;
|
|
329
|
+
try {
|
|
330
|
+
var bytes = fs.readFileSync(abs);
|
|
331
|
+
actual = "sha256:" + require("node:crypto")
|
|
332
|
+
.createHash("sha256").update(bytes).digest("hex");
|
|
333
|
+
} catch (_e) {
|
|
334
|
+
mismatches.push({ pkg: pkgName, kind: kind, path: rel, expected: expected, actual: "<read-failed>" });
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
checkedCount += 1;
|
|
338
|
+
if (actual !== expected) {
|
|
339
|
+
mismatches.push({ pkg: pkgName, kind: kind, path: rel, expected: expected, actual: actual });
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
var ok = mismatches.length === 0;
|
|
344
|
+
try {
|
|
345
|
+
audit().safeEmit({
|
|
346
|
+
action: ok ? "vendor.integrity.verified" : "vendor.integrity.tampered",
|
|
347
|
+
outcome: ok ? "success" : "failure",
|
|
348
|
+
metadata: { checkedCount: checkedCount, mismatchCount: mismatches.length },
|
|
349
|
+
});
|
|
350
|
+
} catch (_e) { /* audit best-effort */ }
|
|
351
|
+
return { ok: ok, checkedCount: checkedCount, mismatches: mismatches };
|
|
352
|
+
}
|
|
353
|
+
|
|
295
354
|
module.exports = {
|
|
296
|
-
create:
|
|
297
|
-
|
|
355
|
+
create: create,
|
|
356
|
+
verifyVendorIntegrity: verifyVendorIntegrity,
|
|
357
|
+
ConfigDriftError: ConfigDriftError,
|
|
298
358
|
// Test-only export for hashing — operators don't need this directly.
|
|
299
|
-
_hashSnapshot:
|
|
300
|
-
_stableStringify:
|
|
359
|
+
_hashSnapshot: _hashSnapshot,
|
|
360
|
+
_stableStringify: _stableStringify,
|
|
301
361
|
};
|
|
@@ -526,27 +526,75 @@ function _sanitizeFilename(name) {
|
|
|
526
526
|
function _parseMultipartHeaders(rawHeaders) {
|
|
527
527
|
// Each line is `Header-Name: value`. Common headers: Content-Disposition,
|
|
528
528
|
// Content-Type, Content-Transfer-Encoding. Unknown headers are ignored.
|
|
529
|
+
// RFC 9112 §5.2 — line folding (obs-fold) is OBSOLETE in HTTP messages;
|
|
530
|
+
// a continuation line beginning with SP/HTAB MUST be refused. RFC 9110
|
|
531
|
+
// §5.5 — header field values MUST NOT contain CR, LF, or NUL bytes.
|
|
532
|
+
// We refuse the part outright (caller surfaces the throw as 400 + drop).
|
|
529
533
|
var lines = rawHeaders.split("\r\n");
|
|
530
534
|
var out = {};
|
|
531
535
|
for (var i = 0; i < lines.length; i++) {
|
|
532
536
|
var line = lines[i];
|
|
533
537
|
if (!line) continue;
|
|
538
|
+
var first = line.charCodeAt(0);
|
|
539
|
+
if (first === 32 || first === 9) { // allow:raw-byte-literal — SP/HTAB obs-fold sentinels
|
|
540
|
+
throw new BodyParserError(
|
|
541
|
+
"body-parser/multipart-obs-fold",
|
|
542
|
+
"multipart part header uses obsolete line folding (RFC 9112 §5.2)",
|
|
543
|
+
true, HTTP_STATUS.BAD_REQUEST
|
|
544
|
+
);
|
|
545
|
+
}
|
|
534
546
|
var idx = line.indexOf(":");
|
|
535
547
|
if (idx === -1) continue;
|
|
536
548
|
var k = line.slice(0, idx).trim().toLowerCase();
|
|
537
549
|
var v = line.slice(idx + 1).trim();
|
|
550
|
+
for (var j = 0; j < v.length; j++) {
|
|
551
|
+
var c = v.charCodeAt(j);
|
|
552
|
+
if (c === 0 || c === 10 || c === 13) { // allow:raw-byte-literal — NUL/LF/CR forbidden in field-value (RFC 9110 §5.5)
|
|
553
|
+
throw new BodyParserError(
|
|
554
|
+
"body-parser/multipart-bad-header-value",
|
|
555
|
+
"multipart part header `" + k + "` contains CR/LF/NUL (RFC 9110 §5.5)",
|
|
556
|
+
true, HTTP_STATUS.BAD_REQUEST
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
538
560
|
out[k] = v;
|
|
539
561
|
}
|
|
540
562
|
return out;
|
|
541
563
|
}
|
|
542
564
|
|
|
565
|
+
// RFC 5987 / 8187 — `filename*=UTF-8''percent%20encoded.txt` extended
|
|
566
|
+
// parameter form for non-ASCII filenames. Charset MUST be `UTF-8`
|
|
567
|
+
// (case-insensitive); we refuse other charsets to keep the decode
|
|
568
|
+
// path single-encoding. Language tag (between the two `'`s) is
|
|
569
|
+
// permitted but ignored.
|
|
570
|
+
function _decodeRfc5987(raw) {
|
|
571
|
+
if (typeof raw !== "string") return null;
|
|
572
|
+
var firstTick = raw.indexOf("'");
|
|
573
|
+
if (firstTick === -1) return null;
|
|
574
|
+
var secondTick = raw.indexOf("'", firstTick + 1);
|
|
575
|
+
if (secondTick === -1) return null;
|
|
576
|
+
var charset = raw.slice(0, firstTick).toLowerCase();
|
|
577
|
+
if (charset !== "utf-8") return null; // RFC 5987 mandated charset; refuse anything else
|
|
578
|
+
var encoded = raw.slice(secondTick + 1);
|
|
579
|
+
try {
|
|
580
|
+
return decodeURIComponent(encoded);
|
|
581
|
+
} catch (_e) {
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
543
586
|
function _parseHeaderParams(headerValue) {
|
|
544
587
|
// Content-Disposition: form-data; name="field"; filename="x.txt"
|
|
545
588
|
// Returns { _value: "form-data", name: "field", filename: "x.txt" }
|
|
589
|
+
// RFC 5987 / 8187 — when a `filename*=UTF-8''...` extended parameter
|
|
590
|
+
// is present, it takes precedence over the legacy `filename=`
|
|
591
|
+
// companion (RFC 6266 §4.3). We surface the decoded value at
|
|
592
|
+
// `filename` so downstream consumers don't need parser-aware code.
|
|
546
593
|
var out = { _value: "" };
|
|
547
594
|
if (!headerValue) return out;
|
|
548
595
|
var parts = headerValue.split(";");
|
|
549
596
|
out._value = parts[0].trim().toLowerCase();
|
|
597
|
+
var extName = null;
|
|
550
598
|
for (var i = 1; i < parts.length; i++) {
|
|
551
599
|
var p = parts[i].trim();
|
|
552
600
|
var eq = p.indexOf("=");
|
|
@@ -554,8 +602,18 @@ function _parseHeaderParams(headerValue) {
|
|
|
554
602
|
var k = p.slice(0, eq).trim().toLowerCase();
|
|
555
603
|
var v = p.slice(eq + 1).trim();
|
|
556
604
|
if (v.length >= 2 && v[0] === '"' && v[v.length - 1] === '"') v = v.slice(1, -1);
|
|
605
|
+
if (k.charAt(k.length - 1) === "*") {
|
|
606
|
+
var decoded = _decodeRfc5987(v);
|
|
607
|
+
if (decoded !== null) {
|
|
608
|
+
var bareKey = k.slice(0, -1);
|
|
609
|
+
if (bareKey === "filename") extName = decoded;
|
|
610
|
+
out[bareKey] = decoded;
|
|
611
|
+
}
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
557
614
|
out[k] = v;
|
|
558
615
|
}
|
|
616
|
+
if (extName !== null) out.filename = extName;
|
|
559
617
|
return out;
|
|
560
618
|
}
|
|
561
619
|
|
|
@@ -751,7 +809,12 @@ async function _parseMultipart(req, opts, ctParams) {
|
|
|
751
809
|
true, HTTP_STATUS.PAYLOAD_TOO_LARGE));
|
|
752
810
|
return;
|
|
753
811
|
}
|
|
754
|
-
|
|
812
|
+
try {
|
|
813
|
+
currentHeaders = _parseMultipartHeaders(pending.slice(0, headEnd).toString("utf8"));
|
|
814
|
+
} catch (parseErr) {
|
|
815
|
+
done(parseErr);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
755
818
|
pending = pending.slice(headEnd + 4);
|
|
756
819
|
// Decode Content-Disposition.
|
|
757
820
|
var cd = _parseHeaderParams(currentHeaders["content-disposition"]);
|
package/lib/network.js
CHANGED
|
@@ -7,6 +7,7 @@ var proxy = require("./network-proxy");
|
|
|
7
7
|
var trust = require("./network-tls");
|
|
8
8
|
var heartbeat = require("./network-heartbeat");
|
|
9
9
|
var smtpPolicy = require("./network-smtp-policy");
|
|
10
|
+
var ssrfGuard = require("./ssrf-guard");
|
|
10
11
|
|
|
11
12
|
var validateOpts = require("./validate-opts");
|
|
12
13
|
var lazyRequire = require("./lazy-require");
|
|
@@ -226,6 +227,7 @@ module.exports = {
|
|
|
226
227
|
dane: smtpPolicy.dane,
|
|
227
228
|
tlsRpt: smtpPolicy.tlsRpt,
|
|
228
229
|
},
|
|
230
|
+
allowlist: { create: ssrfGuard.createAllowlist },
|
|
229
231
|
socket: {
|
|
230
232
|
setDefaultNoDelay: _setSocketNoDelay,
|
|
231
233
|
setDefaultKeepAlive: _setSocketKeepAlive,
|
package/lib/ssrf-guard.js
CHANGED
|
@@ -403,10 +403,65 @@ async function checkUrl(url, opts) {
|
|
|
403
403
|
return { url: parsed, ips: ips };
|
|
404
404
|
}
|
|
405
405
|
|
|
406
|
+
// b.network.allowlist — contextual per-call egress allowlist composing
|
|
407
|
+
// on ssrfGuard. Operators describe an allowed CIDR set + denylist;
|
|
408
|
+
// the resulting `assert(url)` either resolves to the validated IP set
|
|
409
|
+
// or throws SsrfError. Distinct from `ssrfGuard.checkUrl` (which uses
|
|
410
|
+
// the framework's hard-coded private/cloud-metadata ban list) — this
|
|
411
|
+
// is for cases where the operator's deployment has SPECIFIC outbound
|
|
412
|
+
// targets and everything else should be refused.
|
|
413
|
+
//
|
|
414
|
+
// var egress = b.network.allowlist.create({
|
|
415
|
+
// allow: ["api.partner.example.com", "192.0.2.0/24"],
|
|
416
|
+
// deny: ["api.partner.example.com/admin"],
|
|
417
|
+
// });
|
|
418
|
+
// await egress.assert("https://api.partner.example.com/v1/x");
|
|
419
|
+
function createAllowlist(opts) {
|
|
420
|
+
opts = opts || {};
|
|
421
|
+
var allowList = Array.isArray(opts.allow) ? opts.allow.slice() : [];
|
|
422
|
+
var denyList = Array.isArray(opts.deny) ? opts.deny.slice() : [];
|
|
423
|
+
if (allowList.length === 0) {
|
|
424
|
+
throw new SsrfError(
|
|
425
|
+
"network.allowlist.create requires at least one entry in `allow`",
|
|
426
|
+
"ssrf-guard/empty-allowlist", {});
|
|
427
|
+
}
|
|
428
|
+
function _matches(list, hostOrIp) {
|
|
429
|
+
for (var i = 0; i < list.length; i++) {
|
|
430
|
+
var entry = list[i];
|
|
431
|
+
if (entry === hostOrIp) return true;
|
|
432
|
+
if (entry.indexOf("/") !== -1) {
|
|
433
|
+
try { if (cidrContains(entry, hostOrIp)) return true; } catch (_e) { /* ignore */ }
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
async function assertUrl(url) {
|
|
439
|
+
var parsed;
|
|
440
|
+
try { parsed = new URL(url); } // allow:raw-new-url — local URL parse for hostname extraction
|
|
441
|
+
catch (_e) {
|
|
442
|
+
throw new SsrfError("invalid URL", "ssrf-guard/bad-url", { url: url });
|
|
443
|
+
}
|
|
444
|
+
var host = parsed.hostname;
|
|
445
|
+
if (!_matches(allowList, host)) {
|
|
446
|
+
throw new SsrfError(
|
|
447
|
+
"URL host '" + host + "' not on the operator allowlist",
|
|
448
|
+
"ssrf-guard/not-on-allowlist", { url: url, host: host });
|
|
449
|
+
}
|
|
450
|
+
if (_matches(denyList, host)) {
|
|
451
|
+
throw new SsrfError(
|
|
452
|
+
"URL host '" + host + "' on the operator denylist",
|
|
453
|
+
"ssrf-guard/on-denylist", { url: url, host: host });
|
|
454
|
+
}
|
|
455
|
+
return checkUrl(parsed.toString(), { allowInternal: true });
|
|
456
|
+
}
|
|
457
|
+
return { assert: assertUrl };
|
|
458
|
+
}
|
|
459
|
+
|
|
406
460
|
module.exports = {
|
|
407
461
|
classify: classify,
|
|
408
462
|
cidrContains: cidrContains,
|
|
409
463
|
checkUrl: checkUrl,
|
|
464
|
+
createAllowlist: createAllowlist,
|
|
410
465
|
isPrivate: function (ip) { return classify(ip) === "private"; },
|
|
411
466
|
isLoopback: function (ip) { return classify(ip) === "loopback"; },
|
|
412
467
|
isLinkLocal: function (ip) { return classify(ip) === "link-local"; },
|
package/package.json
CHANGED
package/sbom.cyclonedx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:319bfdfd-c9b3-4f86-a42e-c3441466295e",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-07T16:17:54.954Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.8.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.8.39",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.8.
|
|
25
|
+
"version": "0.8.39",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.8.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.8.39",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.8.
|
|
57
|
+
"ref": "@blamejs/core@0.8.39",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|