@node9/proxy 2.16.1 → 2.17.0
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/cli.js +270 -234
- package/dist/cli.mjs +270 -234
- package/dist/dashboard.mjs +2 -2
- package/dist/index.js +269 -233
- package/dist/index.mjs +269 -233
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -3416,6 +3416,211 @@ function analyzeShellCommand(command) {
|
|
|
3416
3416
|
}
|
|
3417
3417
|
return { actions, paths, allTokens };
|
|
3418
3418
|
}
|
|
3419
|
+
var SSRF_MAX_HOST = 253;
|
|
3420
|
+
function parseComponent(s) {
|
|
3421
|
+
if (!s) return null;
|
|
3422
|
+
if (/^0[xX][0-9a-fA-F]+$/.test(s)) return parseInt(s.slice(2), 16);
|
|
3423
|
+
if (s === "0") return 0;
|
|
3424
|
+
if (/^0[0-7]+$/.test(s)) return parseInt(s.slice(1), 8);
|
|
3425
|
+
if (/^[1-9][0-9]*$/.test(s)) return Number(s);
|
|
3426
|
+
return null;
|
|
3427
|
+
}
|
|
3428
|
+
function parseIpv4(input) {
|
|
3429
|
+
let s = input;
|
|
3430
|
+
if (s.endsWith(".")) s = s.slice(0, -1);
|
|
3431
|
+
if (!s) return null;
|
|
3432
|
+
const parts = s.split(".");
|
|
3433
|
+
if (parts.length > 4) return null;
|
|
3434
|
+
const vals = [];
|
|
3435
|
+
for (const p of parts) {
|
|
3436
|
+
const v = parseComponent(p);
|
|
3437
|
+
if (v === null || !Number.isFinite(v) || v < 0) return null;
|
|
3438
|
+
vals.push(v);
|
|
3439
|
+
}
|
|
3440
|
+
const n = vals.length;
|
|
3441
|
+
for (let i = 0; i < n - 1; i++) if (vals[i] > 255) return null;
|
|
3442
|
+
const last = vals[n - 1];
|
|
3443
|
+
const remainingBytes = 4 - (n - 1);
|
|
3444
|
+
const limit = Math.pow(256, remainingBytes);
|
|
3445
|
+
if (last >= limit) return null;
|
|
3446
|
+
let value = last;
|
|
3447
|
+
for (let i = 0; i < n - 1; i++) value += vals[i] * Math.pow(256, 3 - i);
|
|
3448
|
+
if (value > 4294967295) return null;
|
|
3449
|
+
return [value >>> 24 & 255, value >>> 16 & 255, value >>> 8 & 255, value & 255].join(".");
|
|
3450
|
+
}
|
|
3451
|
+
function expandIpv6(input) {
|
|
3452
|
+
const s = input.toLowerCase();
|
|
3453
|
+
if (!/^[0-9a-f:.]+$/.test(s)) return null;
|
|
3454
|
+
if ((s.match(/::/g) ?? []).length > 1) return null;
|
|
3455
|
+
let head = s;
|
|
3456
|
+
let tailV4 = null;
|
|
3457
|
+
const lastColon = s.lastIndexOf(":");
|
|
3458
|
+
const afterLast = s.slice(lastColon + 1);
|
|
3459
|
+
if (afterLast.includes(".")) {
|
|
3460
|
+
const dotted = parseIpv4(afterLast);
|
|
3461
|
+
if (!dotted) return null;
|
|
3462
|
+
const o = dotted.split(".").map(Number);
|
|
3463
|
+
tailV4 = [o[0] << 8 | o[1], o[2] << 8 | o[3]];
|
|
3464
|
+
head = s.slice(0, lastColon + 1) + "0";
|
|
3465
|
+
}
|
|
3466
|
+
const [lhs, rhs] = head.includes("::") ? head.split("::") : [head, null];
|
|
3467
|
+
const toGroups = (part) => {
|
|
3468
|
+
if (!part) return [];
|
|
3469
|
+
const out = [];
|
|
3470
|
+
for (const g of part.split(":")) {
|
|
3471
|
+
if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
|
|
3472
|
+
out.push(parseInt(g, 16));
|
|
3473
|
+
}
|
|
3474
|
+
return out;
|
|
3475
|
+
};
|
|
3476
|
+
const left = toGroups(lhs);
|
|
3477
|
+
if (left === null) return null;
|
|
3478
|
+
let right = [];
|
|
3479
|
+
if (rhs !== null) {
|
|
3480
|
+
const r = toGroups(rhs);
|
|
3481
|
+
if (r === null) return null;
|
|
3482
|
+
right = r;
|
|
3483
|
+
}
|
|
3484
|
+
if (tailV4) {
|
|
3485
|
+
if (rhs !== null) right = right.slice(0, -1).concat(tailV4);
|
|
3486
|
+
else left.splice(left.length - 1, 1, ...tailV4);
|
|
3487
|
+
}
|
|
3488
|
+
const groups = rhs === null ? left : left.concat(new Array(8 - left.length - right.length).fill(0), right);
|
|
3489
|
+
if (rhs === null && groups.length !== 8) return null;
|
|
3490
|
+
if (rhs !== null && left.length + right.length > 8) return null;
|
|
3491
|
+
if (groups.length !== 8) return null;
|
|
3492
|
+
return groups;
|
|
3493
|
+
}
|
|
3494
|
+
function compressIpv6(g) {
|
|
3495
|
+
let bestStart = -1;
|
|
3496
|
+
let bestLen = 0;
|
|
3497
|
+
let i = 0;
|
|
3498
|
+
while (i < 8) {
|
|
3499
|
+
if (g[i] !== 0) {
|
|
3500
|
+
i++;
|
|
3501
|
+
continue;
|
|
3502
|
+
}
|
|
3503
|
+
let j = i;
|
|
3504
|
+
while (j < 8 && g[j] === 0) j++;
|
|
3505
|
+
if (j - i > bestLen) {
|
|
3506
|
+
bestLen = j - i;
|
|
3507
|
+
bestStart = i;
|
|
3508
|
+
}
|
|
3509
|
+
i = j;
|
|
3510
|
+
}
|
|
3511
|
+
const hex = g.map((x) => x.toString(16));
|
|
3512
|
+
if (bestLen < 2) return hex.join(":");
|
|
3513
|
+
return hex.slice(0, bestStart).join(":") + "::" + hex.slice(bestStart + bestLen).join(":");
|
|
3514
|
+
}
|
|
3515
|
+
function normalizeIpLiteral(host) {
|
|
3516
|
+
try {
|
|
3517
|
+
if (typeof host !== "string") return null;
|
|
3518
|
+
let s = host.trim();
|
|
3519
|
+
if (!s || s.length > SSRF_MAX_HOST) return null;
|
|
3520
|
+
if (s.startsWith("[") && s.endsWith("]")) s = s.slice(1, -1);
|
|
3521
|
+
const zone = s.indexOf("%");
|
|
3522
|
+
if (zone >= 0) s = s.slice(0, zone);
|
|
3523
|
+
if (!s) return null;
|
|
3524
|
+
if (s.includes(":")) {
|
|
3525
|
+
const g = expandIpv6(s);
|
|
3526
|
+
if (!g) return null;
|
|
3527
|
+
const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 65535;
|
|
3528
|
+
if (mapped) {
|
|
3529
|
+
return [g[6] >> 8 & 255, g[6] & 255, g[7] >> 8 & 255, g[7] & 255].join(".");
|
|
3530
|
+
}
|
|
3531
|
+
return compressIpv6(g);
|
|
3532
|
+
}
|
|
3533
|
+
return parseIpv4(s);
|
|
3534
|
+
} catch {
|
|
3535
|
+
return null;
|
|
3536
|
+
}
|
|
3537
|
+
}
|
|
3538
|
+
var METADATA_ADDRESSES = /* @__PURE__ */ new Set([
|
|
3539
|
+
"169.254.169.254",
|
|
3540
|
+
// AWS / Azure / DigitalOcean / OpenStack IMDS
|
|
3541
|
+
"169.254.170.2",
|
|
3542
|
+
// AWS ECS task role
|
|
3543
|
+
"168.63.129.16",
|
|
3544
|
+
// Azure WireServer
|
|
3545
|
+
"fd00:ec2::254",
|
|
3546
|
+
// AWS IMDS over IPv6 (inside fc00::/7, which is NOT a tier)
|
|
3547
|
+
// Alibaba Cloud IMDS. It sits INSIDE 100.64/10, so before this line it was
|
|
3548
|
+
// classified as cgnat and therefore exemptable: one ssrfAllow entry opened a
|
|
3549
|
+
// live credential endpoint. It has to be named here, above the range check,
|
|
3550
|
+
// and it is the reason relaxing cgnat is safe.
|
|
3551
|
+
"100.100.100.200"
|
|
3552
|
+
]);
|
|
3553
|
+
var STRICT_TIERS = /* @__PURE__ */ new Set(["private", "unspecified", "cgnat"]);
|
|
3554
|
+
function isStrictGatedTier(tier) {
|
|
3555
|
+
return STRICT_TIERS.has(tier);
|
|
3556
|
+
}
|
|
3557
|
+
function ssrfReason(m, asWritten) {
|
|
3558
|
+
return `Blocked: ${asWritten} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== asWritten ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.");
|
|
3559
|
+
}
|
|
3560
|
+
var METADATA_HOSTNAMES = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.goog", "metadata"]);
|
|
3561
|
+
var v4Octets = (a) => {
|
|
3562
|
+
const p = a.split(".");
|
|
3563
|
+
return p.length === 4 ? p.map(Number) : null;
|
|
3564
|
+
};
|
|
3565
|
+
function classifySsrf(host) {
|
|
3566
|
+
try {
|
|
3567
|
+
if (typeof host !== "string" || !host) return null;
|
|
3568
|
+
const lower = host.trim().toLowerCase().replace(/\.$/, "");
|
|
3569
|
+
const ip = normalizeIpLiteral(host);
|
|
3570
|
+
if (ip === null) {
|
|
3571
|
+
return METADATA_HOSTNAMES.has(lower) ? { tier: "metadata", overridable: false, kind: "hostname" } : null;
|
|
3572
|
+
}
|
|
3573
|
+
const hit = (tier, overridable) => ({
|
|
3574
|
+
tier,
|
|
3575
|
+
overridable,
|
|
3576
|
+
kind: "address",
|
|
3577
|
+
normalized: ip
|
|
3578
|
+
});
|
|
3579
|
+
if (METADATA_ADDRESSES.has(ip)) return hit("metadata", false);
|
|
3580
|
+
const o = v4Octets(ip);
|
|
3581
|
+
if (o) {
|
|
3582
|
+
if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", true);
|
|
3583
|
+
if (o[0] === 169 && o[1] === 254) return hit("link-local", false);
|
|
3584
|
+
if (o[0] >= 224 && o[0] <= 239) return hit("multicast", false);
|
|
3585
|
+
if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return hit("cgnat", true);
|
|
3586
|
+
if (o[0] === 127) return hit("private", true);
|
|
3587
|
+
if (o[0] === 10) return hit("private", true);
|
|
3588
|
+
if (o[0] === 192 && o[1] === 168) return hit("private", true);
|
|
3589
|
+
if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return hit("private", true);
|
|
3590
|
+
return null;
|
|
3591
|
+
}
|
|
3592
|
+
const g = expandIpv6(ip);
|
|
3593
|
+
if (!g) return null;
|
|
3594
|
+
if (g.every((x) => x === 0)) return hit("unspecified", true);
|
|
3595
|
+
if ((g[0] & 65472) === 65152) return hit("link-local", false);
|
|
3596
|
+
if ((g[0] & 65280) === 65280) return hit("multicast", false);
|
|
3597
|
+
if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return hit("private", true);
|
|
3598
|
+
return null;
|
|
3599
|
+
} catch {
|
|
3600
|
+
return null;
|
|
3601
|
+
}
|
|
3602
|
+
}
|
|
3603
|
+
var TIER_REASON = {
|
|
3604
|
+
metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
|
|
3605
|
+
"link-local": "a link-local address",
|
|
3606
|
+
multicast: "a multicast address",
|
|
3607
|
+
unspecified: "the unspecified address, which reaches this host",
|
|
3608
|
+
cgnat: "a carrier-grade NAT address",
|
|
3609
|
+
private: "a loopback or private address"
|
|
3610
|
+
};
|
|
3611
|
+
function ssrfFloor(tokens, opts = {}) {
|
|
3612
|
+
const exempt = new Set(
|
|
3613
|
+
(opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
|
|
3614
|
+
);
|
|
3615
|
+
for (const { token, binary } of tokens) {
|
|
3616
|
+
const m = classifySsrf(token);
|
|
3617
|
+
if (!m) continue;
|
|
3618
|
+
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
3619
|
+
if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
|
|
3620
|
+
return { ...m, host: token, binary, reason: ssrfReason(m, token) };
|
|
3621
|
+
}
|
|
3622
|
+
return null;
|
|
3623
|
+
}
|
|
3419
3624
|
var DEFAULT_EGRESS_ALLOWLIST = [
|
|
3420
3625
|
// node9's own control plane (api, app, dev-api, staging and the apex).
|
|
3421
3626
|
// Without it, turning egress on asks the user to approve node9 itself.
|
|
@@ -3440,29 +3645,38 @@ var DEFAULT_EGRESS_ALLOWLIST = [
|
|
|
3440
3645
|
"*.ubuntu.com"
|
|
3441
3646
|
];
|
|
3442
3647
|
function hostMatches(host, pattern) {
|
|
3443
|
-
const
|
|
3444
|
-
const p = pattern.toLowerCase().trim();
|
|
3648
|
+
const p = pattern.trim().toLowerCase();
|
|
3445
3649
|
if (!p) return false;
|
|
3446
3650
|
if (p === "*") return true;
|
|
3651
|
+
const h = canonicalHost(host);
|
|
3447
3652
|
if (p.startsWith("*.")) {
|
|
3448
|
-
const suffix = p.slice(2);
|
|
3653
|
+
const suffix = canonicalHost(p.slice(2));
|
|
3449
3654
|
return h === suffix || h.endsWith("." + suffix);
|
|
3450
3655
|
}
|
|
3451
|
-
return h === p;
|
|
3656
|
+
return h === canonicalHost(p);
|
|
3452
3657
|
}
|
|
3453
3658
|
function matchesAny(host, patterns) {
|
|
3454
|
-
for (const p of patterns) if (hostMatches(host, p)) return true;
|
|
3659
|
+
for (const p of patterns ?? []) if (hostMatches(host, p)) return true;
|
|
3455
3660
|
return false;
|
|
3456
3661
|
}
|
|
3662
|
+
var PRIVATE_HOST_SUFFIXES = [".local", ".internal", ".localhost"];
|
|
3663
|
+
function isUniqueLocalV6(host) {
|
|
3664
|
+
const ip = normalizeIpLiteral(host);
|
|
3665
|
+
if (!ip || !ip.includes(":")) return false;
|
|
3666
|
+
const g = expandIpv6(ip);
|
|
3667
|
+
return g !== null && (g[0] & 65024) === 64512;
|
|
3668
|
+
}
|
|
3457
3669
|
function isPrivateHost(host) {
|
|
3458
|
-
const h = host.toLowerCase();
|
|
3459
|
-
|
|
3460
|
-
if (
|
|
3461
|
-
if (
|
|
3462
|
-
if (
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3670
|
+
const h = host.trim().toLowerCase().replace(/\.$/, "");
|
|
3671
|
+
const m = classifySsrf(h);
|
|
3672
|
+
if (m) return m.kind === "address" && (m.tier === "private" || m.tier === "unspecified");
|
|
3673
|
+
if (h === "localhost") return true;
|
|
3674
|
+
if (PRIVATE_HOST_SUFFIXES.some((s) => h.endsWith(s))) return true;
|
|
3675
|
+
return isUniqueLocalV6(h);
|
|
3676
|
+
}
|
|
3677
|
+
function canonicalHost(host) {
|
|
3678
|
+
const ip = normalizeIpLiteral(host);
|
|
3679
|
+
return ip ?? host.trim().toLowerCase().replace(/\.$/, "");
|
|
3466
3680
|
}
|
|
3467
3681
|
function evaluateEgress(dests, policy) {
|
|
3468
3682
|
if (!policy.enabled) return null;
|
|
@@ -3805,211 +4019,6 @@ function extractAllSshHosts(tokens) {
|
|
|
3805
4019
|
}
|
|
3806
4020
|
return [...hosts].filter(Boolean);
|
|
3807
4021
|
}
|
|
3808
|
-
var SSRF_MAX_HOST = 253;
|
|
3809
|
-
function parseComponent(s) {
|
|
3810
|
-
if (!s) return null;
|
|
3811
|
-
if (/^0[xX][0-9a-fA-F]+$/.test(s)) return parseInt(s.slice(2), 16);
|
|
3812
|
-
if (s === "0") return 0;
|
|
3813
|
-
if (/^0[0-7]+$/.test(s)) return parseInt(s.slice(1), 8);
|
|
3814
|
-
if (/^[1-9][0-9]*$/.test(s)) return Number(s);
|
|
3815
|
-
return null;
|
|
3816
|
-
}
|
|
3817
|
-
function parseIpv4(input) {
|
|
3818
|
-
let s = input;
|
|
3819
|
-
if (s.endsWith(".")) s = s.slice(0, -1);
|
|
3820
|
-
if (!s) return null;
|
|
3821
|
-
const parts = s.split(".");
|
|
3822
|
-
if (parts.length > 4) return null;
|
|
3823
|
-
const vals = [];
|
|
3824
|
-
for (const p of parts) {
|
|
3825
|
-
const v = parseComponent(p);
|
|
3826
|
-
if (v === null || !Number.isFinite(v) || v < 0) return null;
|
|
3827
|
-
vals.push(v);
|
|
3828
|
-
}
|
|
3829
|
-
const n = vals.length;
|
|
3830
|
-
for (let i = 0; i < n - 1; i++) if (vals[i] > 255) return null;
|
|
3831
|
-
const last = vals[n - 1];
|
|
3832
|
-
const remainingBytes = 4 - (n - 1);
|
|
3833
|
-
const limit = Math.pow(256, remainingBytes);
|
|
3834
|
-
if (last >= limit) return null;
|
|
3835
|
-
let value = last;
|
|
3836
|
-
for (let i = 0; i < n - 1; i++) value += vals[i] * Math.pow(256, 3 - i);
|
|
3837
|
-
if (value > 4294967295) return null;
|
|
3838
|
-
return [value >>> 24 & 255, value >>> 16 & 255, value >>> 8 & 255, value & 255].join(".");
|
|
3839
|
-
}
|
|
3840
|
-
function expandIpv6(input) {
|
|
3841
|
-
const s = input.toLowerCase();
|
|
3842
|
-
if (!/^[0-9a-f:.]+$/.test(s)) return null;
|
|
3843
|
-
if ((s.match(/::/g) ?? []).length > 1) return null;
|
|
3844
|
-
let head = s;
|
|
3845
|
-
let tailV4 = null;
|
|
3846
|
-
const lastColon = s.lastIndexOf(":");
|
|
3847
|
-
const afterLast = s.slice(lastColon + 1);
|
|
3848
|
-
if (afterLast.includes(".")) {
|
|
3849
|
-
const dotted = parseIpv4(afterLast);
|
|
3850
|
-
if (!dotted) return null;
|
|
3851
|
-
const o = dotted.split(".").map(Number);
|
|
3852
|
-
tailV4 = [o[0] << 8 | o[1], o[2] << 8 | o[3]];
|
|
3853
|
-
head = s.slice(0, lastColon + 1) + "0";
|
|
3854
|
-
}
|
|
3855
|
-
const [lhs, rhs] = head.includes("::") ? head.split("::") : [head, null];
|
|
3856
|
-
const toGroups = (part) => {
|
|
3857
|
-
if (!part) return [];
|
|
3858
|
-
const out = [];
|
|
3859
|
-
for (const g of part.split(":")) {
|
|
3860
|
-
if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
|
|
3861
|
-
out.push(parseInt(g, 16));
|
|
3862
|
-
}
|
|
3863
|
-
return out;
|
|
3864
|
-
};
|
|
3865
|
-
const left = toGroups(lhs);
|
|
3866
|
-
if (left === null) return null;
|
|
3867
|
-
let right = [];
|
|
3868
|
-
if (rhs !== null) {
|
|
3869
|
-
const r = toGroups(rhs);
|
|
3870
|
-
if (r === null) return null;
|
|
3871
|
-
right = r;
|
|
3872
|
-
}
|
|
3873
|
-
if (tailV4) {
|
|
3874
|
-
if (rhs !== null) right = right.slice(0, -1).concat(tailV4);
|
|
3875
|
-
else left.splice(left.length - 1, 1, ...tailV4);
|
|
3876
|
-
}
|
|
3877
|
-
const groups = rhs === null ? left : left.concat(new Array(8 - left.length - right.length).fill(0), right);
|
|
3878
|
-
if (rhs === null && groups.length !== 8) return null;
|
|
3879
|
-
if (rhs !== null && left.length + right.length > 8) return null;
|
|
3880
|
-
if (groups.length !== 8) return null;
|
|
3881
|
-
return groups;
|
|
3882
|
-
}
|
|
3883
|
-
function compressIpv6(g) {
|
|
3884
|
-
let bestStart = -1;
|
|
3885
|
-
let bestLen = 0;
|
|
3886
|
-
let i = 0;
|
|
3887
|
-
while (i < 8) {
|
|
3888
|
-
if (g[i] !== 0) {
|
|
3889
|
-
i++;
|
|
3890
|
-
continue;
|
|
3891
|
-
}
|
|
3892
|
-
let j = i;
|
|
3893
|
-
while (j < 8 && g[j] === 0) j++;
|
|
3894
|
-
if (j - i > bestLen) {
|
|
3895
|
-
bestLen = j - i;
|
|
3896
|
-
bestStart = i;
|
|
3897
|
-
}
|
|
3898
|
-
i = j;
|
|
3899
|
-
}
|
|
3900
|
-
const hex = g.map((x) => x.toString(16));
|
|
3901
|
-
if (bestLen < 2) return hex.join(":");
|
|
3902
|
-
return hex.slice(0, bestStart).join(":") + "::" + hex.slice(bestStart + bestLen).join(":");
|
|
3903
|
-
}
|
|
3904
|
-
function normalizeIpLiteral(host) {
|
|
3905
|
-
try {
|
|
3906
|
-
if (typeof host !== "string") return null;
|
|
3907
|
-
let s = host.trim();
|
|
3908
|
-
if (!s || s.length > SSRF_MAX_HOST) return null;
|
|
3909
|
-
if (s.startsWith("[") && s.endsWith("]")) s = s.slice(1, -1);
|
|
3910
|
-
const zone = s.indexOf("%");
|
|
3911
|
-
if (zone >= 0) s = s.slice(0, zone);
|
|
3912
|
-
if (!s) return null;
|
|
3913
|
-
if (s.includes(":")) {
|
|
3914
|
-
const g = expandIpv6(s);
|
|
3915
|
-
if (!g) return null;
|
|
3916
|
-
const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 65535;
|
|
3917
|
-
if (mapped) {
|
|
3918
|
-
return [g[6] >> 8 & 255, g[6] & 255, g[7] >> 8 & 255, g[7] & 255].join(".");
|
|
3919
|
-
}
|
|
3920
|
-
return compressIpv6(g);
|
|
3921
|
-
}
|
|
3922
|
-
return parseIpv4(s);
|
|
3923
|
-
} catch {
|
|
3924
|
-
return null;
|
|
3925
|
-
}
|
|
3926
|
-
}
|
|
3927
|
-
var METADATA_ADDRESSES = /* @__PURE__ */ new Set([
|
|
3928
|
-
"169.254.169.254",
|
|
3929
|
-
// AWS / Azure / DigitalOcean / OpenStack IMDS
|
|
3930
|
-
"169.254.170.2",
|
|
3931
|
-
// AWS ECS task role
|
|
3932
|
-
"168.63.129.16",
|
|
3933
|
-
// Azure WireServer
|
|
3934
|
-
"fd00:ec2::254",
|
|
3935
|
-
// AWS IMDS over IPv6 (inside fc00::/7, which is NOT a tier)
|
|
3936
|
-
// Alibaba Cloud IMDS. It sits INSIDE 100.64/10, so before this line it was
|
|
3937
|
-
// classified as cgnat and therefore exemptable: one ssrfAllow entry opened a
|
|
3938
|
-
// live credential endpoint. It has to be named here, above the range check,
|
|
3939
|
-
// and it is the reason relaxing cgnat is safe.
|
|
3940
|
-
"100.100.100.200"
|
|
3941
|
-
]);
|
|
3942
|
-
var STRICT_TIERS = /* @__PURE__ */ new Set(["private", "unspecified", "cgnat"]);
|
|
3943
|
-
function isStrictGatedTier(tier) {
|
|
3944
|
-
return STRICT_TIERS.has(tier);
|
|
3945
|
-
}
|
|
3946
|
-
function ssrfReason(m, asWritten) {
|
|
3947
|
-
return `Blocked: ${asWritten} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== asWritten ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.");
|
|
3948
|
-
}
|
|
3949
|
-
var METADATA_HOSTNAMES = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.goog", "metadata"]);
|
|
3950
|
-
var v4Octets = (a) => {
|
|
3951
|
-
const p = a.split(".");
|
|
3952
|
-
return p.length === 4 ? p.map(Number) : null;
|
|
3953
|
-
};
|
|
3954
|
-
function classifySsrf(host) {
|
|
3955
|
-
try {
|
|
3956
|
-
if (typeof host !== "string" || !host) return null;
|
|
3957
|
-
const lower = host.trim().toLowerCase().replace(/\.$/, "");
|
|
3958
|
-
const ip = normalizeIpLiteral(host);
|
|
3959
|
-
if (ip === null) {
|
|
3960
|
-
return METADATA_HOSTNAMES.has(lower) ? { tier: "metadata", overridable: false, kind: "hostname" } : null;
|
|
3961
|
-
}
|
|
3962
|
-
const hit = (tier, overridable) => ({
|
|
3963
|
-
tier,
|
|
3964
|
-
overridable,
|
|
3965
|
-
kind: "address",
|
|
3966
|
-
normalized: ip
|
|
3967
|
-
});
|
|
3968
|
-
if (METADATA_ADDRESSES.has(ip)) return hit("metadata", false);
|
|
3969
|
-
const o = v4Octets(ip);
|
|
3970
|
-
if (o) {
|
|
3971
|
-
if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", true);
|
|
3972
|
-
if (o[0] === 169 && o[1] === 254) return hit("link-local", false);
|
|
3973
|
-
if (o[0] >= 224 && o[0] <= 239) return hit("multicast", false);
|
|
3974
|
-
if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return hit("cgnat", true);
|
|
3975
|
-
if (o[0] === 127) return hit("private", true);
|
|
3976
|
-
if (o[0] === 10) return hit("private", true);
|
|
3977
|
-
if (o[0] === 192 && o[1] === 168) return hit("private", true);
|
|
3978
|
-
if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return hit("private", true);
|
|
3979
|
-
return null;
|
|
3980
|
-
}
|
|
3981
|
-
const g = expandIpv6(ip);
|
|
3982
|
-
if (!g) return null;
|
|
3983
|
-
if (g.every((x) => x === 0)) return hit("unspecified", true);
|
|
3984
|
-
if ((g[0] & 65472) === 65152) return hit("link-local", false);
|
|
3985
|
-
if ((g[0] & 65280) === 65280) return hit("multicast", false);
|
|
3986
|
-
if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return hit("private", true);
|
|
3987
|
-
return null;
|
|
3988
|
-
} catch {
|
|
3989
|
-
return null;
|
|
3990
|
-
}
|
|
3991
|
-
}
|
|
3992
|
-
var TIER_REASON = {
|
|
3993
|
-
metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
|
|
3994
|
-
"link-local": "a link-local address",
|
|
3995
|
-
multicast: "a multicast address",
|
|
3996
|
-
unspecified: "the unspecified address, which reaches this host",
|
|
3997
|
-
cgnat: "a carrier-grade NAT address",
|
|
3998
|
-
private: "a loopback or private address"
|
|
3999
|
-
};
|
|
4000
|
-
function ssrfFloor(tokens, opts = {}) {
|
|
4001
|
-
const exempt = new Set(
|
|
4002
|
-
(opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
|
|
4003
|
-
);
|
|
4004
|
-
for (const { token, binary } of tokens) {
|
|
4005
|
-
const m = classifySsrf(token);
|
|
4006
|
-
if (!m) continue;
|
|
4007
|
-
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
4008
|
-
if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
|
|
4009
|
-
return { ...m, host: token, binary, reason: ssrfReason(m, token) };
|
|
4010
|
-
}
|
|
4011
|
-
return null;
|
|
4012
|
-
}
|
|
4013
4022
|
var DESTINATION_ARGS = /* @__PURE__ */ new Map([
|
|
4014
4023
|
["webfetch", ["url"]],
|
|
4015
4024
|
["fetch", ["url", "uri"]],
|
|
@@ -4050,6 +4059,22 @@ function hostOf(value) {
|
|
|
4050
4059
|
return null;
|
|
4051
4060
|
}
|
|
4052
4061
|
}
|
|
4062
|
+
function extractToolDestinations(toolName, args) {
|
|
4063
|
+
try {
|
|
4064
|
+
const paths = DESTINATION_ARGS.get(bareToolName(toolName));
|
|
4065
|
+
if (!paths) return [];
|
|
4066
|
+
const out = [];
|
|
4067
|
+
for (const path16 of paths) {
|
|
4068
|
+
for (const value of valuesAt(args, path16)) {
|
|
4069
|
+
const host = hostOf(value);
|
|
4070
|
+
if (host) out.push({ host, binary: toolName, raw: value });
|
|
4071
|
+
}
|
|
4072
|
+
}
|
|
4073
|
+
return out;
|
|
4074
|
+
} catch {
|
|
4075
|
+
return [];
|
|
4076
|
+
}
|
|
4077
|
+
}
|
|
4053
4078
|
function ssrfDestinationFloor(toolName, args, opts = {}) {
|
|
4054
4079
|
try {
|
|
4055
4080
|
const paths = DESTINATION_ARGS.get(bareToolName(toolName));
|
|
@@ -4157,6 +4182,16 @@ function pipeChainVerdict(command, isTrustedHost2, highAction = "review") {
|
|
|
4157
4182
|
tier: 3
|
|
4158
4183
|
};
|
|
4159
4184
|
}
|
|
4185
|
+
function egressPolicyVerdict(eg) {
|
|
4186
|
+
return {
|
|
4187
|
+
decision: eg.verdict,
|
|
4188
|
+
blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
|
|
4189
|
+
reason: eg.reason,
|
|
4190
|
+
ruleName: `egress:${eg.binary}:${eg.host}`,
|
|
4191
|
+
ruleDescription: eg.reason,
|
|
4192
|
+
tier: eg.verdict === "block" ? 3 : 4
|
|
4193
|
+
};
|
|
4194
|
+
}
|
|
4160
4195
|
async function evaluatePolicy(config, toolName, args, context = {}, hooks = {}) {
|
|
4161
4196
|
const { agent, cwd, activeEnvironment } = context;
|
|
4162
4197
|
const { checkProvenance: checkProvenance2, isTrustedHost: isTrustedHost2 } = hooks;
|
|
@@ -4191,7 +4226,14 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
4191
4226
|
};
|
|
4192
4227
|
}
|
|
4193
4228
|
}
|
|
4194
|
-
|
|
4229
|
+
const pendingToolEgress = config.policy.egress?.enabled ? (() => {
|
|
4230
|
+
const dests = extractToolDestinations(toolName, args);
|
|
4231
|
+
const eg = dests.length > 0 ? evaluateEgress(dests, config.policy.egress) : null;
|
|
4232
|
+
return eg ? egressPolicyVerdict(eg) : void 0;
|
|
4233
|
+
})() : void 0;
|
|
4234
|
+
if (wouldBeIgnored && !context.skipIgnoredFastPath) {
|
|
4235
|
+
return pendingToolEgress ?? { decision: "allow" };
|
|
4236
|
+
}
|
|
4195
4237
|
const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
|
|
4196
4238
|
const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
|
|
4197
4239
|
const bashCommand = agent !== "Terminal" ? shellShapedCommand : null;
|
|
@@ -4280,6 +4322,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
4280
4322
|
}
|
|
4281
4323
|
let allTokens = [];
|
|
4282
4324
|
let pathTokens = [];
|
|
4325
|
+
if (pendingToolEgress) return pendingToolEgress;
|
|
4283
4326
|
const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
|
|
4284
4327
|
if (shellCommand) {
|
|
4285
4328
|
const analyzed = analyzeShellCommand(shellCommand);
|
|
@@ -4345,16 +4388,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
4345
4388
|
const dests = extractShellDestinations(shellCommand);
|
|
4346
4389
|
if (dests.length > 0) {
|
|
4347
4390
|
const eg = evaluateEgress(dests, config.policy.egress);
|
|
4348
|
-
if (eg)
|
|
4349
|
-
return {
|
|
4350
|
-
decision: eg.verdict,
|
|
4351
|
-
blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
|
|
4352
|
-
reason: eg.reason,
|
|
4353
|
-
ruleName: `egress:${eg.binary}:${eg.host}`,
|
|
4354
|
-
ruleDescription: eg.reason,
|
|
4355
|
-
tier: eg.verdict === "block" ? 3 : 4
|
|
4356
|
-
};
|
|
4357
|
-
}
|
|
4391
|
+
if (eg) return egressPolicyVerdict(eg);
|
|
4358
4392
|
}
|
|
4359
4393
|
}
|
|
4360
4394
|
const firstToken = analyzed.actions[0] ?? "";
|
|
@@ -8294,8 +8328,10 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
|
8294
8328
|
};
|
|
8295
8329
|
}
|
|
8296
8330
|
}
|
|
8331
|
+
const declaredEgress = config.policy.egress?.enabled === true && extractToolDestinations(toolName, args).length > 0;
|
|
8332
|
+
const judge = !isIgnoredTool2(toolName) || declaredEgress;
|
|
8297
8333
|
if (isObserveMode) {
|
|
8298
|
-
if (
|
|
8334
|
+
if (judge) {
|
|
8299
8335
|
const policyResult = await evaluatePolicy2(toolName, args, meta?.agent, options?.cwd);
|
|
8300
8336
|
const wouldBlock = policyResult.decision === "block";
|
|
8301
8337
|
if (!isManual)
|
|
@@ -8320,7 +8356,7 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
|
8320
8356
|
return { approved: true, checkedBy: "audit" };
|
|
8321
8357
|
}
|
|
8322
8358
|
if (config.settings.mode === "audit") {
|
|
8323
|
-
if (
|
|
8359
|
+
if (judge) {
|
|
8324
8360
|
const policyResult = await evaluatePolicy2(toolName, args, meta?.agent, options?.cwd);
|
|
8325
8361
|
if (policyResult.decision === "review") {
|
|
8326
8362
|
appendLocalAudit(toolName, args, "allow", "audit-mode", meta, hashAuditArgs);
|
|
@@ -8359,9 +8395,9 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
|
8359
8395
|
appPermReviewTool = bareTool;
|
|
8360
8396
|
}
|
|
8361
8397
|
}
|
|
8362
|
-
if (!taintWarning &&
|
|
8398
|
+
if (!taintWarning && judge) {
|
|
8363
8399
|
const ld = config.policy.loopDetection;
|
|
8364
|
-
if (ld.enabled && !appPermReview) {
|
|
8400
|
+
if (ld.enabled && !appPermReview && !isIgnoredTool2(toolName)) {
|
|
8365
8401
|
const loopResult = recordAndCheck(toolName, args, ld.threshold, ld.windowSeconds * 1e3);
|
|
8366
8402
|
if (loopResult.looping) {
|
|
8367
8403
|
const reason = `It looks like you've called "${toolName}" ${loopResult.count} times with identical arguments in the last ${ld.windowSeconds}s. Are you stuck? Step back and reconsider your approach \u2014 what are you actually trying to accomplish, and is there a different way to get there?`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@node9/proxy",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.17.0",
|
|
4
4
|
"description": "IAM for your AI agents. Set what Claude Code, Codex, Gemini, Cursor and any MCP server are allowed to do, review risky actions before they run, and keep every action on the record.",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.mjs",
|