@node9/policy-engine 2.16.0 → 2.16.2
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/dist/index.d.mts +19 -1
- package/dist/index.d.ts +19 -1
- package/dist/index.js +220 -215
- package/dist/index.mjs +220 -215
- package/package.json +1 -1
package/dist/index.d.mts
CHANGED
|
@@ -431,7 +431,25 @@ interface EgressVerdict {
|
|
|
431
431
|
declare const DEFAULT_EGRESS_ALLOWLIST: readonly string[];
|
|
432
432
|
/** Glob host match: "*" = any, "*.x" = apex x + any subdomain, else exact. */
|
|
433
433
|
declare function hostMatches(host: string, pattern: string): boolean;
|
|
434
|
-
/**
|
|
434
|
+
/**
|
|
435
|
+
* "Private" for the allowPrivate opt-in: loopback, RFC1918 and its IPv6
|
|
436
|
+
* analogue, the unspecified address, and the conventional local suffixes.
|
|
437
|
+
*
|
|
438
|
+
* The address half is classifySsrf's answer, so every spelling that file
|
|
439
|
+
* normalizes (brackets, zone id, IPv4-mapped IPv6) is one spelling here too.
|
|
440
|
+
* The old body had its own IPv4-only regexes and returned false for `[::1]`,
|
|
441
|
+
* which BLOCKED a local IPv6 dev server under allowPrivate (measured
|
|
442
|
+
* 2026-09-20; the shell extractor keeps the brackets, the declared-URL
|
|
443
|
+
* extractor strips them, and this function knew neither). Two parsers for
|
|
444
|
+
* one question is how that happens.
|
|
445
|
+
*
|
|
446
|
+
* NOT private here: link-local, multicast, the metadata endpoints. Those are
|
|
447
|
+
* the SSRF floor's tiers and allowPrivate must not be able to reach them;
|
|
448
|
+
* evaluateEgress documents that the floor runs first, and this function
|
|
449
|
+
* agrees with it rather than relying on it. CGNAT (100.64/10) is also out:
|
|
450
|
+
* a mesh-VPN peer is not everyone's private network, which is why the floor
|
|
451
|
+
* gave it its own tier; a Tailscale user lists the range in `allow`.
|
|
452
|
+
*/
|
|
435
453
|
declare function isPrivateHost(host: string): boolean;
|
|
436
454
|
/**
|
|
437
455
|
* Evaluate extracted destinations against the egress policy. Precedence per
|
package/dist/index.d.ts
CHANGED
|
@@ -431,7 +431,25 @@ interface EgressVerdict {
|
|
|
431
431
|
declare const DEFAULT_EGRESS_ALLOWLIST: readonly string[];
|
|
432
432
|
/** Glob host match: "*" = any, "*.x" = apex x + any subdomain, else exact. */
|
|
433
433
|
declare function hostMatches(host: string, pattern: string): boolean;
|
|
434
|
-
/**
|
|
434
|
+
/**
|
|
435
|
+
* "Private" for the allowPrivate opt-in: loopback, RFC1918 and its IPv6
|
|
436
|
+
* analogue, the unspecified address, and the conventional local suffixes.
|
|
437
|
+
*
|
|
438
|
+
* The address half is classifySsrf's answer, so every spelling that file
|
|
439
|
+
* normalizes (brackets, zone id, IPv4-mapped IPv6) is one spelling here too.
|
|
440
|
+
* The old body had its own IPv4-only regexes and returned false for `[::1]`,
|
|
441
|
+
* which BLOCKED a local IPv6 dev server under allowPrivate (measured
|
|
442
|
+
* 2026-09-20; the shell extractor keeps the brackets, the declared-URL
|
|
443
|
+
* extractor strips them, and this function knew neither). Two parsers for
|
|
444
|
+
* one question is how that happens.
|
|
445
|
+
*
|
|
446
|
+
* NOT private here: link-local, multicast, the metadata endpoints. Those are
|
|
447
|
+
* the SSRF floor's tiers and allowPrivate must not be able to reach them;
|
|
448
|
+
* evaluateEgress documents that the floor runs first, and this function
|
|
449
|
+
* agrees with it rather than relying on it. CGNAT (100.64/10) is also out:
|
|
450
|
+
* a mesh-VPN peer is not everyone's private network, which is why the floor
|
|
451
|
+
* gave it its own tier; a Tailscale user lists the range in `allow`.
|
|
452
|
+
*/
|
|
435
453
|
declare function isPrivateHost(host: string): boolean;
|
|
436
454
|
/**
|
|
437
455
|
* Evaluate extracted destinations against the egress policy. Precedence per
|
package/dist/index.js
CHANGED
|
@@ -3213,6 +3213,213 @@ function analyzeShellCommand(command) {
|
|
|
3213
3213
|
return { actions, paths, allTokens };
|
|
3214
3214
|
}
|
|
3215
3215
|
|
|
3216
|
+
// src/egress/ssrf.ts
|
|
3217
|
+
var SSRF_MAX_HOST = 253;
|
|
3218
|
+
function parseComponent(s) {
|
|
3219
|
+
if (!s) return null;
|
|
3220
|
+
if (/^0[xX][0-9a-fA-F]+$/.test(s)) return parseInt(s.slice(2), 16);
|
|
3221
|
+
if (s === "0") return 0;
|
|
3222
|
+
if (/^0[0-7]+$/.test(s)) return parseInt(s.slice(1), 8);
|
|
3223
|
+
if (/^[1-9][0-9]*$/.test(s)) return Number(s);
|
|
3224
|
+
return null;
|
|
3225
|
+
}
|
|
3226
|
+
function parseIpv4(input) {
|
|
3227
|
+
let s = input;
|
|
3228
|
+
if (s.endsWith(".")) s = s.slice(0, -1);
|
|
3229
|
+
if (!s) return null;
|
|
3230
|
+
const parts = s.split(".");
|
|
3231
|
+
if (parts.length > 4) return null;
|
|
3232
|
+
const vals = [];
|
|
3233
|
+
for (const p of parts) {
|
|
3234
|
+
const v = parseComponent(p);
|
|
3235
|
+
if (v === null || !Number.isFinite(v) || v < 0) return null;
|
|
3236
|
+
vals.push(v);
|
|
3237
|
+
}
|
|
3238
|
+
const n = vals.length;
|
|
3239
|
+
for (let i = 0; i < n - 1; i++) if (vals[i] > 255) return null;
|
|
3240
|
+
const last = vals[n - 1];
|
|
3241
|
+
const remainingBytes = 4 - (n - 1);
|
|
3242
|
+
const limit = Math.pow(256, remainingBytes);
|
|
3243
|
+
if (last >= limit) return null;
|
|
3244
|
+
let value = last;
|
|
3245
|
+
for (let i = 0; i < n - 1; i++) value += vals[i] * Math.pow(256, 3 - i);
|
|
3246
|
+
if (value > 4294967295) return null;
|
|
3247
|
+
return [value >>> 24 & 255, value >>> 16 & 255, value >>> 8 & 255, value & 255].join(".");
|
|
3248
|
+
}
|
|
3249
|
+
function expandIpv6(input) {
|
|
3250
|
+
const s = input.toLowerCase();
|
|
3251
|
+
if (!/^[0-9a-f:.]+$/.test(s)) return null;
|
|
3252
|
+
if ((s.match(/::/g) ?? []).length > 1) return null;
|
|
3253
|
+
let head = s;
|
|
3254
|
+
let tailV4 = null;
|
|
3255
|
+
const lastColon = s.lastIndexOf(":");
|
|
3256
|
+
const afterLast = s.slice(lastColon + 1);
|
|
3257
|
+
if (afterLast.includes(".")) {
|
|
3258
|
+
const dotted = parseIpv4(afterLast);
|
|
3259
|
+
if (!dotted) return null;
|
|
3260
|
+
const o = dotted.split(".").map(Number);
|
|
3261
|
+
tailV4 = [o[0] << 8 | o[1], o[2] << 8 | o[3]];
|
|
3262
|
+
head = s.slice(0, lastColon + 1) + "0";
|
|
3263
|
+
}
|
|
3264
|
+
const [lhs, rhs] = head.includes("::") ? head.split("::") : [head, null];
|
|
3265
|
+
const toGroups = (part) => {
|
|
3266
|
+
if (!part) return [];
|
|
3267
|
+
const out = [];
|
|
3268
|
+
for (const g of part.split(":")) {
|
|
3269
|
+
if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
|
|
3270
|
+
out.push(parseInt(g, 16));
|
|
3271
|
+
}
|
|
3272
|
+
return out;
|
|
3273
|
+
};
|
|
3274
|
+
const left = toGroups(lhs);
|
|
3275
|
+
if (left === null) return null;
|
|
3276
|
+
let right = [];
|
|
3277
|
+
if (rhs !== null) {
|
|
3278
|
+
const r = toGroups(rhs);
|
|
3279
|
+
if (r === null) return null;
|
|
3280
|
+
right = r;
|
|
3281
|
+
}
|
|
3282
|
+
if (tailV4) {
|
|
3283
|
+
if (rhs !== null) right = right.slice(0, -1).concat(tailV4);
|
|
3284
|
+
else left.splice(left.length - 1, 1, ...tailV4);
|
|
3285
|
+
}
|
|
3286
|
+
const groups = rhs === null ? left : left.concat(new Array(8 - left.length - right.length).fill(0), right);
|
|
3287
|
+
if (rhs === null && groups.length !== 8) return null;
|
|
3288
|
+
if (rhs !== null && left.length + right.length > 8) return null;
|
|
3289
|
+
if (groups.length !== 8) return null;
|
|
3290
|
+
return groups;
|
|
3291
|
+
}
|
|
3292
|
+
function compressIpv6(g) {
|
|
3293
|
+
let bestStart = -1;
|
|
3294
|
+
let bestLen = 0;
|
|
3295
|
+
let i = 0;
|
|
3296
|
+
while (i < 8) {
|
|
3297
|
+
if (g[i] !== 0) {
|
|
3298
|
+
i++;
|
|
3299
|
+
continue;
|
|
3300
|
+
}
|
|
3301
|
+
let j = i;
|
|
3302
|
+
while (j < 8 && g[j] === 0) j++;
|
|
3303
|
+
if (j - i > bestLen) {
|
|
3304
|
+
bestLen = j - i;
|
|
3305
|
+
bestStart = i;
|
|
3306
|
+
}
|
|
3307
|
+
i = j;
|
|
3308
|
+
}
|
|
3309
|
+
const hex = g.map((x) => x.toString(16));
|
|
3310
|
+
if (bestLen < 2) return hex.join(":");
|
|
3311
|
+
return hex.slice(0, bestStart).join(":") + "::" + hex.slice(bestStart + bestLen).join(":");
|
|
3312
|
+
}
|
|
3313
|
+
function normalizeIpLiteral(host) {
|
|
3314
|
+
try {
|
|
3315
|
+
if (typeof host !== "string") return null;
|
|
3316
|
+
let s = host.trim();
|
|
3317
|
+
if (!s || s.length > SSRF_MAX_HOST) return null;
|
|
3318
|
+
if (s.startsWith("[") && s.endsWith("]")) s = s.slice(1, -1);
|
|
3319
|
+
const zone = s.indexOf("%");
|
|
3320
|
+
if (zone >= 0) s = s.slice(0, zone);
|
|
3321
|
+
if (!s) return null;
|
|
3322
|
+
if (s.includes(":")) {
|
|
3323
|
+
const g = expandIpv6(s);
|
|
3324
|
+
if (!g) return null;
|
|
3325
|
+
const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 65535;
|
|
3326
|
+
if (mapped) {
|
|
3327
|
+
return [g[6] >> 8 & 255, g[6] & 255, g[7] >> 8 & 255, g[7] & 255].join(".");
|
|
3328
|
+
}
|
|
3329
|
+
return compressIpv6(g);
|
|
3330
|
+
}
|
|
3331
|
+
return parseIpv4(s);
|
|
3332
|
+
} catch {
|
|
3333
|
+
return null;
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
var METADATA_ADDRESSES = /* @__PURE__ */ new Set([
|
|
3337
|
+
"169.254.169.254",
|
|
3338
|
+
// AWS / Azure / DigitalOcean / OpenStack IMDS
|
|
3339
|
+
"169.254.170.2",
|
|
3340
|
+
// AWS ECS task role
|
|
3341
|
+
"168.63.129.16",
|
|
3342
|
+
// Azure WireServer
|
|
3343
|
+
"fd00:ec2::254",
|
|
3344
|
+
// AWS IMDS over IPv6 (inside fc00::/7, which is NOT a tier)
|
|
3345
|
+
// Alibaba Cloud IMDS. It sits INSIDE 100.64/10, so before this line it was
|
|
3346
|
+
// classified as cgnat and therefore exemptable: one ssrfAllow entry opened a
|
|
3347
|
+
// live credential endpoint. It has to be named here, above the range check,
|
|
3348
|
+
// and it is the reason relaxing cgnat is safe.
|
|
3349
|
+
"100.100.100.200"
|
|
3350
|
+
]);
|
|
3351
|
+
var STRICT_TIERS = /* @__PURE__ */ new Set(["private", "unspecified", "cgnat"]);
|
|
3352
|
+
function isStrictGatedTier(tier) {
|
|
3353
|
+
return STRICT_TIERS.has(tier);
|
|
3354
|
+
}
|
|
3355
|
+
function ssrfReason(m, asWritten) {
|
|
3356
|
+
return `Blocked: ${asWritten} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== asWritten ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.");
|
|
3357
|
+
}
|
|
3358
|
+
var METADATA_HOSTNAMES = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.goog", "metadata"]);
|
|
3359
|
+
var v4Octets = (a) => {
|
|
3360
|
+
const p = a.split(".");
|
|
3361
|
+
return p.length === 4 ? p.map(Number) : null;
|
|
3362
|
+
};
|
|
3363
|
+
function classifySsrf(host) {
|
|
3364
|
+
try {
|
|
3365
|
+
if (typeof host !== "string" || !host) return null;
|
|
3366
|
+
const lower = host.trim().toLowerCase().replace(/\.$/, "");
|
|
3367
|
+
const ip = normalizeIpLiteral(host);
|
|
3368
|
+
if (ip === null) {
|
|
3369
|
+
return METADATA_HOSTNAMES.has(lower) ? { tier: "metadata", overridable: false, kind: "hostname" } : null;
|
|
3370
|
+
}
|
|
3371
|
+
const hit = (tier, overridable) => ({
|
|
3372
|
+
tier,
|
|
3373
|
+
overridable,
|
|
3374
|
+
kind: "address",
|
|
3375
|
+
normalized: ip
|
|
3376
|
+
});
|
|
3377
|
+
if (METADATA_ADDRESSES.has(ip)) return hit("metadata", false);
|
|
3378
|
+
const o = v4Octets(ip);
|
|
3379
|
+
if (o) {
|
|
3380
|
+
if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", true);
|
|
3381
|
+
if (o[0] === 169 && o[1] === 254) return hit("link-local", false);
|
|
3382
|
+
if (o[0] >= 224 && o[0] <= 239) return hit("multicast", false);
|
|
3383
|
+
if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return hit("cgnat", true);
|
|
3384
|
+
if (o[0] === 127) return hit("private", true);
|
|
3385
|
+
if (o[0] === 10) return hit("private", true);
|
|
3386
|
+
if (o[0] === 192 && o[1] === 168) return hit("private", true);
|
|
3387
|
+
if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return hit("private", true);
|
|
3388
|
+
return null;
|
|
3389
|
+
}
|
|
3390
|
+
const g = expandIpv6(ip);
|
|
3391
|
+
if (!g) return null;
|
|
3392
|
+
if (g.every((x) => x === 0)) return hit("unspecified", true);
|
|
3393
|
+
if ((g[0] & 65472) === 65152) return hit("link-local", false);
|
|
3394
|
+
if ((g[0] & 65280) === 65280) return hit("multicast", false);
|
|
3395
|
+
if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return hit("private", true);
|
|
3396
|
+
return null;
|
|
3397
|
+
} catch {
|
|
3398
|
+
return null;
|
|
3399
|
+
}
|
|
3400
|
+
}
|
|
3401
|
+
var TIER_REASON = {
|
|
3402
|
+
metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
|
|
3403
|
+
"link-local": "a link-local address",
|
|
3404
|
+
multicast: "a multicast address",
|
|
3405
|
+
unspecified: "the unspecified address, which reaches this host",
|
|
3406
|
+
cgnat: "a carrier-grade NAT address",
|
|
3407
|
+
private: "a loopback or private address"
|
|
3408
|
+
};
|
|
3409
|
+
function ssrfFloor(tokens, opts = {}) {
|
|
3410
|
+
const exempt = new Set(
|
|
3411
|
+
(opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
|
|
3412
|
+
);
|
|
3413
|
+
for (const { token, binary } of tokens) {
|
|
3414
|
+
const m = classifySsrf(token);
|
|
3415
|
+
if (!m) continue;
|
|
3416
|
+
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
3417
|
+
if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
|
|
3418
|
+
return { ...m, host: token, binary, reason: ssrfReason(m, token) };
|
|
3419
|
+
}
|
|
3420
|
+
return null;
|
|
3421
|
+
}
|
|
3422
|
+
|
|
3216
3423
|
// src/egress/index.ts
|
|
3217
3424
|
var DEFAULT_EGRESS_ALLOWLIST = [
|
|
3218
3425
|
// node9's own control plane (api, app, dev-api, staging and the apex).
|
|
@@ -3252,15 +3459,20 @@ function matchesAny(host, patterns) {
|
|
|
3252
3459
|
for (const p of patterns) if (hostMatches(host, p)) return true;
|
|
3253
3460
|
return false;
|
|
3254
3461
|
}
|
|
3462
|
+
var PRIVATE_HOST_SUFFIXES = [".local", ".internal", ".localhost"];
|
|
3463
|
+
function isUniqueLocalV6(host) {
|
|
3464
|
+
const ip = normalizeIpLiteral(host);
|
|
3465
|
+
if (!ip || !ip.includes(":")) return false;
|
|
3466
|
+
const g = expandIpv6(ip);
|
|
3467
|
+
return g !== null && (g[0] & 65024) === 64512;
|
|
3468
|
+
}
|
|
3255
3469
|
function isPrivateHost(host) {
|
|
3256
|
-
const h = host.toLowerCase();
|
|
3257
|
-
|
|
3258
|
-
if (
|
|
3259
|
-
if (
|
|
3260
|
-
if (
|
|
3261
|
-
|
|
3262
|
-
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true;
|
|
3263
|
-
return false;
|
|
3470
|
+
const h = host.trim().toLowerCase();
|
|
3471
|
+
const m = classifySsrf(h);
|
|
3472
|
+
if (m) return m.kind === "address" && (m.tier === "private" || m.tier === "unspecified");
|
|
3473
|
+
if (h === "localhost") return true;
|
|
3474
|
+
if (PRIVATE_HOST_SUFFIXES.some((s) => h.endsWith(s))) return true;
|
|
3475
|
+
return isUniqueLocalV6(h);
|
|
3264
3476
|
}
|
|
3265
3477
|
function evaluateEgress(dests, policy) {
|
|
3266
3478
|
if (!policy.enabled) return null;
|
|
@@ -3614,213 +3826,6 @@ function parseAllSshHostsFromCommand(command) {
|
|
|
3614
3826
|
return extractAllSshHosts(tokens.slice(1));
|
|
3615
3827
|
}
|
|
3616
3828
|
|
|
3617
|
-
// src/egress/ssrf.ts
|
|
3618
|
-
var SSRF_MAX_HOST = 253;
|
|
3619
|
-
function parseComponent(s) {
|
|
3620
|
-
if (!s) return null;
|
|
3621
|
-
if (/^0[xX][0-9a-fA-F]+$/.test(s)) return parseInt(s.slice(2), 16);
|
|
3622
|
-
if (s === "0") return 0;
|
|
3623
|
-
if (/^0[0-7]+$/.test(s)) return parseInt(s.slice(1), 8);
|
|
3624
|
-
if (/^[1-9][0-9]*$/.test(s)) return Number(s);
|
|
3625
|
-
return null;
|
|
3626
|
-
}
|
|
3627
|
-
function parseIpv4(input) {
|
|
3628
|
-
let s = input;
|
|
3629
|
-
if (s.endsWith(".")) s = s.slice(0, -1);
|
|
3630
|
-
if (!s) return null;
|
|
3631
|
-
const parts = s.split(".");
|
|
3632
|
-
if (parts.length > 4) return null;
|
|
3633
|
-
const vals = [];
|
|
3634
|
-
for (const p of parts) {
|
|
3635
|
-
const v = parseComponent(p);
|
|
3636
|
-
if (v === null || !Number.isFinite(v) || v < 0) return null;
|
|
3637
|
-
vals.push(v);
|
|
3638
|
-
}
|
|
3639
|
-
const n = vals.length;
|
|
3640
|
-
for (let i = 0; i < n - 1; i++) if (vals[i] > 255) return null;
|
|
3641
|
-
const last = vals[n - 1];
|
|
3642
|
-
const remainingBytes = 4 - (n - 1);
|
|
3643
|
-
const limit = Math.pow(256, remainingBytes);
|
|
3644
|
-
if (last >= limit) return null;
|
|
3645
|
-
let value = last;
|
|
3646
|
-
for (let i = 0; i < n - 1; i++) value += vals[i] * Math.pow(256, 3 - i);
|
|
3647
|
-
if (value > 4294967295) return null;
|
|
3648
|
-
return [value >>> 24 & 255, value >>> 16 & 255, value >>> 8 & 255, value & 255].join(".");
|
|
3649
|
-
}
|
|
3650
|
-
function expandIpv6(input) {
|
|
3651
|
-
const s = input.toLowerCase();
|
|
3652
|
-
if (!/^[0-9a-f:.]+$/.test(s)) return null;
|
|
3653
|
-
if ((s.match(/::/g) ?? []).length > 1) return null;
|
|
3654
|
-
let head = s;
|
|
3655
|
-
let tailV4 = null;
|
|
3656
|
-
const lastColon = s.lastIndexOf(":");
|
|
3657
|
-
const afterLast = s.slice(lastColon + 1);
|
|
3658
|
-
if (afterLast.includes(".")) {
|
|
3659
|
-
const dotted = parseIpv4(afterLast);
|
|
3660
|
-
if (!dotted) return null;
|
|
3661
|
-
const o = dotted.split(".").map(Number);
|
|
3662
|
-
tailV4 = [o[0] << 8 | o[1], o[2] << 8 | o[3]];
|
|
3663
|
-
head = s.slice(0, lastColon + 1) + "0";
|
|
3664
|
-
}
|
|
3665
|
-
const [lhs, rhs] = head.includes("::") ? head.split("::") : [head, null];
|
|
3666
|
-
const toGroups = (part) => {
|
|
3667
|
-
if (!part) return [];
|
|
3668
|
-
const out = [];
|
|
3669
|
-
for (const g of part.split(":")) {
|
|
3670
|
-
if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
|
|
3671
|
-
out.push(parseInt(g, 16));
|
|
3672
|
-
}
|
|
3673
|
-
return out;
|
|
3674
|
-
};
|
|
3675
|
-
const left = toGroups(lhs);
|
|
3676
|
-
if (left === null) return null;
|
|
3677
|
-
let right = [];
|
|
3678
|
-
if (rhs !== null) {
|
|
3679
|
-
const r = toGroups(rhs);
|
|
3680
|
-
if (r === null) return null;
|
|
3681
|
-
right = r;
|
|
3682
|
-
}
|
|
3683
|
-
if (tailV4) {
|
|
3684
|
-
if (rhs !== null) right = right.slice(0, -1).concat(tailV4);
|
|
3685
|
-
else left.splice(left.length - 1, 1, ...tailV4);
|
|
3686
|
-
}
|
|
3687
|
-
const groups = rhs === null ? left : left.concat(new Array(8 - left.length - right.length).fill(0), right);
|
|
3688
|
-
if (rhs === null && groups.length !== 8) return null;
|
|
3689
|
-
if (rhs !== null && left.length + right.length > 8) return null;
|
|
3690
|
-
if (groups.length !== 8) return null;
|
|
3691
|
-
return groups;
|
|
3692
|
-
}
|
|
3693
|
-
function compressIpv6(g) {
|
|
3694
|
-
let bestStart = -1;
|
|
3695
|
-
let bestLen = 0;
|
|
3696
|
-
let i = 0;
|
|
3697
|
-
while (i < 8) {
|
|
3698
|
-
if (g[i] !== 0) {
|
|
3699
|
-
i++;
|
|
3700
|
-
continue;
|
|
3701
|
-
}
|
|
3702
|
-
let j = i;
|
|
3703
|
-
while (j < 8 && g[j] === 0) j++;
|
|
3704
|
-
if (j - i > bestLen) {
|
|
3705
|
-
bestLen = j - i;
|
|
3706
|
-
bestStart = i;
|
|
3707
|
-
}
|
|
3708
|
-
i = j;
|
|
3709
|
-
}
|
|
3710
|
-
const hex = g.map((x) => x.toString(16));
|
|
3711
|
-
if (bestLen < 2) return hex.join(":");
|
|
3712
|
-
return hex.slice(0, bestStart).join(":") + "::" + hex.slice(bestStart + bestLen).join(":");
|
|
3713
|
-
}
|
|
3714
|
-
function normalizeIpLiteral(host) {
|
|
3715
|
-
try {
|
|
3716
|
-
if (typeof host !== "string") return null;
|
|
3717
|
-
let s = host.trim();
|
|
3718
|
-
if (!s || s.length > SSRF_MAX_HOST) return null;
|
|
3719
|
-
if (s.startsWith("[") && s.endsWith("]")) s = s.slice(1, -1);
|
|
3720
|
-
const zone = s.indexOf("%");
|
|
3721
|
-
if (zone >= 0) s = s.slice(0, zone);
|
|
3722
|
-
if (!s) return null;
|
|
3723
|
-
if (s.includes(":")) {
|
|
3724
|
-
const g = expandIpv6(s);
|
|
3725
|
-
if (!g) return null;
|
|
3726
|
-
const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 65535;
|
|
3727
|
-
if (mapped) {
|
|
3728
|
-
return [g[6] >> 8 & 255, g[6] & 255, g[7] >> 8 & 255, g[7] & 255].join(".");
|
|
3729
|
-
}
|
|
3730
|
-
return compressIpv6(g);
|
|
3731
|
-
}
|
|
3732
|
-
return parseIpv4(s);
|
|
3733
|
-
} catch {
|
|
3734
|
-
return null;
|
|
3735
|
-
}
|
|
3736
|
-
}
|
|
3737
|
-
var METADATA_ADDRESSES = /* @__PURE__ */ new Set([
|
|
3738
|
-
"169.254.169.254",
|
|
3739
|
-
// AWS / Azure / DigitalOcean / OpenStack IMDS
|
|
3740
|
-
"169.254.170.2",
|
|
3741
|
-
// AWS ECS task role
|
|
3742
|
-
"168.63.129.16",
|
|
3743
|
-
// Azure WireServer
|
|
3744
|
-
"fd00:ec2::254",
|
|
3745
|
-
// AWS IMDS over IPv6 (inside fc00::/7, which is NOT a tier)
|
|
3746
|
-
// Alibaba Cloud IMDS. It sits INSIDE 100.64/10, so before this line it was
|
|
3747
|
-
// classified as cgnat and therefore exemptable: one ssrfAllow entry opened a
|
|
3748
|
-
// live credential endpoint. It has to be named here, above the range check,
|
|
3749
|
-
// and it is the reason relaxing cgnat is safe.
|
|
3750
|
-
"100.100.100.200"
|
|
3751
|
-
]);
|
|
3752
|
-
var STRICT_TIERS = /* @__PURE__ */ new Set(["private", "unspecified", "cgnat"]);
|
|
3753
|
-
function isStrictGatedTier(tier) {
|
|
3754
|
-
return STRICT_TIERS.has(tier);
|
|
3755
|
-
}
|
|
3756
|
-
function ssrfReason(m, asWritten) {
|
|
3757
|
-
return `Blocked: ${asWritten} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== asWritten ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.");
|
|
3758
|
-
}
|
|
3759
|
-
var METADATA_HOSTNAMES = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.goog", "metadata"]);
|
|
3760
|
-
var v4Octets = (a) => {
|
|
3761
|
-
const p = a.split(".");
|
|
3762
|
-
return p.length === 4 ? p.map(Number) : null;
|
|
3763
|
-
};
|
|
3764
|
-
function classifySsrf(host) {
|
|
3765
|
-
try {
|
|
3766
|
-
if (typeof host !== "string" || !host) return null;
|
|
3767
|
-
const lower = host.trim().toLowerCase().replace(/\.$/, "");
|
|
3768
|
-
const ip = normalizeIpLiteral(host);
|
|
3769
|
-
if (ip === null) {
|
|
3770
|
-
return METADATA_HOSTNAMES.has(lower) ? { tier: "metadata", overridable: false, kind: "hostname" } : null;
|
|
3771
|
-
}
|
|
3772
|
-
const hit = (tier, overridable) => ({
|
|
3773
|
-
tier,
|
|
3774
|
-
overridable,
|
|
3775
|
-
kind: "address",
|
|
3776
|
-
normalized: ip
|
|
3777
|
-
});
|
|
3778
|
-
if (METADATA_ADDRESSES.has(ip)) return hit("metadata", false);
|
|
3779
|
-
const o = v4Octets(ip);
|
|
3780
|
-
if (o) {
|
|
3781
|
-
if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", true);
|
|
3782
|
-
if (o[0] === 169 && o[1] === 254) return hit("link-local", false);
|
|
3783
|
-
if (o[0] >= 224 && o[0] <= 239) return hit("multicast", false);
|
|
3784
|
-
if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return hit("cgnat", true);
|
|
3785
|
-
if (o[0] === 127) return hit("private", true);
|
|
3786
|
-
if (o[0] === 10) return hit("private", true);
|
|
3787
|
-
if (o[0] === 192 && o[1] === 168) return hit("private", true);
|
|
3788
|
-
if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return hit("private", true);
|
|
3789
|
-
return null;
|
|
3790
|
-
}
|
|
3791
|
-
const g = expandIpv6(ip);
|
|
3792
|
-
if (!g) return null;
|
|
3793
|
-
if (g.every((x) => x === 0)) return hit("unspecified", true);
|
|
3794
|
-
if ((g[0] & 65472) === 65152) return hit("link-local", false);
|
|
3795
|
-
if ((g[0] & 65280) === 65280) return hit("multicast", false);
|
|
3796
|
-
if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return hit("private", true);
|
|
3797
|
-
return null;
|
|
3798
|
-
} catch {
|
|
3799
|
-
return null;
|
|
3800
|
-
}
|
|
3801
|
-
}
|
|
3802
|
-
var TIER_REASON = {
|
|
3803
|
-
metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
|
|
3804
|
-
"link-local": "a link-local address",
|
|
3805
|
-
multicast: "a multicast address",
|
|
3806
|
-
unspecified: "the unspecified address, which reaches this host",
|
|
3807
|
-
cgnat: "a carrier-grade NAT address",
|
|
3808
|
-
private: "a loopback or private address"
|
|
3809
|
-
};
|
|
3810
|
-
function ssrfFloor(tokens, opts = {}) {
|
|
3811
|
-
const exempt = new Set(
|
|
3812
|
-
(opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
|
|
3813
|
-
);
|
|
3814
|
-
for (const { token, binary } of tokens) {
|
|
3815
|
-
const m = classifySsrf(token);
|
|
3816
|
-
if (!m) continue;
|
|
3817
|
-
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
3818
|
-
if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
|
|
3819
|
-
return { ...m, host: token, binary, reason: ssrfReason(m, token) };
|
|
3820
|
-
}
|
|
3821
|
-
return null;
|
|
3822
|
-
}
|
|
3823
|
-
|
|
3824
3829
|
// src/egress/destinations.ts
|
|
3825
3830
|
var DESTINATION_ARGS = /* @__PURE__ */ new Map([
|
|
3826
3831
|
["webfetch", ["url"]],
|
package/dist/index.mjs
CHANGED
|
@@ -3073,6 +3073,213 @@ function analyzeShellCommand(command) {
|
|
|
3073
3073
|
return { actions, paths, allTokens };
|
|
3074
3074
|
}
|
|
3075
3075
|
|
|
3076
|
+
// src/egress/ssrf.ts
|
|
3077
|
+
var SSRF_MAX_HOST = 253;
|
|
3078
|
+
function parseComponent(s) {
|
|
3079
|
+
if (!s) return null;
|
|
3080
|
+
if (/^0[xX][0-9a-fA-F]+$/.test(s)) return parseInt(s.slice(2), 16);
|
|
3081
|
+
if (s === "0") return 0;
|
|
3082
|
+
if (/^0[0-7]+$/.test(s)) return parseInt(s.slice(1), 8);
|
|
3083
|
+
if (/^[1-9][0-9]*$/.test(s)) return Number(s);
|
|
3084
|
+
return null;
|
|
3085
|
+
}
|
|
3086
|
+
function parseIpv4(input) {
|
|
3087
|
+
let s = input;
|
|
3088
|
+
if (s.endsWith(".")) s = s.slice(0, -1);
|
|
3089
|
+
if (!s) return null;
|
|
3090
|
+
const parts = s.split(".");
|
|
3091
|
+
if (parts.length > 4) return null;
|
|
3092
|
+
const vals = [];
|
|
3093
|
+
for (const p of parts) {
|
|
3094
|
+
const v = parseComponent(p);
|
|
3095
|
+
if (v === null || !Number.isFinite(v) || v < 0) return null;
|
|
3096
|
+
vals.push(v);
|
|
3097
|
+
}
|
|
3098
|
+
const n = vals.length;
|
|
3099
|
+
for (let i = 0; i < n - 1; i++) if (vals[i] > 255) return null;
|
|
3100
|
+
const last = vals[n - 1];
|
|
3101
|
+
const remainingBytes = 4 - (n - 1);
|
|
3102
|
+
const limit = Math.pow(256, remainingBytes);
|
|
3103
|
+
if (last >= limit) return null;
|
|
3104
|
+
let value = last;
|
|
3105
|
+
for (let i = 0; i < n - 1; i++) value += vals[i] * Math.pow(256, 3 - i);
|
|
3106
|
+
if (value > 4294967295) return null;
|
|
3107
|
+
return [value >>> 24 & 255, value >>> 16 & 255, value >>> 8 & 255, value & 255].join(".");
|
|
3108
|
+
}
|
|
3109
|
+
function expandIpv6(input) {
|
|
3110
|
+
const s = input.toLowerCase();
|
|
3111
|
+
if (!/^[0-9a-f:.]+$/.test(s)) return null;
|
|
3112
|
+
if ((s.match(/::/g) ?? []).length > 1) return null;
|
|
3113
|
+
let head = s;
|
|
3114
|
+
let tailV4 = null;
|
|
3115
|
+
const lastColon = s.lastIndexOf(":");
|
|
3116
|
+
const afterLast = s.slice(lastColon + 1);
|
|
3117
|
+
if (afterLast.includes(".")) {
|
|
3118
|
+
const dotted = parseIpv4(afterLast);
|
|
3119
|
+
if (!dotted) return null;
|
|
3120
|
+
const o = dotted.split(".").map(Number);
|
|
3121
|
+
tailV4 = [o[0] << 8 | o[1], o[2] << 8 | o[3]];
|
|
3122
|
+
head = s.slice(0, lastColon + 1) + "0";
|
|
3123
|
+
}
|
|
3124
|
+
const [lhs, rhs] = head.includes("::") ? head.split("::") : [head, null];
|
|
3125
|
+
const toGroups = (part) => {
|
|
3126
|
+
if (!part) return [];
|
|
3127
|
+
const out = [];
|
|
3128
|
+
for (const g of part.split(":")) {
|
|
3129
|
+
if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
|
|
3130
|
+
out.push(parseInt(g, 16));
|
|
3131
|
+
}
|
|
3132
|
+
return out;
|
|
3133
|
+
};
|
|
3134
|
+
const left = toGroups(lhs);
|
|
3135
|
+
if (left === null) return null;
|
|
3136
|
+
let right = [];
|
|
3137
|
+
if (rhs !== null) {
|
|
3138
|
+
const r = toGroups(rhs);
|
|
3139
|
+
if (r === null) return null;
|
|
3140
|
+
right = r;
|
|
3141
|
+
}
|
|
3142
|
+
if (tailV4) {
|
|
3143
|
+
if (rhs !== null) right = right.slice(0, -1).concat(tailV4);
|
|
3144
|
+
else left.splice(left.length - 1, 1, ...tailV4);
|
|
3145
|
+
}
|
|
3146
|
+
const groups = rhs === null ? left : left.concat(new Array(8 - left.length - right.length).fill(0), right);
|
|
3147
|
+
if (rhs === null && groups.length !== 8) return null;
|
|
3148
|
+
if (rhs !== null && left.length + right.length > 8) return null;
|
|
3149
|
+
if (groups.length !== 8) return null;
|
|
3150
|
+
return groups;
|
|
3151
|
+
}
|
|
3152
|
+
function compressIpv6(g) {
|
|
3153
|
+
let bestStart = -1;
|
|
3154
|
+
let bestLen = 0;
|
|
3155
|
+
let i = 0;
|
|
3156
|
+
while (i < 8) {
|
|
3157
|
+
if (g[i] !== 0) {
|
|
3158
|
+
i++;
|
|
3159
|
+
continue;
|
|
3160
|
+
}
|
|
3161
|
+
let j = i;
|
|
3162
|
+
while (j < 8 && g[j] === 0) j++;
|
|
3163
|
+
if (j - i > bestLen) {
|
|
3164
|
+
bestLen = j - i;
|
|
3165
|
+
bestStart = i;
|
|
3166
|
+
}
|
|
3167
|
+
i = j;
|
|
3168
|
+
}
|
|
3169
|
+
const hex = g.map((x) => x.toString(16));
|
|
3170
|
+
if (bestLen < 2) return hex.join(":");
|
|
3171
|
+
return hex.slice(0, bestStart).join(":") + "::" + hex.slice(bestStart + bestLen).join(":");
|
|
3172
|
+
}
|
|
3173
|
+
function normalizeIpLiteral(host) {
|
|
3174
|
+
try {
|
|
3175
|
+
if (typeof host !== "string") return null;
|
|
3176
|
+
let s = host.trim();
|
|
3177
|
+
if (!s || s.length > SSRF_MAX_HOST) return null;
|
|
3178
|
+
if (s.startsWith("[") && s.endsWith("]")) s = s.slice(1, -1);
|
|
3179
|
+
const zone = s.indexOf("%");
|
|
3180
|
+
if (zone >= 0) s = s.slice(0, zone);
|
|
3181
|
+
if (!s) return null;
|
|
3182
|
+
if (s.includes(":")) {
|
|
3183
|
+
const g = expandIpv6(s);
|
|
3184
|
+
if (!g) return null;
|
|
3185
|
+
const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 65535;
|
|
3186
|
+
if (mapped) {
|
|
3187
|
+
return [g[6] >> 8 & 255, g[6] & 255, g[7] >> 8 & 255, g[7] & 255].join(".");
|
|
3188
|
+
}
|
|
3189
|
+
return compressIpv6(g);
|
|
3190
|
+
}
|
|
3191
|
+
return parseIpv4(s);
|
|
3192
|
+
} catch {
|
|
3193
|
+
return null;
|
|
3194
|
+
}
|
|
3195
|
+
}
|
|
3196
|
+
var METADATA_ADDRESSES = /* @__PURE__ */ new Set([
|
|
3197
|
+
"169.254.169.254",
|
|
3198
|
+
// AWS / Azure / DigitalOcean / OpenStack IMDS
|
|
3199
|
+
"169.254.170.2",
|
|
3200
|
+
// AWS ECS task role
|
|
3201
|
+
"168.63.129.16",
|
|
3202
|
+
// Azure WireServer
|
|
3203
|
+
"fd00:ec2::254",
|
|
3204
|
+
// AWS IMDS over IPv6 (inside fc00::/7, which is NOT a tier)
|
|
3205
|
+
// Alibaba Cloud IMDS. It sits INSIDE 100.64/10, so before this line it was
|
|
3206
|
+
// classified as cgnat and therefore exemptable: one ssrfAllow entry opened a
|
|
3207
|
+
// live credential endpoint. It has to be named here, above the range check,
|
|
3208
|
+
// and it is the reason relaxing cgnat is safe.
|
|
3209
|
+
"100.100.100.200"
|
|
3210
|
+
]);
|
|
3211
|
+
var STRICT_TIERS = /* @__PURE__ */ new Set(["private", "unspecified", "cgnat"]);
|
|
3212
|
+
function isStrictGatedTier(tier) {
|
|
3213
|
+
return STRICT_TIERS.has(tier);
|
|
3214
|
+
}
|
|
3215
|
+
function ssrfReason(m, asWritten) {
|
|
3216
|
+
return `Blocked: ${asWritten} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== asWritten ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.");
|
|
3217
|
+
}
|
|
3218
|
+
var METADATA_HOSTNAMES = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.goog", "metadata"]);
|
|
3219
|
+
var v4Octets = (a) => {
|
|
3220
|
+
const p = a.split(".");
|
|
3221
|
+
return p.length === 4 ? p.map(Number) : null;
|
|
3222
|
+
};
|
|
3223
|
+
function classifySsrf(host) {
|
|
3224
|
+
try {
|
|
3225
|
+
if (typeof host !== "string" || !host) return null;
|
|
3226
|
+
const lower = host.trim().toLowerCase().replace(/\.$/, "");
|
|
3227
|
+
const ip = normalizeIpLiteral(host);
|
|
3228
|
+
if (ip === null) {
|
|
3229
|
+
return METADATA_HOSTNAMES.has(lower) ? { tier: "metadata", overridable: false, kind: "hostname" } : null;
|
|
3230
|
+
}
|
|
3231
|
+
const hit = (tier, overridable) => ({
|
|
3232
|
+
tier,
|
|
3233
|
+
overridable,
|
|
3234
|
+
kind: "address",
|
|
3235
|
+
normalized: ip
|
|
3236
|
+
});
|
|
3237
|
+
if (METADATA_ADDRESSES.has(ip)) return hit("metadata", false);
|
|
3238
|
+
const o = v4Octets(ip);
|
|
3239
|
+
if (o) {
|
|
3240
|
+
if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", true);
|
|
3241
|
+
if (o[0] === 169 && o[1] === 254) return hit("link-local", false);
|
|
3242
|
+
if (o[0] >= 224 && o[0] <= 239) return hit("multicast", false);
|
|
3243
|
+
if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return hit("cgnat", true);
|
|
3244
|
+
if (o[0] === 127) return hit("private", true);
|
|
3245
|
+
if (o[0] === 10) return hit("private", true);
|
|
3246
|
+
if (o[0] === 192 && o[1] === 168) return hit("private", true);
|
|
3247
|
+
if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return hit("private", true);
|
|
3248
|
+
return null;
|
|
3249
|
+
}
|
|
3250
|
+
const g = expandIpv6(ip);
|
|
3251
|
+
if (!g) return null;
|
|
3252
|
+
if (g.every((x) => x === 0)) return hit("unspecified", true);
|
|
3253
|
+
if ((g[0] & 65472) === 65152) return hit("link-local", false);
|
|
3254
|
+
if ((g[0] & 65280) === 65280) return hit("multicast", false);
|
|
3255
|
+
if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return hit("private", true);
|
|
3256
|
+
return null;
|
|
3257
|
+
} catch {
|
|
3258
|
+
return null;
|
|
3259
|
+
}
|
|
3260
|
+
}
|
|
3261
|
+
var TIER_REASON = {
|
|
3262
|
+
metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
|
|
3263
|
+
"link-local": "a link-local address",
|
|
3264
|
+
multicast: "a multicast address",
|
|
3265
|
+
unspecified: "the unspecified address, which reaches this host",
|
|
3266
|
+
cgnat: "a carrier-grade NAT address",
|
|
3267
|
+
private: "a loopback or private address"
|
|
3268
|
+
};
|
|
3269
|
+
function ssrfFloor(tokens, opts = {}) {
|
|
3270
|
+
const exempt = new Set(
|
|
3271
|
+
(opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
|
|
3272
|
+
);
|
|
3273
|
+
for (const { token, binary } of tokens) {
|
|
3274
|
+
const m = classifySsrf(token);
|
|
3275
|
+
if (!m) continue;
|
|
3276
|
+
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
3277
|
+
if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
|
|
3278
|
+
return { ...m, host: token, binary, reason: ssrfReason(m, token) };
|
|
3279
|
+
}
|
|
3280
|
+
return null;
|
|
3281
|
+
}
|
|
3282
|
+
|
|
3076
3283
|
// src/egress/index.ts
|
|
3077
3284
|
var DEFAULT_EGRESS_ALLOWLIST = [
|
|
3078
3285
|
// node9's own control plane (api, app, dev-api, staging and the apex).
|
|
@@ -3112,15 +3319,20 @@ function matchesAny(host, patterns) {
|
|
|
3112
3319
|
for (const p of patterns) if (hostMatches(host, p)) return true;
|
|
3113
3320
|
return false;
|
|
3114
3321
|
}
|
|
3322
|
+
var PRIVATE_HOST_SUFFIXES = [".local", ".internal", ".localhost"];
|
|
3323
|
+
function isUniqueLocalV6(host) {
|
|
3324
|
+
const ip = normalizeIpLiteral(host);
|
|
3325
|
+
if (!ip || !ip.includes(":")) return false;
|
|
3326
|
+
const g = expandIpv6(ip);
|
|
3327
|
+
return g !== null && (g[0] & 65024) === 64512;
|
|
3328
|
+
}
|
|
3115
3329
|
function isPrivateHost(host) {
|
|
3116
|
-
const h = host.toLowerCase();
|
|
3117
|
-
|
|
3118
|
-
if (
|
|
3119
|
-
if (
|
|
3120
|
-
if (
|
|
3121
|
-
|
|
3122
|
-
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true;
|
|
3123
|
-
return false;
|
|
3330
|
+
const h = host.trim().toLowerCase();
|
|
3331
|
+
const m = classifySsrf(h);
|
|
3332
|
+
if (m) return m.kind === "address" && (m.tier === "private" || m.tier === "unspecified");
|
|
3333
|
+
if (h === "localhost") return true;
|
|
3334
|
+
if (PRIVATE_HOST_SUFFIXES.some((s) => h.endsWith(s))) return true;
|
|
3335
|
+
return isUniqueLocalV6(h);
|
|
3124
3336
|
}
|
|
3125
3337
|
function evaluateEgress(dests, policy) {
|
|
3126
3338
|
if (!policy.enabled) return null;
|
|
@@ -3474,213 +3686,6 @@ function parseAllSshHostsFromCommand(command) {
|
|
|
3474
3686
|
return extractAllSshHosts(tokens.slice(1));
|
|
3475
3687
|
}
|
|
3476
3688
|
|
|
3477
|
-
// src/egress/ssrf.ts
|
|
3478
|
-
var SSRF_MAX_HOST = 253;
|
|
3479
|
-
function parseComponent(s) {
|
|
3480
|
-
if (!s) return null;
|
|
3481
|
-
if (/^0[xX][0-9a-fA-F]+$/.test(s)) return parseInt(s.slice(2), 16);
|
|
3482
|
-
if (s === "0") return 0;
|
|
3483
|
-
if (/^0[0-7]+$/.test(s)) return parseInt(s.slice(1), 8);
|
|
3484
|
-
if (/^[1-9][0-9]*$/.test(s)) return Number(s);
|
|
3485
|
-
return null;
|
|
3486
|
-
}
|
|
3487
|
-
function parseIpv4(input) {
|
|
3488
|
-
let s = input;
|
|
3489
|
-
if (s.endsWith(".")) s = s.slice(0, -1);
|
|
3490
|
-
if (!s) return null;
|
|
3491
|
-
const parts = s.split(".");
|
|
3492
|
-
if (parts.length > 4) return null;
|
|
3493
|
-
const vals = [];
|
|
3494
|
-
for (const p of parts) {
|
|
3495
|
-
const v = parseComponent(p);
|
|
3496
|
-
if (v === null || !Number.isFinite(v) || v < 0) return null;
|
|
3497
|
-
vals.push(v);
|
|
3498
|
-
}
|
|
3499
|
-
const n = vals.length;
|
|
3500
|
-
for (let i = 0; i < n - 1; i++) if (vals[i] > 255) return null;
|
|
3501
|
-
const last = vals[n - 1];
|
|
3502
|
-
const remainingBytes = 4 - (n - 1);
|
|
3503
|
-
const limit = Math.pow(256, remainingBytes);
|
|
3504
|
-
if (last >= limit) return null;
|
|
3505
|
-
let value = last;
|
|
3506
|
-
for (let i = 0; i < n - 1; i++) value += vals[i] * Math.pow(256, 3 - i);
|
|
3507
|
-
if (value > 4294967295) return null;
|
|
3508
|
-
return [value >>> 24 & 255, value >>> 16 & 255, value >>> 8 & 255, value & 255].join(".");
|
|
3509
|
-
}
|
|
3510
|
-
function expandIpv6(input) {
|
|
3511
|
-
const s = input.toLowerCase();
|
|
3512
|
-
if (!/^[0-9a-f:.]+$/.test(s)) return null;
|
|
3513
|
-
if ((s.match(/::/g) ?? []).length > 1) return null;
|
|
3514
|
-
let head = s;
|
|
3515
|
-
let tailV4 = null;
|
|
3516
|
-
const lastColon = s.lastIndexOf(":");
|
|
3517
|
-
const afterLast = s.slice(lastColon + 1);
|
|
3518
|
-
if (afterLast.includes(".")) {
|
|
3519
|
-
const dotted = parseIpv4(afterLast);
|
|
3520
|
-
if (!dotted) return null;
|
|
3521
|
-
const o = dotted.split(".").map(Number);
|
|
3522
|
-
tailV4 = [o[0] << 8 | o[1], o[2] << 8 | o[3]];
|
|
3523
|
-
head = s.slice(0, lastColon + 1) + "0";
|
|
3524
|
-
}
|
|
3525
|
-
const [lhs, rhs] = head.includes("::") ? head.split("::") : [head, null];
|
|
3526
|
-
const toGroups = (part) => {
|
|
3527
|
-
if (!part) return [];
|
|
3528
|
-
const out = [];
|
|
3529
|
-
for (const g of part.split(":")) {
|
|
3530
|
-
if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
|
|
3531
|
-
out.push(parseInt(g, 16));
|
|
3532
|
-
}
|
|
3533
|
-
return out;
|
|
3534
|
-
};
|
|
3535
|
-
const left = toGroups(lhs);
|
|
3536
|
-
if (left === null) return null;
|
|
3537
|
-
let right = [];
|
|
3538
|
-
if (rhs !== null) {
|
|
3539
|
-
const r = toGroups(rhs);
|
|
3540
|
-
if (r === null) return null;
|
|
3541
|
-
right = r;
|
|
3542
|
-
}
|
|
3543
|
-
if (tailV4) {
|
|
3544
|
-
if (rhs !== null) right = right.slice(0, -1).concat(tailV4);
|
|
3545
|
-
else left.splice(left.length - 1, 1, ...tailV4);
|
|
3546
|
-
}
|
|
3547
|
-
const groups = rhs === null ? left : left.concat(new Array(8 - left.length - right.length).fill(0), right);
|
|
3548
|
-
if (rhs === null && groups.length !== 8) return null;
|
|
3549
|
-
if (rhs !== null && left.length + right.length > 8) return null;
|
|
3550
|
-
if (groups.length !== 8) return null;
|
|
3551
|
-
return groups;
|
|
3552
|
-
}
|
|
3553
|
-
function compressIpv6(g) {
|
|
3554
|
-
let bestStart = -1;
|
|
3555
|
-
let bestLen = 0;
|
|
3556
|
-
let i = 0;
|
|
3557
|
-
while (i < 8) {
|
|
3558
|
-
if (g[i] !== 0) {
|
|
3559
|
-
i++;
|
|
3560
|
-
continue;
|
|
3561
|
-
}
|
|
3562
|
-
let j = i;
|
|
3563
|
-
while (j < 8 && g[j] === 0) j++;
|
|
3564
|
-
if (j - i > bestLen) {
|
|
3565
|
-
bestLen = j - i;
|
|
3566
|
-
bestStart = i;
|
|
3567
|
-
}
|
|
3568
|
-
i = j;
|
|
3569
|
-
}
|
|
3570
|
-
const hex = g.map((x) => x.toString(16));
|
|
3571
|
-
if (bestLen < 2) return hex.join(":");
|
|
3572
|
-
return hex.slice(0, bestStart).join(":") + "::" + hex.slice(bestStart + bestLen).join(":");
|
|
3573
|
-
}
|
|
3574
|
-
function normalizeIpLiteral(host) {
|
|
3575
|
-
try {
|
|
3576
|
-
if (typeof host !== "string") return null;
|
|
3577
|
-
let s = host.trim();
|
|
3578
|
-
if (!s || s.length > SSRF_MAX_HOST) return null;
|
|
3579
|
-
if (s.startsWith("[") && s.endsWith("]")) s = s.slice(1, -1);
|
|
3580
|
-
const zone = s.indexOf("%");
|
|
3581
|
-
if (zone >= 0) s = s.slice(0, zone);
|
|
3582
|
-
if (!s) return null;
|
|
3583
|
-
if (s.includes(":")) {
|
|
3584
|
-
const g = expandIpv6(s);
|
|
3585
|
-
if (!g) return null;
|
|
3586
|
-
const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 65535;
|
|
3587
|
-
if (mapped) {
|
|
3588
|
-
return [g[6] >> 8 & 255, g[6] & 255, g[7] >> 8 & 255, g[7] & 255].join(".");
|
|
3589
|
-
}
|
|
3590
|
-
return compressIpv6(g);
|
|
3591
|
-
}
|
|
3592
|
-
return parseIpv4(s);
|
|
3593
|
-
} catch {
|
|
3594
|
-
return null;
|
|
3595
|
-
}
|
|
3596
|
-
}
|
|
3597
|
-
var METADATA_ADDRESSES = /* @__PURE__ */ new Set([
|
|
3598
|
-
"169.254.169.254",
|
|
3599
|
-
// AWS / Azure / DigitalOcean / OpenStack IMDS
|
|
3600
|
-
"169.254.170.2",
|
|
3601
|
-
// AWS ECS task role
|
|
3602
|
-
"168.63.129.16",
|
|
3603
|
-
// Azure WireServer
|
|
3604
|
-
"fd00:ec2::254",
|
|
3605
|
-
// AWS IMDS over IPv6 (inside fc00::/7, which is NOT a tier)
|
|
3606
|
-
// Alibaba Cloud IMDS. It sits INSIDE 100.64/10, so before this line it was
|
|
3607
|
-
// classified as cgnat and therefore exemptable: one ssrfAllow entry opened a
|
|
3608
|
-
// live credential endpoint. It has to be named here, above the range check,
|
|
3609
|
-
// and it is the reason relaxing cgnat is safe.
|
|
3610
|
-
"100.100.100.200"
|
|
3611
|
-
]);
|
|
3612
|
-
var STRICT_TIERS = /* @__PURE__ */ new Set(["private", "unspecified", "cgnat"]);
|
|
3613
|
-
function isStrictGatedTier(tier) {
|
|
3614
|
-
return STRICT_TIERS.has(tier);
|
|
3615
|
-
}
|
|
3616
|
-
function ssrfReason(m, asWritten) {
|
|
3617
|
-
return `Blocked: ${asWritten} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== asWritten ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.");
|
|
3618
|
-
}
|
|
3619
|
-
var METADATA_HOSTNAMES = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.goog", "metadata"]);
|
|
3620
|
-
var v4Octets = (a) => {
|
|
3621
|
-
const p = a.split(".");
|
|
3622
|
-
return p.length === 4 ? p.map(Number) : null;
|
|
3623
|
-
};
|
|
3624
|
-
function classifySsrf(host) {
|
|
3625
|
-
try {
|
|
3626
|
-
if (typeof host !== "string" || !host) return null;
|
|
3627
|
-
const lower = host.trim().toLowerCase().replace(/\.$/, "");
|
|
3628
|
-
const ip = normalizeIpLiteral(host);
|
|
3629
|
-
if (ip === null) {
|
|
3630
|
-
return METADATA_HOSTNAMES.has(lower) ? { tier: "metadata", overridable: false, kind: "hostname" } : null;
|
|
3631
|
-
}
|
|
3632
|
-
const hit = (tier, overridable) => ({
|
|
3633
|
-
tier,
|
|
3634
|
-
overridable,
|
|
3635
|
-
kind: "address",
|
|
3636
|
-
normalized: ip
|
|
3637
|
-
});
|
|
3638
|
-
if (METADATA_ADDRESSES.has(ip)) return hit("metadata", false);
|
|
3639
|
-
const o = v4Octets(ip);
|
|
3640
|
-
if (o) {
|
|
3641
|
-
if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", true);
|
|
3642
|
-
if (o[0] === 169 && o[1] === 254) return hit("link-local", false);
|
|
3643
|
-
if (o[0] >= 224 && o[0] <= 239) return hit("multicast", false);
|
|
3644
|
-
if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return hit("cgnat", true);
|
|
3645
|
-
if (o[0] === 127) return hit("private", true);
|
|
3646
|
-
if (o[0] === 10) return hit("private", true);
|
|
3647
|
-
if (o[0] === 192 && o[1] === 168) return hit("private", true);
|
|
3648
|
-
if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return hit("private", true);
|
|
3649
|
-
return null;
|
|
3650
|
-
}
|
|
3651
|
-
const g = expandIpv6(ip);
|
|
3652
|
-
if (!g) return null;
|
|
3653
|
-
if (g.every((x) => x === 0)) return hit("unspecified", true);
|
|
3654
|
-
if ((g[0] & 65472) === 65152) return hit("link-local", false);
|
|
3655
|
-
if ((g[0] & 65280) === 65280) return hit("multicast", false);
|
|
3656
|
-
if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return hit("private", true);
|
|
3657
|
-
return null;
|
|
3658
|
-
} catch {
|
|
3659
|
-
return null;
|
|
3660
|
-
}
|
|
3661
|
-
}
|
|
3662
|
-
var TIER_REASON = {
|
|
3663
|
-
metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
|
|
3664
|
-
"link-local": "a link-local address",
|
|
3665
|
-
multicast: "a multicast address",
|
|
3666
|
-
unspecified: "the unspecified address, which reaches this host",
|
|
3667
|
-
cgnat: "a carrier-grade NAT address",
|
|
3668
|
-
private: "a loopback or private address"
|
|
3669
|
-
};
|
|
3670
|
-
function ssrfFloor(tokens, opts = {}) {
|
|
3671
|
-
const exempt = new Set(
|
|
3672
|
-
(opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
|
|
3673
|
-
);
|
|
3674
|
-
for (const { token, binary } of tokens) {
|
|
3675
|
-
const m = classifySsrf(token);
|
|
3676
|
-
if (!m) continue;
|
|
3677
|
-
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
3678
|
-
if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
|
|
3679
|
-
return { ...m, host: token, binary, reason: ssrfReason(m, token) };
|
|
3680
|
-
}
|
|
3681
|
-
return null;
|
|
3682
|
-
}
|
|
3683
|
-
|
|
3684
3689
|
// src/egress/destinations.ts
|
|
3685
3690
|
var DESTINATION_ARGS = /* @__PURE__ */ new Map([
|
|
3686
3691
|
["webfetch", ["url"]],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@node9/policy-engine",
|
|
3
|
-
"version": "2.16.
|
|
3
|
+
"version": "2.16.2",
|
|
4
4
|
"description": "Shared policy evaluation engine for node9 — DLP, smart rules, AST shell parsing, shields, loop detection. Pure functions, no I/O. Used by both node9-proxy and the node9 SaaS firewall.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://node9.ai",
|