@node9/proxy 2.16.2 → 2.18.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/README.md +76 -331
- package/dist/cli.js +188 -70
- package/dist/cli.mjs +188 -70
- package/dist/dashboard.mjs +4 -2
- package/dist/index.js +90 -29
- package/dist/index.mjs +90 -29
- package/package.json +1 -1
package/dist/cli.mjs
CHANGED
|
@@ -2262,32 +2262,61 @@ function classifySsrf(host) {
|
|
|
2262
2262
|
return null;
|
|
2263
2263
|
}
|
|
2264
2264
|
}
|
|
2265
|
+
function ssrfExemptMatches(entries, normalized) {
|
|
2266
|
+
if (!entries?.length || !normalized) return false;
|
|
2267
|
+
const target = bitsOf(normalized);
|
|
2268
|
+
for (const raw of entries) {
|
|
2269
|
+
const entry = raw.trim().toLowerCase();
|
|
2270
|
+
if (!entry) continue;
|
|
2271
|
+
const slash = entry.indexOf("/");
|
|
2272
|
+
if (slash === -1) {
|
|
2273
|
+
if ((normalizeIpLiteral(entry) ?? entry) === normalized) return true;
|
|
2274
|
+
continue;
|
|
2275
|
+
}
|
|
2276
|
+
if (!target) continue;
|
|
2277
|
+
const base = bitsOf(normalizeIpLiteral(entry.slice(0, slash)) ?? "");
|
|
2278
|
+
const prefixText = entry.slice(slash + 1);
|
|
2279
|
+
const prefix = /^\d+$/.test(prefixText) ? Number(prefixText) : NaN;
|
|
2280
|
+
if (!base || !Number.isInteger(prefix) || prefix < 0 || // A v4 range never matches a v6 address, and the reverse: the widths
|
|
2281
|
+
// differ, so `0.0.0.0/0` does not release `::1`.
|
|
2282
|
+
base.length !== target.length || prefix > base.length) {
|
|
2283
|
+
continue;
|
|
2284
|
+
}
|
|
2285
|
+
if (base.slice(0, prefix) === target.slice(0, prefix)) return true;
|
|
2286
|
+
}
|
|
2287
|
+
return false;
|
|
2288
|
+
}
|
|
2289
|
+
function bitsOf(normalized) {
|
|
2290
|
+
const o = v4Octets(normalized);
|
|
2291
|
+
if (o) {
|
|
2292
|
+
return o.every((n) => Number.isInteger(n) && n >= 0 && n <= 255) ? o.map((n) => n.toString(2).padStart(8, "0")).join("") : null;
|
|
2293
|
+
}
|
|
2294
|
+
const g = expandIpv6(normalized);
|
|
2295
|
+
return g ? g.map((n) => n.toString(2).padStart(16, "0")).join("") : null;
|
|
2296
|
+
}
|
|
2265
2297
|
function ssrfFloor(tokens, opts = {}) {
|
|
2266
|
-
const exempt2 = new Set(
|
|
2267
|
-
(opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
|
|
2268
|
-
);
|
|
2269
2298
|
for (const { token, binary } of tokens) {
|
|
2270
2299
|
const m = classifySsrf(token);
|
|
2271
2300
|
if (!m) continue;
|
|
2272
2301
|
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
2273
|
-
if (m.overridable &&
|
|
2302
|
+
if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
|
|
2274
2303
|
return { ...m, host: token, binary, reason: ssrfReason(m, token) };
|
|
2275
2304
|
}
|
|
2276
2305
|
return null;
|
|
2277
2306
|
}
|
|
2278
2307
|
function hostMatches(host, pattern) {
|
|
2279
|
-
const
|
|
2280
|
-
const p = pattern.toLowerCase().trim();
|
|
2308
|
+
const p = pattern.trim().toLowerCase();
|
|
2281
2309
|
if (!p) return false;
|
|
2282
2310
|
if (p === "*") return true;
|
|
2311
|
+
const h = canonicalHost(host);
|
|
2283
2312
|
if (p.startsWith("*.")) {
|
|
2284
|
-
const suffix = p.slice(2);
|
|
2313
|
+
const suffix = canonicalHost(p.slice(2));
|
|
2285
2314
|
return h === suffix || h.endsWith("." + suffix);
|
|
2286
2315
|
}
|
|
2287
|
-
return h === p;
|
|
2316
|
+
return h === canonicalHost(p);
|
|
2288
2317
|
}
|
|
2289
2318
|
function matchesAny(host, patterns) {
|
|
2290
|
-
for (const p of patterns) if (hostMatches(host, p)) return true;
|
|
2319
|
+
for (const p of patterns ?? []) if (hostMatches(host, p)) return true;
|
|
2291
2320
|
return false;
|
|
2292
2321
|
}
|
|
2293
2322
|
function isUniqueLocalV6(host) {
|
|
@@ -2297,13 +2326,17 @@ function isUniqueLocalV6(host) {
|
|
|
2297
2326
|
return g !== null && (g[0] & 65024) === 64512;
|
|
2298
2327
|
}
|
|
2299
2328
|
function isPrivateHost(host) {
|
|
2300
|
-
const h = host.trim().toLowerCase();
|
|
2329
|
+
const h = host.trim().toLowerCase().replace(/\.$/, "");
|
|
2301
2330
|
const m = classifySsrf(h);
|
|
2302
2331
|
if (m) return m.kind === "address" && (m.tier === "private" || m.tier === "unspecified");
|
|
2303
2332
|
if (h === "localhost") return true;
|
|
2304
2333
|
if (PRIVATE_HOST_SUFFIXES.some((s) => h.endsWith(s))) return true;
|
|
2305
2334
|
return isUniqueLocalV6(h);
|
|
2306
2335
|
}
|
|
2336
|
+
function canonicalHost(host) {
|
|
2337
|
+
const ip = normalizeIpLiteral(host);
|
|
2338
|
+
return ip ?? host.trim().toLowerCase().replace(/\.$/, "");
|
|
2339
|
+
}
|
|
2307
2340
|
function evaluateEgress(dests, policy) {
|
|
2308
2341
|
if (!policy.enabled) return null;
|
|
2309
2342
|
let review = null;
|
|
@@ -2555,11 +2588,26 @@ function hostOf(value) {
|
|
|
2555
2588
|
return null;
|
|
2556
2589
|
}
|
|
2557
2590
|
}
|
|
2591
|
+
function extractToolDestinations(toolName, args) {
|
|
2592
|
+
try {
|
|
2593
|
+
const paths = DESTINATION_ARGS.get(bareToolName(toolName));
|
|
2594
|
+
if (!paths) return [];
|
|
2595
|
+
const out = [];
|
|
2596
|
+
for (const path78 of paths) {
|
|
2597
|
+
for (const value of valuesAt(args, path78)) {
|
|
2598
|
+
const host = hostOf(value);
|
|
2599
|
+
if (host) out.push({ host, binary: toolName, raw: value });
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
return out;
|
|
2603
|
+
} catch {
|
|
2604
|
+
return [];
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2558
2607
|
function ssrfDestinationFloor(toolName, args, opts = {}) {
|
|
2559
2608
|
try {
|
|
2560
2609
|
const paths = DESTINATION_ARGS.get(bareToolName(toolName));
|
|
2561
2610
|
if (!paths) return null;
|
|
2562
|
-
const exempt2 = new Set((opts.ssrfAllow ?? []).map((a) => classifySsrf(a)?.normalized ?? a));
|
|
2563
2611
|
for (const path78 of paths) {
|
|
2564
2612
|
for (const value of valuesAt(args, path78)) {
|
|
2565
2613
|
const host = hostOf(value);
|
|
@@ -2567,7 +2615,7 @@ function ssrfDestinationFloor(toolName, args, opts = {}) {
|
|
|
2567
2615
|
const m = classifySsrf(host);
|
|
2568
2616
|
if (!m) continue;
|
|
2569
2617
|
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
2570
|
-
if (m.overridable &&
|
|
2618
|
+
if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
|
|
2571
2619
|
return { ...m, argPath: path78, host, reason: ssrfReason(m, host) };
|
|
2572
2620
|
}
|
|
2573
2621
|
}
|
|
@@ -2656,6 +2704,16 @@ function pipeChainVerdict(command, isTrustedHost2, highAction = "review") {
|
|
|
2656
2704
|
tier: 3
|
|
2657
2705
|
};
|
|
2658
2706
|
}
|
|
2707
|
+
function egressPolicyVerdict(eg) {
|
|
2708
|
+
return {
|
|
2709
|
+
decision: eg.verdict,
|
|
2710
|
+
blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
|
|
2711
|
+
reason: eg.reason,
|
|
2712
|
+
ruleName: `egress:${eg.binary}:${eg.host}`,
|
|
2713
|
+
ruleDescription: eg.reason,
|
|
2714
|
+
tier: eg.verdict === "block" ? 3 : 4
|
|
2715
|
+
};
|
|
2716
|
+
}
|
|
2659
2717
|
async function evaluatePolicy(config, toolName, args, context = {}, hooks = {}) {
|
|
2660
2718
|
const { agent, cwd, activeEnvironment } = context;
|
|
2661
2719
|
const { checkProvenance: checkProvenance2, isTrustedHost: isTrustedHost2 } = hooks;
|
|
@@ -2690,7 +2748,14 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
2690
2748
|
};
|
|
2691
2749
|
}
|
|
2692
2750
|
}
|
|
2693
|
-
|
|
2751
|
+
const pendingToolEgress = config.policy.egress?.enabled ? (() => {
|
|
2752
|
+
const dests = extractToolDestinations(toolName, args);
|
|
2753
|
+
const eg = dests.length > 0 ? evaluateEgress(dests, config.policy.egress) : null;
|
|
2754
|
+
return eg ? egressPolicyVerdict(eg) : void 0;
|
|
2755
|
+
})() : void 0;
|
|
2756
|
+
if (wouldBeIgnored && !context.skipIgnoredFastPath) {
|
|
2757
|
+
return pendingToolEgress ?? { decision: "allow" };
|
|
2758
|
+
}
|
|
2694
2759
|
const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
|
|
2695
2760
|
const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
|
|
2696
2761
|
const bashCommand = agent !== "Terminal" ? shellShapedCommand : null;
|
|
@@ -2779,6 +2844,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
2779
2844
|
}
|
|
2780
2845
|
let allTokens = [];
|
|
2781
2846
|
let pathTokens = [];
|
|
2847
|
+
if (pendingToolEgress) return pendingToolEgress;
|
|
2782
2848
|
const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
|
|
2783
2849
|
if (shellCommand) {
|
|
2784
2850
|
const analyzed = analyzeShellCommand(shellCommand);
|
|
@@ -2844,16 +2910,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
2844
2910
|
const dests = extractShellDestinations(shellCommand);
|
|
2845
2911
|
if (dests.length > 0) {
|
|
2846
2912
|
const eg = evaluateEgress(dests, config.policy.egress);
|
|
2847
|
-
if (eg)
|
|
2848
|
-
return {
|
|
2849
|
-
decision: eg.verdict,
|
|
2850
|
-
blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
|
|
2851
|
-
reason: eg.reason,
|
|
2852
|
-
ruleName: `egress:${eg.binary}:${eg.host}`,
|
|
2853
|
-
ruleDescription: eg.reason,
|
|
2854
|
-
tier: eg.verdict === "block" ? 3 : 4
|
|
2855
|
-
};
|
|
2856
|
-
}
|
|
2913
|
+
if (eg) return egressPolicyVerdict(eg);
|
|
2857
2914
|
}
|
|
2858
2915
|
}
|
|
2859
2916
|
const firstToken = analyzed.actions[0] ?? "";
|
|
@@ -6777,10 +6834,12 @@ import os4 from "os";
|
|
|
6777
6834
|
function sanitizeSsrfAllow(entries, source) {
|
|
6778
6835
|
const kept = [];
|
|
6779
6836
|
for (const entry of entries) {
|
|
6780
|
-
const
|
|
6837
|
+
const base = entry.includes("/") ? entry.slice(0, entry.indexOf("/")).trim() : entry;
|
|
6838
|
+
const m = classifySsrf(base);
|
|
6781
6839
|
if (m && !m.overridable) {
|
|
6840
|
+
const what = entry.includes("/") ? "covers only protected addresses" : "is a protected address";
|
|
6782
6841
|
process.emitWarning(
|
|
6783
|
-
`[node9] ${source} ssrfAllow entry "${entry}"
|
|
6842
|
+
`[node9] ${source} ssrfAllow entry "${entry}" ${what} (${m.tier}) and cannot be exempted; ignoring it.`
|
|
6784
6843
|
);
|
|
6785
6844
|
continue;
|
|
6786
6845
|
}
|
|
@@ -9946,8 +10005,10 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
|
9946
10005
|
};
|
|
9947
10006
|
}
|
|
9948
10007
|
}
|
|
10008
|
+
const declaredEgress = config.policy.egress?.enabled === true && extractToolDestinations(toolName, args).length > 0;
|
|
10009
|
+
const judge = !isIgnoredTool2(toolName) || declaredEgress;
|
|
9949
10010
|
if (isObserveMode) {
|
|
9950
|
-
if (
|
|
10011
|
+
if (judge) {
|
|
9951
10012
|
const policyResult = await evaluatePolicy2(toolName, args, meta?.agent, options?.cwd);
|
|
9952
10013
|
const wouldBlock = policyResult.decision === "block";
|
|
9953
10014
|
if (!isManual)
|
|
@@ -9972,7 +10033,7 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
|
9972
10033
|
return { approved: true, checkedBy: "audit" };
|
|
9973
10034
|
}
|
|
9974
10035
|
if (config.settings.mode === "audit") {
|
|
9975
|
-
if (
|
|
10036
|
+
if (judge) {
|
|
9976
10037
|
const policyResult = await evaluatePolicy2(toolName, args, meta?.agent, options?.cwd);
|
|
9977
10038
|
if (policyResult.decision === "review") {
|
|
9978
10039
|
appendLocalAudit(toolName, args, "allow", "audit-mode", meta, hashAuditArgs);
|
|
@@ -10011,9 +10072,9 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
|
10011
10072
|
appPermReviewTool = bareTool;
|
|
10012
10073
|
}
|
|
10013
10074
|
}
|
|
10014
|
-
if (!taintWarning &&
|
|
10075
|
+
if (!taintWarning && judge) {
|
|
10015
10076
|
const ld = config.policy.loopDetection;
|
|
10016
|
-
if (ld.enabled && !appPermReview) {
|
|
10077
|
+
if (ld.enabled && !appPermReview && !isIgnoredTool2(toolName)) {
|
|
10017
10078
|
const loopResult = recordAndCheck(toolName, args, ld.threshold, ld.windowSeconds * 1e3);
|
|
10018
10079
|
if (loopResult.looping) {
|
|
10019
10080
|
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?`;
|
|
@@ -20011,19 +20072,19 @@ function evaluateEgressConfig(egress) {
|
|
|
20011
20072
|
function checkEgressFloor(egress) {
|
|
20012
20073
|
const detail = [
|
|
20013
20074
|
"the cloud instance-metadata endpoint",
|
|
20014
|
-
"link-local
|
|
20015
|
-
egress.ssrfStrict ? "the strict tier is on: loopback
|
|
20075
|
+
"link-local and multicast addresses",
|
|
20076
|
+
egress.ssrfStrict ? "the strict tier is on: loopback, the private ranges and CGNAT are blocked too" : `the strict tier is off: loopback, the private ranges and CGNAT (100.64/10) stay reachable (${egress.policySource === "workspace" ? "turn it on in the dashboard, Enforcement \u2192 Network" : "`node9 egress strict on`"})`
|
|
20016
20077
|
];
|
|
20017
20078
|
if (egress.ssrfAllow.length) {
|
|
20018
20079
|
detail.push(`you exempted: ${egress.ssrfAllow.join(", ")}`);
|
|
20019
20080
|
}
|
|
20020
|
-
detail.push("not covered:
|
|
20081
|
+
detail.push("not covered: an interpreter one-liner (node -e, python3 -c) hides its destination");
|
|
20021
20082
|
return [
|
|
20022
20083
|
{
|
|
20023
20084
|
category: "Egress",
|
|
20024
20085
|
severity: "advisory",
|
|
20025
|
-
title: "The cloud metadata endpoint is blocked
|
|
20026
|
-
what: "node9 blocks it before any egress policy is consulted, and no setting releases it. It sees shell commands (curl, wget, ssh)
|
|
20086
|
+
title: "The cloud metadata endpoint is blocked",
|
|
20087
|
+
what: "node9 blocks it before any egress policy is consulted, and no setting releases it. It sees shell commands (curl, wget, ssh) and tools that declare a URL (WebFetch, an MCP fetch tool, browser navigate); an interpreter one-liner does not reach it.",
|
|
20027
20088
|
why: "One request to that address returns this machine's cloud credentials, to anyone who can make the agent send it.",
|
|
20028
20089
|
who: "An agent talked into fetching that address hands over the keys and cannot, here.",
|
|
20029
20090
|
owner: "node9",
|
|
@@ -57062,10 +57123,11 @@ function addEgressHost(list, host) {
|
|
|
57062
57123
|
writeEgressRawConfig(config);
|
|
57063
57124
|
}
|
|
57064
57125
|
function addSsrfExemption(address) {
|
|
57065
|
-
const
|
|
57126
|
+
const slash = address.indexOf("/");
|
|
57127
|
+
const m = classifySsrf(slash === -1 ? address : address.slice(0, slash));
|
|
57066
57128
|
if (m && !m.overridable) {
|
|
57067
57129
|
throw new Error(
|
|
57068
|
-
`${address} is a protected address (${m.tier}) and cannot be exempted by anyone. This is the one part of the floor no setting releases.`
|
|
57130
|
+
`${address} ${slash === -1 ? "is a protected address" : "covers only protected addresses"} (${m.tier}) and cannot be exempted by anyone. This is the one part of the floor no setting releases.`
|
|
57069
57131
|
);
|
|
57070
57132
|
}
|
|
57071
57133
|
const config = readEgressRawConfig();
|
|
@@ -57337,7 +57399,7 @@ var TOOLS = [
|
|
|
57337
57399
|
},
|
|
57338
57400
|
{
|
|
57339
57401
|
name: "node9_egress_status",
|
|
57340
|
-
description: "Show egress (outbound network) control: whether it is enabled, the mode (off / review / block), and your allow + deny host lists. Common dev/LLM hosts (github, npm, pypi, anthropic, \u2026) are always allowed by a built-in list. Also reports the SSRF floor: the addresses blocked
|
|
57402
|
+
description: "Show egress (outbound network) control: whether it is enabled, the mode (off / review / block), and your allow + deny host lists. Common dev/LLM hosts (github, npm, pypi, anthropic, \u2026) are always allowed by a built-in list. Also reports the SSRF floor: the addresses blocked before any policy is consulted (cloud metadata, link-local, multicast), the carriers it covers, whether the strict tier (loopback, private ranges, CGNAT) is on, and the exemptions in force. Read-only.",
|
|
57341
57403
|
inputSchema: { type: "object", properties: {}, required: [] }
|
|
57342
57404
|
},
|
|
57343
57405
|
{
|
|
@@ -57497,11 +57559,12 @@ function handleEgressStatus() {
|
|
|
57497
57559
|
// The SSRF floor. Without these lines an agent reading this answer
|
|
57498
57560
|
// concludes that internal addresses are reachable, because nothing said
|
|
57499
57561
|
// otherwise. The LIMITS are here for the same reason and matter more on
|
|
57500
|
-
// this surface than on any other: an agent treats this as ground truth
|
|
57501
|
-
//
|
|
57502
|
-
// shell
|
|
57503
|
-
|
|
57504
|
-
"
|
|
57562
|
+
// this surface than on any other: an agent treats this as ground truth.
|
|
57563
|
+
// The first version said the floor was absolute; the correction said it
|
|
57564
|
+
// was shell-only; both were wrong by the time they were read. What it
|
|
57565
|
+
// actually covers is measured in egress.integration.test.ts.
|
|
57566
|
+
"Protected addresses: cloud metadata, link-local and multicast are blocked before any of the above is consulted, in shell commands AND in tools that declare a URL (WebFetch, an MCP fetch tool, browser navigate). No setting releases them, though `node9 pause` suspends all enforcement.",
|
|
57567
|
+
"NOT covered by that: an interpreter one-liner (node -e, python3 -c) carries its destination inside a program and does not reach this gate. CGNAT (100.64/10) is NOT in the always-blocked set \u2014 it is reachable until the strict tier is on, and an exemption can release it.",
|
|
57505
57568
|
`Internal addresses: ${e.ssrfStrict ? "on" : "off"} \u2014 loopback and the private ranges are ${e.ssrfStrict ? "blocked too" : "reachable"}.`,
|
|
57506
57569
|
`Floor exemptions: ${e.ssrfAllow?.length ? e.ssrfAllow.join(", ") : "(none)"}`
|
|
57507
57570
|
];
|
|
@@ -60260,26 +60323,76 @@ function exempt(address) {
|
|
|
60260
60323
|
if (!cliGuardPolicyWrite(`egress exempt ${address}`)) return false;
|
|
60261
60324
|
return guard(() => addSsrfExemption(address));
|
|
60262
60325
|
}
|
|
60326
|
+
var INTERNAL_FIELDS = {
|
|
60327
|
+
allowed: { ssrfStrict: false, allowPrivate: true },
|
|
60328
|
+
listed: { ssrfStrict: false, allowPrivate: false },
|
|
60329
|
+
blocked: { ssrfStrict: true, allowPrivate: false }
|
|
60330
|
+
};
|
|
60331
|
+
var INTERNAL_SAID = {
|
|
60332
|
+
allowed: "reachable without listing them",
|
|
60333
|
+
listed: "reachable only if they are on your allowlist",
|
|
60334
|
+
blocked: "blocked"
|
|
60335
|
+
};
|
|
60336
|
+
function readInternalState(e) {
|
|
60337
|
+
if (e.ssrfStrict === true) return "blocked";
|
|
60338
|
+
return e.allowPrivate === false ? "listed" : "allowed";
|
|
60339
|
+
}
|
|
60340
|
+
function setInternal(value) {
|
|
60341
|
+
const state = value.trim().toLowerCase();
|
|
60342
|
+
if (!(state in INTERNAL_FIELDS)) {
|
|
60343
|
+
console.error(
|
|
60344
|
+
chalk34.red(`
|
|
60345
|
+
\u2717 Expected "allowed", "listed" or "blocked", got "${value}".`) + chalk34.gray(
|
|
60346
|
+
"\n allowed loopback, 10/172.16/192.168 and CGNAT are reachable (default)\n listed they are reachable only if you allowlist them\n blocked they are blocked at the floor\n"
|
|
60347
|
+
)
|
|
60348
|
+
);
|
|
60349
|
+
process.exitCode = 1;
|
|
60350
|
+
return;
|
|
60351
|
+
}
|
|
60352
|
+
if (!mutate(`egress internal ${state}`, INTERNAL_FIELDS[state])) return;
|
|
60353
|
+
_resetConfigCache();
|
|
60354
|
+
const effective = readInternalState(getConfig().policy.egress);
|
|
60355
|
+
if (effective !== state) {
|
|
60356
|
+
console.log(
|
|
60357
|
+
chalk34.yellow(
|
|
60358
|
+
`
|
|
60359
|
+
\u26A0 Saved, but not in effect: your workspace sets internal addresses to ${effective.toUpperCase()} and that governs this machine.
|
|
60360
|
+
Change it in the dashboard, Enforcement \u2192 Network.
|
|
60361
|
+
`
|
|
60362
|
+
)
|
|
60363
|
+
);
|
|
60364
|
+
return;
|
|
60365
|
+
}
|
|
60366
|
+
const line = `
|
|
60367
|
+
\u2713 Internal addresses: ${state} \u2014 loopback, the private ranges and CGNAT are ${INTERNAL_SAID[state]}.
|
|
60368
|
+
`;
|
|
60369
|
+
console.log(state === "allowed" ? chalk34.yellow(line) : chalk34.green(line));
|
|
60370
|
+
}
|
|
60263
60371
|
function showFloor(e, ssrfStrictSource) {
|
|
60264
|
-
console.log(
|
|
60372
|
+
console.log(
|
|
60373
|
+
chalk34.gray("\n Protected addresses") + chalk34.gray(" \u2014 in shell commands and declared URLs")
|
|
60374
|
+
);
|
|
60265
60375
|
console.log(
|
|
60266
60376
|
chalk34.gray(
|
|
60267
|
-
" always blocked: cloud metadata, link-local, multicast
|
|
60377
|
+
" always blocked: cloud metadata, link-local, multicast\n no setting releases these, though `node9 pause` suspends all enforcement"
|
|
60268
60378
|
)
|
|
60269
60379
|
);
|
|
60270
|
-
const
|
|
60380
|
+
const state = readInternalState(e);
|
|
60271
60381
|
const by = ssrfStrictSource === "workspace" ? "workspace (app.node9.ai)" : ssrfStrictSource === "local" ? "this machine (config.json)" : "the shipped default";
|
|
60382
|
+
const STATE_LABEL = {
|
|
60383
|
+
allowed: "allowed",
|
|
60384
|
+
listed: "allowlist only",
|
|
60385
|
+
blocked: "blocked"
|
|
60386
|
+
};
|
|
60272
60387
|
console.log(
|
|
60273
|
-
` Internal addresses: ${
|
|
60274
|
-
strict ? " loopback and 10/172.16/192.168 are blocked too" : " loopback and 10/172.16/192.168 are reachable"
|
|
60275
|
-
)
|
|
60388
|
+
` Internal addresses: ${state === "blocked" ? chalk34.green(STATE_LABEL[state]) : chalk34.yellow(STATE_LABEL[state])}` + chalk34.gray(` loopback, 10/172.16/192.168 and CGNAT are ${INTERNAL_SAID[state]}`)
|
|
60276
60389
|
);
|
|
60277
60390
|
console.log(chalk34.gray(` set by: ${by}`));
|
|
60278
60391
|
const exemptions = e.ssrfAllow ?? [];
|
|
60279
60392
|
console.log(chalk34.gray(` Exemptions: ${exemptions.length ? exemptions.join(", ") : "none"}`));
|
|
60280
60393
|
console.log(
|
|
60281
60394
|
chalk34.gray(
|
|
60282
|
-
"
|
|
60395
|
+
" Covered: shell commands, and tools that declare a URL (WebFetch, an MCP\n fetch tool, browser navigate).\n Not covered: an interpreter one-liner (node -e, python3 -c) hides its\n destination inside a program and does not reach this gate.\n CGNAT (100.64/10) is NOT in the always-blocked set: it is reachable\n until Internal addresses is on, and an exemption can release it."
|
|
60283
60396
|
)
|
|
60284
60397
|
);
|
|
60285
60398
|
}
|
|
@@ -60344,7 +60457,10 @@ function registerEgressCommand(program2) {
|
|
|
60344
60457
|
chalk34.yellow("\n\u2713 Egress control is off \u2014 the agent can reach any host again.\n")
|
|
60345
60458
|
);
|
|
60346
60459
|
});
|
|
60347
|
-
egress.command("
|
|
60460
|
+
egress.command("internal <allowed|listed|blocked>").description(
|
|
60461
|
+
"How loopback, private ranges and CGNAT are treated: allowed (default), listed (must be on the allowlist), or blocked"
|
|
60462
|
+
).action((value) => setInternal(value));
|
|
60463
|
+
egress.command("strict <on|off>").description("Deprecated alias for `egress internal blocked|allowed`").action((value) => {
|
|
60348
60464
|
const v = value.trim().toLowerCase();
|
|
60349
60465
|
if (v !== "on" && v !== "off") {
|
|
60350
60466
|
console.error(chalk34.red(`
|
|
@@ -60353,38 +60469,40 @@ function registerEgressCommand(program2) {
|
|
|
60353
60469
|
process.exitCode = 1;
|
|
60354
60470
|
return;
|
|
60355
60471
|
}
|
|
60356
|
-
|
|
60357
|
-
|
|
60358
|
-
|
|
60359
|
-
|
|
60360
|
-
|
|
60361
|
-
|
|
60362
|
-
|
|
60363
|
-
|
|
60364
|
-
|
|
60365
|
-
|
|
60472
|
+
const state = v === "on" ? "blocked" : getConfig().policy.egress.allowPrivate === false ? "listed" : "allowed";
|
|
60473
|
+
console.log(chalk34.gray(`
|
|
60474
|
+
(\`egress strict ${v}\` is now \`egress internal ${state}\`)`));
|
|
60475
|
+
setInternal(state);
|
|
60476
|
+
});
|
|
60477
|
+
egress.command("exempt <address>").description("Let an address or a CIDR range through the floor (e.g. 100.64.0.0/10)").action((address) => {
|
|
60478
|
+
const a = normalizeEgressHost(address);
|
|
60479
|
+
const slash = a.indexOf("/");
|
|
60480
|
+
const base = slash === -1 ? a : a.slice(0, slash);
|
|
60481
|
+
const prefixText = slash === -1 ? null : a.slice(slash + 1);
|
|
60482
|
+
if (!normalizeIpLiteral(base) || prefixText !== null && !/^\d{1,3}$/.test(prefixText)) {
|
|
60483
|
+
console.error(
|
|
60484
|
+
chalk34.red(`
|
|
60485
|
+
\u2717 "${address}" is not an address or a range.`) + chalk34.gray(
|
|
60486
|
+
"\n Exemptions are matched as an address (10.0.0.5) or a CIDR\n range (100.64.0.0/10), never a name.\n"
|
|
60366
60487
|
)
|
|
60367
60488
|
);
|
|
60489
|
+
process.exitCode = 1;
|
|
60368
60490
|
return;
|
|
60369
60491
|
}
|
|
60370
|
-
|
|
60371
|
-
|
|
60372
|
-
);
|
|
60373
|
-
});
|
|
60374
|
-
egress.command("exempt <address>").description("Let ONE address through the floor (exact address, not a range)").action((address) => {
|
|
60375
|
-
const a = normalizeEgressHost(address);
|
|
60376
|
-
if (!normalizeIpLiteral(a)) {
|
|
60492
|
+
const m = classifySsrf(base);
|
|
60493
|
+
if (m && !m.overridable) {
|
|
60377
60494
|
console.error(
|
|
60378
60495
|
chalk34.red(`
|
|
60379
|
-
\u2717
|
|
60380
|
-
|
|
60496
|
+
\u2717 ${a} cannot be exempted.`) + chalk34.gray(
|
|
60497
|
+
`
|
|
60498
|
+
${slash === -1 ? "That address is" : "That range covers only"} protected addresses (${m.tier}), which no setting releases.
|
|
60499
|
+
`
|
|
60381
60500
|
)
|
|
60382
60501
|
);
|
|
60383
60502
|
process.exitCode = 1;
|
|
60384
60503
|
return;
|
|
60385
60504
|
}
|
|
60386
60505
|
if (!exempt(a)) return;
|
|
60387
|
-
const m = classifySsrf(a);
|
|
60388
60506
|
console.log(chalk34.green(`
|
|
60389
60507
|
\u2713 ${a} is exempt from the floor.`));
|
|
60390
60508
|
if (!m)
|
package/dist/dashboard.mjs
CHANGED
|
@@ -4717,10 +4717,12 @@ import os7 from "os";
|
|
|
4717
4717
|
function sanitizeSsrfAllow(entries, source) {
|
|
4718
4718
|
const kept = [];
|
|
4719
4719
|
for (const entry of entries) {
|
|
4720
|
-
const
|
|
4720
|
+
const base = entry.includes("/") ? entry.slice(0, entry.indexOf("/")).trim() : entry;
|
|
4721
|
+
const m = classifySsrf(base);
|
|
4721
4722
|
if (m && !m.overridable) {
|
|
4723
|
+
const what = entry.includes("/") ? "covers only protected addresses" : "is a protected address";
|
|
4722
4724
|
process.emitWarning(
|
|
4723
|
-
`[node9] ${source} ssrfAllow entry "${entry}"
|
|
4725
|
+
`[node9] ${source} ssrfAllow entry "${entry}" ${what} (${m.tier}) and cannot be exempted; ignoring it.`
|
|
4724
4726
|
);
|
|
4725
4727
|
continue;
|
|
4726
4728
|
}
|