@node9/proxy 2.17.0 → 2.19.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 +143 -51
- package/dist/cli.mjs +143 -51
- package/dist/dashboard.mjs +4 -2
- package/dist/index.js +42 -9
- package/dist/index.mjs +42 -9
- package/package.json +1 -1
package/dist/cli.mjs
CHANGED
|
@@ -2262,15 +2262,44 @@ 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;
|
|
@@ -2579,7 +2608,6 @@ function ssrfDestinationFloor(toolName, args, opts = {}) {
|
|
|
2579
2608
|
try {
|
|
2580
2609
|
const paths = DESTINATION_ARGS.get(bareToolName(toolName));
|
|
2581
2610
|
if (!paths) return null;
|
|
2582
|
-
const exempt2 = new Set((opts.ssrfAllow ?? []).map((a) => classifySsrf(a)?.normalized ?? a));
|
|
2583
2611
|
for (const path78 of paths) {
|
|
2584
2612
|
for (const value of valuesAt(args, path78)) {
|
|
2585
2613
|
const host = hostOf(value);
|
|
@@ -2587,7 +2615,7 @@ function ssrfDestinationFloor(toolName, args, opts = {}) {
|
|
|
2587
2615
|
const m = classifySsrf(host);
|
|
2588
2616
|
if (!m) continue;
|
|
2589
2617
|
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
2590
|
-
if (m.overridable &&
|
|
2618
|
+
if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
|
|
2591
2619
|
return { ...m, argPath: path78, host, reason: ssrfReason(m, host) };
|
|
2592
2620
|
}
|
|
2593
2621
|
}
|
|
@@ -6806,10 +6834,12 @@ import os4 from "os";
|
|
|
6806
6834
|
function sanitizeSsrfAllow(entries, source) {
|
|
6807
6835
|
const kept = [];
|
|
6808
6836
|
for (const entry of entries) {
|
|
6809
|
-
const
|
|
6837
|
+
const base = entry.includes("/") ? entry.slice(0, entry.indexOf("/")).trim() : entry;
|
|
6838
|
+
const m = classifySsrf(base);
|
|
6810
6839
|
if (m && !m.overridable) {
|
|
6840
|
+
const what = entry.includes("/") ? "covers only protected addresses" : "is a protected address";
|
|
6811
6841
|
process.emitWarning(
|
|
6812
|
-
`[node9] ${source} ssrfAllow entry "${entry}"
|
|
6842
|
+
`[node9] ${source} ssrfAllow entry "${entry}" ${what} (${m.tier}) and cannot be exempted; ignoring it.`
|
|
6813
6843
|
);
|
|
6814
6844
|
continue;
|
|
6815
6845
|
}
|
|
@@ -9747,6 +9777,9 @@ async function authorizeHeadless(toolName, args, meta, options) {
|
|
|
9747
9777
|
}
|
|
9748
9778
|
return _authorizeHeadlessCore(toolName, args, meta, options);
|
|
9749
9779
|
}
|
|
9780
|
+
function approvalTimeoutReason(approvalTimeoutMs) {
|
|
9781
|
+
return `No human response within ${approvalTimeoutMs / 1e3}s \u2014 auto-denied by timeout policy. Approve from your phone next time: run \`node9 login\`.`;
|
|
9782
|
+
}
|
|
9750
9783
|
async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
9751
9784
|
const meta = options?.cwd && !metaArg?.workingDir ? { ...metaArg, workingDir: options.cwd } : metaArg;
|
|
9752
9785
|
if (process.env.NODE9_PAUSED === "1") return { approved: true, checkedBy: "paused" };
|
|
@@ -10336,7 +10369,7 @@ ${appPermReview}`
|
|
|
10336
10369
|
const timer = setTimeout(() => {
|
|
10337
10370
|
resolve2({
|
|
10338
10371
|
approved: false,
|
|
10339
|
-
reason:
|
|
10372
|
+
reason: approvalTimeoutReason(approvalTimeoutMs),
|
|
10340
10373
|
blockedBy: "timeout",
|
|
10341
10374
|
blockedByLabel: "Approval Timeout"
|
|
10342
10375
|
});
|
|
@@ -20042,19 +20075,19 @@ function evaluateEgressConfig(egress) {
|
|
|
20042
20075
|
function checkEgressFloor(egress) {
|
|
20043
20076
|
const detail = [
|
|
20044
20077
|
"the cloud instance-metadata endpoint",
|
|
20045
|
-
"link-local
|
|
20046
|
-
egress.ssrfStrict ? "the strict tier is on: loopback
|
|
20078
|
+
"link-local and multicast addresses",
|
|
20079
|
+
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`"})`
|
|
20047
20080
|
];
|
|
20048
20081
|
if (egress.ssrfAllow.length) {
|
|
20049
20082
|
detail.push(`you exempted: ${egress.ssrfAllow.join(", ")}`);
|
|
20050
20083
|
}
|
|
20051
|
-
detail.push("not covered:
|
|
20084
|
+
detail.push("not covered: an interpreter one-liner (node -e, python3 -c) hides its destination");
|
|
20052
20085
|
return [
|
|
20053
20086
|
{
|
|
20054
20087
|
category: "Egress",
|
|
20055
20088
|
severity: "advisory",
|
|
20056
|
-
title: "The cloud metadata endpoint is blocked
|
|
20057
|
-
what: "node9 blocks it before any egress policy is consulted, and no setting releases it. It sees shell commands (curl, wget, ssh)
|
|
20089
|
+
title: "The cloud metadata endpoint is blocked",
|
|
20090
|
+
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.",
|
|
20058
20091
|
why: "One request to that address returns this machine's cloud credentials, to anyone who can make the agent send it.",
|
|
20059
20092
|
who: "An agent talked into fetching that address hands over the keys and cannot, here.",
|
|
20060
20093
|
owner: "node9",
|
|
@@ -56084,6 +56117,7 @@ function registerStatusCommand(program2) {
|
|
|
56084
56117
|
// src/cli/commands/init.ts
|
|
56085
56118
|
init_core();
|
|
56086
56119
|
init_setup();
|
|
56120
|
+
init_machine_id();
|
|
56087
56121
|
init_shields();
|
|
56088
56122
|
init_service();
|
|
56089
56123
|
init_core();
|
|
@@ -56100,7 +56134,8 @@ function buildTelemetryPayload(agents, firstInstall) {
|
|
|
56100
56134
|
agents_detected: agents,
|
|
56101
56135
|
os: process.platform,
|
|
56102
56136
|
node9_version: node9Version(),
|
|
56103
|
-
first_install: firstInstall
|
|
56137
|
+
first_install: firstInstall,
|
|
56138
|
+
machine_id: getMachineId()
|
|
56104
56139
|
};
|
|
56105
56140
|
}
|
|
56106
56141
|
function fireTelemetryPing(agents, firstInstall) {
|
|
@@ -57093,10 +57128,11 @@ function addEgressHost(list, host) {
|
|
|
57093
57128
|
writeEgressRawConfig(config);
|
|
57094
57129
|
}
|
|
57095
57130
|
function addSsrfExemption(address) {
|
|
57096
|
-
const
|
|
57131
|
+
const slash = address.indexOf("/");
|
|
57132
|
+
const m = classifySsrf(slash === -1 ? address : address.slice(0, slash));
|
|
57097
57133
|
if (m && !m.overridable) {
|
|
57098
57134
|
throw new Error(
|
|
57099
|
-
`${address} is a protected address (${m.tier}) and cannot be exempted by anyone. This is the one part of the floor no setting releases.`
|
|
57135
|
+
`${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.`
|
|
57100
57136
|
);
|
|
57101
57137
|
}
|
|
57102
57138
|
const config = readEgressRawConfig();
|
|
@@ -57368,7 +57404,7 @@ var TOOLS = [
|
|
|
57368
57404
|
},
|
|
57369
57405
|
{
|
|
57370
57406
|
name: "node9_egress_status",
|
|
57371
|
-
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
|
|
57407
|
+
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.",
|
|
57372
57408
|
inputSchema: { type: "object", properties: {}, required: [] }
|
|
57373
57409
|
},
|
|
57374
57410
|
{
|
|
@@ -57528,11 +57564,12 @@ function handleEgressStatus() {
|
|
|
57528
57564
|
// The SSRF floor. Without these lines an agent reading this answer
|
|
57529
57565
|
// concludes that internal addresses are reachable, because nothing said
|
|
57530
57566
|
// otherwise. The LIMITS are here for the same reason and matter more on
|
|
57531
|
-
// this surface than on any other: an agent treats this as ground truth
|
|
57532
|
-
//
|
|
57533
|
-
// shell
|
|
57534
|
-
|
|
57535
|
-
"
|
|
57567
|
+
// this surface than on any other: an agent treats this as ground truth.
|
|
57568
|
+
// The first version said the floor was absolute; the correction said it
|
|
57569
|
+
// was shell-only; both were wrong by the time they were read. What it
|
|
57570
|
+
// actually covers is measured in egress.integration.test.ts.
|
|
57571
|
+
"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.",
|
|
57572
|
+
"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.",
|
|
57536
57573
|
`Internal addresses: ${e.ssrfStrict ? "on" : "off"} \u2014 loopback and the private ranges are ${e.ssrfStrict ? "blocked too" : "reachable"}.`,
|
|
57537
57574
|
`Floor exemptions: ${e.ssrfAllow?.length ? e.ssrfAllow.join(", ") : "(none)"}`
|
|
57538
57575
|
];
|
|
@@ -60291,26 +60328,76 @@ function exempt(address) {
|
|
|
60291
60328
|
if (!cliGuardPolicyWrite(`egress exempt ${address}`)) return false;
|
|
60292
60329
|
return guard(() => addSsrfExemption(address));
|
|
60293
60330
|
}
|
|
60331
|
+
var INTERNAL_FIELDS = {
|
|
60332
|
+
allowed: { ssrfStrict: false, allowPrivate: true },
|
|
60333
|
+
listed: { ssrfStrict: false, allowPrivate: false },
|
|
60334
|
+
blocked: { ssrfStrict: true, allowPrivate: false }
|
|
60335
|
+
};
|
|
60336
|
+
var INTERNAL_SAID = {
|
|
60337
|
+
allowed: "reachable without listing them",
|
|
60338
|
+
listed: "reachable only if they are on your allowlist",
|
|
60339
|
+
blocked: "blocked"
|
|
60340
|
+
};
|
|
60341
|
+
function readInternalState(e) {
|
|
60342
|
+
if (e.ssrfStrict === true) return "blocked";
|
|
60343
|
+
return e.allowPrivate === false ? "listed" : "allowed";
|
|
60344
|
+
}
|
|
60345
|
+
function setInternal(value) {
|
|
60346
|
+
const state = value.trim().toLowerCase();
|
|
60347
|
+
if (!(state in INTERNAL_FIELDS)) {
|
|
60348
|
+
console.error(
|
|
60349
|
+
chalk34.red(`
|
|
60350
|
+
\u2717 Expected "allowed", "listed" or "blocked", got "${value}".`) + chalk34.gray(
|
|
60351
|
+
"\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"
|
|
60352
|
+
)
|
|
60353
|
+
);
|
|
60354
|
+
process.exitCode = 1;
|
|
60355
|
+
return;
|
|
60356
|
+
}
|
|
60357
|
+
if (!mutate(`egress internal ${state}`, INTERNAL_FIELDS[state])) return;
|
|
60358
|
+
_resetConfigCache();
|
|
60359
|
+
const effective = readInternalState(getConfig().policy.egress);
|
|
60360
|
+
if (effective !== state) {
|
|
60361
|
+
console.log(
|
|
60362
|
+
chalk34.yellow(
|
|
60363
|
+
`
|
|
60364
|
+
\u26A0 Saved, but not in effect: your workspace sets internal addresses to ${effective.toUpperCase()} and that governs this machine.
|
|
60365
|
+
Change it in the dashboard, Enforcement \u2192 Network.
|
|
60366
|
+
`
|
|
60367
|
+
)
|
|
60368
|
+
);
|
|
60369
|
+
return;
|
|
60370
|
+
}
|
|
60371
|
+
const line = `
|
|
60372
|
+
\u2713 Internal addresses: ${state} \u2014 loopback, the private ranges and CGNAT are ${INTERNAL_SAID[state]}.
|
|
60373
|
+
`;
|
|
60374
|
+
console.log(state === "allowed" ? chalk34.yellow(line) : chalk34.green(line));
|
|
60375
|
+
}
|
|
60294
60376
|
function showFloor(e, ssrfStrictSource) {
|
|
60295
|
-
console.log(
|
|
60377
|
+
console.log(
|
|
60378
|
+
chalk34.gray("\n Protected addresses") + chalk34.gray(" \u2014 in shell commands and declared URLs")
|
|
60379
|
+
);
|
|
60296
60380
|
console.log(
|
|
60297
60381
|
chalk34.gray(
|
|
60298
|
-
" always blocked: cloud metadata, link-local, multicast
|
|
60382
|
+
" always blocked: cloud metadata, link-local, multicast\n no setting releases these, though `node9 pause` suspends all enforcement"
|
|
60299
60383
|
)
|
|
60300
60384
|
);
|
|
60301
|
-
const
|
|
60385
|
+
const state = readInternalState(e);
|
|
60302
60386
|
const by = ssrfStrictSource === "workspace" ? "workspace (app.node9.ai)" : ssrfStrictSource === "local" ? "this machine (config.json)" : "the shipped default";
|
|
60387
|
+
const STATE_LABEL = {
|
|
60388
|
+
allowed: "allowed",
|
|
60389
|
+
listed: "allowlist only",
|
|
60390
|
+
blocked: "blocked"
|
|
60391
|
+
};
|
|
60303
60392
|
console.log(
|
|
60304
|
-
` Internal addresses: ${
|
|
60305
|
-
strict ? " loopback and 10/172.16/192.168 are blocked too" : " loopback and 10/172.16/192.168 are reachable"
|
|
60306
|
-
)
|
|
60393
|
+
` 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]}`)
|
|
60307
60394
|
);
|
|
60308
60395
|
console.log(chalk34.gray(` set by: ${by}`));
|
|
60309
60396
|
const exemptions = e.ssrfAllow ?? [];
|
|
60310
60397
|
console.log(chalk34.gray(` Exemptions: ${exemptions.length ? exemptions.join(", ") : "none"}`));
|
|
60311
60398
|
console.log(
|
|
60312
60399
|
chalk34.gray(
|
|
60313
|
-
"
|
|
60400
|
+
" 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."
|
|
60314
60401
|
)
|
|
60315
60402
|
);
|
|
60316
60403
|
}
|
|
@@ -60375,7 +60462,10 @@ function registerEgressCommand(program2) {
|
|
|
60375
60462
|
chalk34.yellow("\n\u2713 Egress control is off \u2014 the agent can reach any host again.\n")
|
|
60376
60463
|
);
|
|
60377
60464
|
});
|
|
60378
|
-
egress.command("
|
|
60465
|
+
egress.command("internal <allowed|listed|blocked>").description(
|
|
60466
|
+
"How loopback, private ranges and CGNAT are treated: allowed (default), listed (must be on the allowlist), or blocked"
|
|
60467
|
+
).action((value) => setInternal(value));
|
|
60468
|
+
egress.command("strict <on|off>").description("Deprecated alias for `egress internal blocked|allowed`").action((value) => {
|
|
60379
60469
|
const v = value.trim().toLowerCase();
|
|
60380
60470
|
if (v !== "on" && v !== "off") {
|
|
60381
60471
|
console.error(chalk34.red(`
|
|
@@ -60384,38 +60474,40 @@ function registerEgressCommand(program2) {
|
|
|
60384
60474
|
process.exitCode = 1;
|
|
60385
60475
|
return;
|
|
60386
60476
|
}
|
|
60387
|
-
|
|
60388
|
-
|
|
60389
|
-
|
|
60390
|
-
|
|
60391
|
-
|
|
60392
|
-
|
|
60393
|
-
|
|
60394
|
-
|
|
60395
|
-
|
|
60396
|
-
|
|
60477
|
+
const state = v === "on" ? "blocked" : getConfig().policy.egress.allowPrivate === false ? "listed" : "allowed";
|
|
60478
|
+
console.log(chalk34.gray(`
|
|
60479
|
+
(\`egress strict ${v}\` is now \`egress internal ${state}\`)`));
|
|
60480
|
+
setInternal(state);
|
|
60481
|
+
});
|
|
60482
|
+
egress.command("exempt <address>").description("Let an address or a CIDR range through the floor (e.g. 100.64.0.0/10)").action((address) => {
|
|
60483
|
+
const a = normalizeEgressHost(address);
|
|
60484
|
+
const slash = a.indexOf("/");
|
|
60485
|
+
const base = slash === -1 ? a : a.slice(0, slash);
|
|
60486
|
+
const prefixText = slash === -1 ? null : a.slice(slash + 1);
|
|
60487
|
+
if (!normalizeIpLiteral(base) || prefixText !== null && !/^\d{1,3}$/.test(prefixText)) {
|
|
60488
|
+
console.error(
|
|
60489
|
+
chalk34.red(`
|
|
60490
|
+
\u2717 "${address}" is not an address or a range.`) + chalk34.gray(
|
|
60491
|
+
"\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"
|
|
60397
60492
|
)
|
|
60398
60493
|
);
|
|
60494
|
+
process.exitCode = 1;
|
|
60399
60495
|
return;
|
|
60400
60496
|
}
|
|
60401
|
-
|
|
60402
|
-
|
|
60403
|
-
);
|
|
60404
|
-
});
|
|
60405
|
-
egress.command("exempt <address>").description("Let ONE address through the floor (exact address, not a range)").action((address) => {
|
|
60406
|
-
const a = normalizeEgressHost(address);
|
|
60407
|
-
if (!normalizeIpLiteral(a)) {
|
|
60497
|
+
const m = classifySsrf(base);
|
|
60498
|
+
if (m && !m.overridable) {
|
|
60408
60499
|
console.error(
|
|
60409
60500
|
chalk34.red(`
|
|
60410
|
-
\u2717
|
|
60411
|
-
|
|
60501
|
+
\u2717 ${a} cannot be exempted.`) + chalk34.gray(
|
|
60502
|
+
`
|
|
60503
|
+
${slash === -1 ? "That address is" : "That range covers only"} protected addresses (${m.tier}), which no setting releases.
|
|
60504
|
+
`
|
|
60412
60505
|
)
|
|
60413
60506
|
);
|
|
60414
60507
|
process.exitCode = 1;
|
|
60415
60508
|
return;
|
|
60416
60509
|
}
|
|
60417
60510
|
if (!exempt(a)) return;
|
|
60418
|
-
const m = classifySsrf(a);
|
|
60419
60511
|
console.log(chalk34.green(`
|
|
60420
60512
|
\u2713 ${a} is exempt from the floor.`));
|
|
60421
60513
|
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
|
}
|
package/dist/index.js
CHANGED
|
@@ -3630,6 +3630,38 @@ function classifySsrf(host) {
|
|
|
3630
3630
|
return null;
|
|
3631
3631
|
}
|
|
3632
3632
|
}
|
|
3633
|
+
function ssrfExemptMatches(entries, normalized) {
|
|
3634
|
+
if (!entries?.length || !normalized) return false;
|
|
3635
|
+
const target = bitsOf(normalized);
|
|
3636
|
+
for (const raw of entries) {
|
|
3637
|
+
const entry = raw.trim().toLowerCase();
|
|
3638
|
+
if (!entry) continue;
|
|
3639
|
+
const slash = entry.indexOf("/");
|
|
3640
|
+
if (slash === -1) {
|
|
3641
|
+
if ((normalizeIpLiteral(entry) ?? entry) === normalized) return true;
|
|
3642
|
+
continue;
|
|
3643
|
+
}
|
|
3644
|
+
if (!target) continue;
|
|
3645
|
+
const base = bitsOf(normalizeIpLiteral(entry.slice(0, slash)) ?? "");
|
|
3646
|
+
const prefixText = entry.slice(slash + 1);
|
|
3647
|
+
const prefix = /^\d+$/.test(prefixText) ? Number(prefixText) : NaN;
|
|
3648
|
+
if (!base || !Number.isInteger(prefix) || prefix < 0 || // A v4 range never matches a v6 address, and the reverse: the widths
|
|
3649
|
+
// differ, so `0.0.0.0/0` does not release `::1`.
|
|
3650
|
+
base.length !== target.length || prefix > base.length) {
|
|
3651
|
+
continue;
|
|
3652
|
+
}
|
|
3653
|
+
if (base.slice(0, prefix) === target.slice(0, prefix)) return true;
|
|
3654
|
+
}
|
|
3655
|
+
return false;
|
|
3656
|
+
}
|
|
3657
|
+
function bitsOf(normalized) {
|
|
3658
|
+
const o = v4Octets(normalized);
|
|
3659
|
+
if (o) {
|
|
3660
|
+
return o.every((n) => Number.isInteger(n) && n >= 0 && n <= 255) ? o.map((n) => n.toString(2).padStart(8, "0")).join("") : null;
|
|
3661
|
+
}
|
|
3662
|
+
const g = expandIpv6(normalized);
|
|
3663
|
+
return g ? g.map((n) => n.toString(2).padStart(16, "0")).join("") : null;
|
|
3664
|
+
}
|
|
3633
3665
|
var TIER_REASON = {
|
|
3634
3666
|
metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
|
|
3635
3667
|
"link-local": "a link-local address",
|
|
@@ -3639,14 +3671,11 @@ var TIER_REASON = {
|
|
|
3639
3671
|
private: "a loopback or private address"
|
|
3640
3672
|
};
|
|
3641
3673
|
function ssrfFloor(tokens, opts = {}) {
|
|
3642
|
-
const exempt = new Set(
|
|
3643
|
-
(opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
|
|
3644
|
-
);
|
|
3645
3674
|
for (const { token, binary } of tokens) {
|
|
3646
3675
|
const m = classifySsrf(token);
|
|
3647
3676
|
if (!m) continue;
|
|
3648
3677
|
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
3649
|
-
if (m.overridable &&
|
|
3678
|
+
if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
|
|
3650
3679
|
return { ...m, host: token, binary, reason: ssrfReason(m, token) };
|
|
3651
3680
|
}
|
|
3652
3681
|
return null;
|
|
@@ -4109,7 +4138,6 @@ function ssrfDestinationFloor(toolName, args, opts = {}) {
|
|
|
4109
4138
|
try {
|
|
4110
4139
|
const paths = DESTINATION_ARGS.get(bareToolName(toolName));
|
|
4111
4140
|
if (!paths) return null;
|
|
4112
|
-
const exempt = new Set((opts.ssrfAllow ?? []).map((a) => classifySsrf(a)?.normalized ?? a));
|
|
4113
4141
|
for (const path16 of paths) {
|
|
4114
4142
|
for (const value of valuesAt(args, path16)) {
|
|
4115
4143
|
const host = hostOf(value);
|
|
@@ -4117,7 +4145,7 @@ function ssrfDestinationFloor(toolName, args, opts = {}) {
|
|
|
4117
4145
|
const m = classifySsrf(host);
|
|
4118
4146
|
if (!m) continue;
|
|
4119
4147
|
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
4120
|
-
if (m.overridable &&
|
|
4148
|
+
if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
|
|
4121
4149
|
return { ...m, argPath: path16, host, reason: ssrfReason(m, host) };
|
|
4122
4150
|
}
|
|
4123
4151
|
}
|
|
@@ -5950,10 +5978,12 @@ function safeApiUrl(raw, onReject) {
|
|
|
5950
5978
|
function sanitizeSsrfAllow(entries, source) {
|
|
5951
5979
|
const kept = [];
|
|
5952
5980
|
for (const entry of entries) {
|
|
5953
|
-
const
|
|
5981
|
+
const base = entry.includes("/") ? entry.slice(0, entry.indexOf("/")).trim() : entry;
|
|
5982
|
+
const m = classifySsrf(base);
|
|
5954
5983
|
if (m && !m.overridable) {
|
|
5984
|
+
const what = entry.includes("/") ? "covers only protected addresses" : "is a protected address";
|
|
5955
5985
|
process.emitWarning(
|
|
5956
|
-
`[node9] ${source} ssrfAllow entry "${entry}"
|
|
5986
|
+
`[node9] ${source} ssrfAllow entry "${entry}" ${what} (${m.tier}) and cannot be exempted; ignoring it.`
|
|
5957
5987
|
);
|
|
5958
5988
|
continue;
|
|
5959
5989
|
}
|
|
@@ -8130,6 +8160,9 @@ async function authorizeHeadless(toolName, args, meta, options) {
|
|
|
8130
8160
|
}
|
|
8131
8161
|
return _authorizeHeadlessCore(toolName, args, meta, options);
|
|
8132
8162
|
}
|
|
8163
|
+
function approvalTimeoutReason(approvalTimeoutMs) {
|
|
8164
|
+
return `No human response within ${approvalTimeoutMs / 1e3}s \u2014 auto-denied by timeout policy. Approve from your phone next time: run \`node9 login\`.`;
|
|
8165
|
+
}
|
|
8133
8166
|
async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
8134
8167
|
const meta = options?.cwd && !metaArg?.workingDir ? { ...metaArg, workingDir: options.cwd } : metaArg;
|
|
8135
8168
|
if (process.env.NODE9_PAUSED === "1") return { approved: true, checkedBy: "paused" };
|
|
@@ -8719,7 +8752,7 @@ ${appPermReview}`
|
|
|
8719
8752
|
const timer = setTimeout(() => {
|
|
8720
8753
|
resolve({
|
|
8721
8754
|
approved: false,
|
|
8722
|
-
reason:
|
|
8755
|
+
reason: approvalTimeoutReason(approvalTimeoutMs),
|
|
8723
8756
|
blockedBy: "timeout",
|
|
8724
8757
|
blockedByLabel: "Approval Timeout"
|
|
8725
8758
|
});
|
package/dist/index.mjs
CHANGED
|
@@ -3600,6 +3600,38 @@ function classifySsrf(host) {
|
|
|
3600
3600
|
return null;
|
|
3601
3601
|
}
|
|
3602
3602
|
}
|
|
3603
|
+
function ssrfExemptMatches(entries, normalized) {
|
|
3604
|
+
if (!entries?.length || !normalized) return false;
|
|
3605
|
+
const target = bitsOf(normalized);
|
|
3606
|
+
for (const raw of entries) {
|
|
3607
|
+
const entry = raw.trim().toLowerCase();
|
|
3608
|
+
if (!entry) continue;
|
|
3609
|
+
const slash = entry.indexOf("/");
|
|
3610
|
+
if (slash === -1) {
|
|
3611
|
+
if ((normalizeIpLiteral(entry) ?? entry) === normalized) return true;
|
|
3612
|
+
continue;
|
|
3613
|
+
}
|
|
3614
|
+
if (!target) continue;
|
|
3615
|
+
const base = bitsOf(normalizeIpLiteral(entry.slice(0, slash)) ?? "");
|
|
3616
|
+
const prefixText = entry.slice(slash + 1);
|
|
3617
|
+
const prefix = /^\d+$/.test(prefixText) ? Number(prefixText) : NaN;
|
|
3618
|
+
if (!base || !Number.isInteger(prefix) || prefix < 0 || // A v4 range never matches a v6 address, and the reverse: the widths
|
|
3619
|
+
// differ, so `0.0.0.0/0` does not release `::1`.
|
|
3620
|
+
base.length !== target.length || prefix > base.length) {
|
|
3621
|
+
continue;
|
|
3622
|
+
}
|
|
3623
|
+
if (base.slice(0, prefix) === target.slice(0, prefix)) return true;
|
|
3624
|
+
}
|
|
3625
|
+
return false;
|
|
3626
|
+
}
|
|
3627
|
+
function bitsOf(normalized) {
|
|
3628
|
+
const o = v4Octets(normalized);
|
|
3629
|
+
if (o) {
|
|
3630
|
+
return o.every((n) => Number.isInteger(n) && n >= 0 && n <= 255) ? o.map((n) => n.toString(2).padStart(8, "0")).join("") : null;
|
|
3631
|
+
}
|
|
3632
|
+
const g = expandIpv6(normalized);
|
|
3633
|
+
return g ? g.map((n) => n.toString(2).padStart(16, "0")).join("") : null;
|
|
3634
|
+
}
|
|
3603
3635
|
var TIER_REASON = {
|
|
3604
3636
|
metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
|
|
3605
3637
|
"link-local": "a link-local address",
|
|
@@ -3609,14 +3641,11 @@ var TIER_REASON = {
|
|
|
3609
3641
|
private: "a loopback or private address"
|
|
3610
3642
|
};
|
|
3611
3643
|
function ssrfFloor(tokens, opts = {}) {
|
|
3612
|
-
const exempt = new Set(
|
|
3613
|
-
(opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
|
|
3614
|
-
);
|
|
3615
3644
|
for (const { token, binary } of tokens) {
|
|
3616
3645
|
const m = classifySsrf(token);
|
|
3617
3646
|
if (!m) continue;
|
|
3618
3647
|
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
3619
|
-
if (m.overridable &&
|
|
3648
|
+
if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
|
|
3620
3649
|
return { ...m, host: token, binary, reason: ssrfReason(m, token) };
|
|
3621
3650
|
}
|
|
3622
3651
|
return null;
|
|
@@ -4079,7 +4108,6 @@ function ssrfDestinationFloor(toolName, args, opts = {}) {
|
|
|
4079
4108
|
try {
|
|
4080
4109
|
const paths = DESTINATION_ARGS.get(bareToolName(toolName));
|
|
4081
4110
|
if (!paths) return null;
|
|
4082
|
-
const exempt = new Set((opts.ssrfAllow ?? []).map((a) => classifySsrf(a)?.normalized ?? a));
|
|
4083
4111
|
for (const path16 of paths) {
|
|
4084
4112
|
for (const value of valuesAt(args, path16)) {
|
|
4085
4113
|
const host = hostOf(value);
|
|
@@ -4087,7 +4115,7 @@ function ssrfDestinationFloor(toolName, args, opts = {}) {
|
|
|
4087
4115
|
const m = classifySsrf(host);
|
|
4088
4116
|
if (!m) continue;
|
|
4089
4117
|
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
4090
|
-
if (m.overridable &&
|
|
4118
|
+
if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
|
|
4091
4119
|
return { ...m, argPath: path16, host, reason: ssrfReason(m, host) };
|
|
4092
4120
|
}
|
|
4093
4121
|
}
|
|
@@ -5920,10 +5948,12 @@ function safeApiUrl(raw, onReject) {
|
|
|
5920
5948
|
function sanitizeSsrfAllow(entries, source) {
|
|
5921
5949
|
const kept = [];
|
|
5922
5950
|
for (const entry of entries) {
|
|
5923
|
-
const
|
|
5951
|
+
const base = entry.includes("/") ? entry.slice(0, entry.indexOf("/")).trim() : entry;
|
|
5952
|
+
const m = classifySsrf(base);
|
|
5924
5953
|
if (m && !m.overridable) {
|
|
5954
|
+
const what = entry.includes("/") ? "covers only protected addresses" : "is a protected address";
|
|
5925
5955
|
process.emitWarning(
|
|
5926
|
-
`[node9] ${source} ssrfAllow entry "${entry}"
|
|
5956
|
+
`[node9] ${source} ssrfAllow entry "${entry}" ${what} (${m.tier}) and cannot be exempted; ignoring it.`
|
|
5927
5957
|
);
|
|
5928
5958
|
continue;
|
|
5929
5959
|
}
|
|
@@ -8100,6 +8130,9 @@ async function authorizeHeadless(toolName, args, meta, options) {
|
|
|
8100
8130
|
}
|
|
8101
8131
|
return _authorizeHeadlessCore(toolName, args, meta, options);
|
|
8102
8132
|
}
|
|
8133
|
+
function approvalTimeoutReason(approvalTimeoutMs) {
|
|
8134
|
+
return `No human response within ${approvalTimeoutMs / 1e3}s \u2014 auto-denied by timeout policy. Approve from your phone next time: run \`node9 login\`.`;
|
|
8135
|
+
}
|
|
8103
8136
|
async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
8104
8137
|
const meta = options?.cwd && !metaArg?.workingDir ? { ...metaArg, workingDir: options.cwd } : metaArg;
|
|
8105
8138
|
if (process.env.NODE9_PAUSED === "1") return { approved: true, checkedBy: "paused" };
|
|
@@ -8689,7 +8722,7 @@ ${appPermReview}`
|
|
|
8689
8722
|
const timer = setTimeout(() => {
|
|
8690
8723
|
resolve({
|
|
8691
8724
|
approved: false,
|
|
8692
|
-
reason:
|
|
8725
|
+
reason: approvalTimeoutReason(approvalTimeoutMs),
|
|
8693
8726
|
blockedBy: "timeout",
|
|
8694
8727
|
blockedByLabel: "Approval Timeout"
|
|
8695
8728
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@node9/proxy",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.19.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",
|