kritya 0.8.21-beta → 0.8.22-beta
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/atomicWrite.js +16 -2
- package/dist/config/config.js +9 -10
- package/dist/mcp/client.js +9 -34
- package/dist/mcp/oauth.js +15 -4
- package/dist/net/urlSafety.js +33 -0
- package/dist/provider/client.js +5 -0
- package/dist/session/store.js +36 -4
- package/dist/shell/background.js +38 -3
- package/dist/tools/fetchUrl.js +57 -15
- package/package.json +3 -2
package/dist/atomicWrite.js
CHANGED
|
@@ -59,8 +59,16 @@ export async function writeFileAtomic(filePath, data, options = {}) {
|
|
|
59
59
|
const target = resolveTarget(filePath);
|
|
60
60
|
const mode = options.mode ?? existingMode(target);
|
|
61
61
|
const tmp = tempPathFor(target);
|
|
62
|
+
const encoding = options.encoding ?? (typeof data === "string" ? "utf8" : null);
|
|
62
63
|
try {
|
|
63
|
-
|
|
64
|
+
// Pass `mode` to the create itself rather than chmod-ing afterward: a
|
|
65
|
+
// separate chmod call leaves a window where the temp file sits at
|
|
66
|
+
// Node's default (umask-derived, typically world/group-readable) mode
|
|
67
|
+
// before being narrowed — for a sensitive target (e.g. config.json) that
|
|
68
|
+
// briefly exposes contents a caller explicitly asked to keep owner-only.
|
|
69
|
+
// The chmod is kept too, since umask can still trim bits `mode` set on
|
|
70
|
+
// some platforms.
|
|
71
|
+
await fsp.writeFile(tmp, data, { encoding, ...(mode !== undefined ? { mode } : {}) });
|
|
64
72
|
if (mode !== undefined)
|
|
65
73
|
await fsp.chmod(tmp, mode);
|
|
66
74
|
await fsp.rename(tmp, target);
|
|
@@ -69,7 +77,9 @@ export async function writeFileAtomic(filePath, data, options = {}) {
|
|
|
69
77
|
await fsp.rm(tmp, { force: true }).catch(() => { });
|
|
70
78
|
if (!isRenameFallback(err))
|
|
71
79
|
throw err;
|
|
72
|
-
await fsp.writeFile(filePath, data,
|
|
80
|
+
await fsp.writeFile(filePath, data, { encoding, ...(mode !== undefined ? { mode } : {}) });
|
|
81
|
+
if (mode !== undefined)
|
|
82
|
+
await fsp.chmod(filePath, mode);
|
|
73
83
|
}
|
|
74
84
|
}
|
|
75
85
|
export function writeFileAtomicSync(filePath, data, options = {}) {
|
|
@@ -101,6 +111,10 @@ export function writeFileAtomicSync(filePath, data, options = {}) {
|
|
|
101
111
|
...(options.encoding ? { encoding: options.encoding } : {}),
|
|
102
112
|
...(mode !== undefined ? { mode } : {}),
|
|
103
113
|
});
|
|
114
|
+
// Same umask concern as the primary path above: `mode` on writeFileSync
|
|
115
|
+
// can still be trimmed by umask, so enforce it explicitly.
|
|
116
|
+
if (mode !== undefined)
|
|
117
|
+
fs.chmodSync(filePath, mode);
|
|
104
118
|
}
|
|
105
119
|
}
|
|
106
120
|
function isRenameFallback(err) {
|
package/dist/config/config.js
CHANGED
|
@@ -3,8 +3,9 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { hardenWindowsDir } from "./winAcl.js";
|
|
5
5
|
import { debugLog } from "./debug.js";
|
|
6
|
+
import { writeFileAtomicSync } from "../atomicWrite.js";
|
|
6
7
|
export const CONFIG_DIR = path.join(os.homedir(), ".kritya");
|
|
7
|
-
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
8
|
+
export const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
8
9
|
export const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
|
|
9
10
|
/**
|
|
10
11
|
* Built-in OpenAI-compatible providers. Users select one with `provider` in
|
|
@@ -136,15 +137,13 @@ export function saveConfig(patch) {
|
|
|
136
137
|
// config.json can hold a literal apiKey — keep it readable only by the owner.
|
|
137
138
|
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
138
139
|
hardenWindowsDir(CONFIG_DIR);
|
|
139
|
-
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
debugLog(`saveConfig chmod(${CONFIG_FILE})`, err);
|
|
147
|
-
}
|
|
140
|
+
// Atomic (write-to-temp-then-rename) rather than a direct write: config.json
|
|
141
|
+
// holds API keys, and a crash mid-write would otherwise leave it truncated
|
|
142
|
+
// and unreadable on next launch. `mode: 0o600` is forced on every save
|
|
143
|
+
// (rather than only applying on creation, like a plain writeFileSync would),
|
|
144
|
+
// so a config.json that pre-existed with looser permissions still ends up
|
|
145
|
+
// owner-only.
|
|
146
|
+
writeFileAtomicSync(CONFIG_FILE, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
|
|
148
147
|
}
|
|
149
148
|
/**
|
|
150
149
|
* A copy of process.env with kritya-managed secrets removed. Every built-in
|
package/dist/mcp/client.js
CHANGED
|
@@ -6,7 +6,7 @@ import { NOOP_TRACER } from "../telemetry/tracer.js";
|
|
|
6
6
|
import { McpAuthRequiredError } from "./oauth.js";
|
|
7
7
|
import { missingVars } from "./servers.js";
|
|
8
8
|
import { HttpTransport, StdioTransport } from "./transport.js";
|
|
9
|
-
import {
|
|
9
|
+
import { assertSafeUrl as assertSafeUrlShared } from "../net/urlSafety.js";
|
|
10
10
|
import { checkToolsShape, serverFingerprint } from "../trust/mcpTrust.js";
|
|
11
11
|
import { probeStdioEra, probeHttpEra } from "./eraDetect.js";
|
|
12
12
|
import { ModernMcpConnection } from "./clientModern.js";
|
|
@@ -545,42 +545,17 @@ export function toolAllowed(name, filter) {
|
|
|
545
545
|
return true;
|
|
546
546
|
return filter.allow.some((p) => matchesPattern(name, p));
|
|
547
547
|
}
|
|
548
|
-
/** Loopback is exempt from the https requirement: there's no network to sniff. */
|
|
549
|
-
function isLoopback(hostname) {
|
|
550
|
-
const h = hostname.replace(/^\[|\]$/g, "");
|
|
551
|
-
return h === "localhost" || h === "127.0.0.1" || h === "::1" || h.endsWith(".localhost");
|
|
552
|
-
}
|
|
553
548
|
/**
|
|
554
|
-
* Reject a remote server reachable only over plaintext
|
|
555
|
-
* refuses these, but that
|
|
556
|
-
* ~/.kritya/config.json or a
|
|
557
|
-
*
|
|
558
|
-
* three sources share
|
|
549
|
+
* Reject a remote server reachable only over plaintext, or pointed at a
|
|
550
|
+
* private/internal address. `/mcp add` already refuses these, but that
|
|
551
|
+
* guards one entrance: a server hand-written into ~/.kritya/config.json or a
|
|
552
|
+
* repo's .mcp.json never passes through it and would happily POST a bearer
|
|
553
|
+
* token in the clear. This is the choke point all three sources share —
|
|
554
|
+
* delegates to the same check used for OAuth discovery/token endpoints
|
|
555
|
+
* (src/mcp/oauth.ts) so the two can't silently drift apart.
|
|
559
556
|
*/
|
|
560
557
|
export function assertSafeUrl(name, url) {
|
|
561
|
-
|
|
562
|
-
try {
|
|
563
|
-
parsed = new URL(url);
|
|
564
|
-
}
|
|
565
|
-
catch {
|
|
566
|
-
throw new Error(`server "${name}" has an invalid url: ${url}`);
|
|
567
|
-
}
|
|
568
|
-
// https alone doesn't mean the traffic (incl. any bearer token in headers)
|
|
569
|
-
// stays where the user intends — a config pointing at 169.254.169.254 or
|
|
570
|
-
// another private/internal address would still ship credentials there.
|
|
571
|
-
// Loopback is exempt: that's this app talking to itself, nothing to leak to.
|
|
572
|
-
if (!isLoopback(parsed.hostname) && isPrivateOrLoopbackHost(parsed.hostname)) {
|
|
573
|
-
throw new Error(`server "${name}" points at a private/internal address (${parsed.hostname}) — refusing to connect.`);
|
|
574
|
-
}
|
|
575
|
-
if (parsed.protocol === "https:")
|
|
576
|
-
return parsed;
|
|
577
|
-
if (parsed.protocol === "http:" && isLoopback(parsed.hostname))
|
|
578
|
-
return parsed;
|
|
579
|
-
if (parsed.protocol !== "http:") {
|
|
580
|
-
throw new Error(`server "${name}" uses unsupported scheme "${parsed.protocol}" — use https://`);
|
|
581
|
-
}
|
|
582
|
-
throw new Error(`server "${name}" uses plain http:// (${parsed.host}) — an MCP session carries ` +
|
|
583
|
-
`your credentials in cleartext over it. Use https:// (localhost is exempt).`);
|
|
558
|
+
return assertSafeUrlShared(`server "${name}"`, url);
|
|
584
559
|
}
|
|
585
560
|
function makeTransport(name, cfg, workspace) {
|
|
586
561
|
if (cfg.url) {
|
package/dist/mcp/oauth.js
CHANGED
|
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import { VERSION } from "../version.js";
|
|
3
3
|
import { debugLog } from "../config/debug.js";
|
|
4
4
|
import { isExpired, loadAuth, saveAuth } from "./tokens.js";
|
|
5
|
+
import { assertSafeUrl } from "../net/urlSafety.js";
|
|
5
6
|
/**
|
|
6
7
|
* OAuth 2.1 for remote (Streamable HTTP) MCP servers.
|
|
7
8
|
*
|
|
@@ -43,8 +44,18 @@ export class McpAuthRequiredError extends Error {
|
|
|
43
44
|
function ua() {
|
|
44
45
|
return { "user-agent": `kritya/${VERSION}`, accept: "application/json" };
|
|
45
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Fetches JSON from a URL that isn't necessarily one kritya's own config
|
|
49
|
+
* chose — the .well-known candidates are built from a `serverUrl`/`issuer`,
|
|
50
|
+
* but a caller can also pass a `resource_metadata` URL taken straight off a
|
|
51
|
+
* server's WWW-Authenticate header, or an `authorization_servers` entry
|
|
52
|
+
* from that server's own metadata document. Both are attacker-controlled if
|
|
53
|
+
* the MCP server is malicious or compromised, so validate here rather than
|
|
54
|
+
* trust every candidate this module constructs.
|
|
55
|
+
*/
|
|
46
56
|
async function getJson(url, timeoutMs = DISCOVERY_TIMEOUT_MS) {
|
|
47
|
-
const
|
|
57
|
+
const safe = assertSafeUrl("OAuth discovery endpoint", url);
|
|
58
|
+
const res = await fetch(safe, { headers: ua(), signal: AbortSignal.timeout(timeoutMs) });
|
|
48
59
|
if (!res.ok)
|
|
49
60
|
throw new Error(`HTTP ${res.status} from ${url}`);
|
|
50
61
|
return res.json();
|
|
@@ -156,7 +167,7 @@ export async function registerClient(meta, redirectUri, scope) {
|
|
|
156
167
|
throw new Error(`authorization server ${meta.issuer} does not support dynamic client registration; ` +
|
|
157
168
|
`register kritya manually and put the client_id in mcp-auth.json`);
|
|
158
169
|
}
|
|
159
|
-
const res = await fetch(meta.registrationEndpoint, {
|
|
170
|
+
const res = await fetch(assertSafeUrl("OAuth registration endpoint", meta.registrationEndpoint), {
|
|
160
171
|
method: "POST",
|
|
161
172
|
headers: { ...ua(), "content-type": "application/json" },
|
|
162
173
|
body: JSON.stringify({
|
|
@@ -204,7 +215,7 @@ async function postToken(tokenEndpoint, params, clientSecret) {
|
|
|
204
215
|
const basic = Buffer.from(`${params.client_id}:${clientSecret}`).toString("base64");
|
|
205
216
|
headers.authorization = `Basic ${basic}`;
|
|
206
217
|
}
|
|
207
|
-
const res = await fetch(tokenEndpoint, {
|
|
218
|
+
const res = await fetch(assertSafeUrl("OAuth token endpoint", tokenEndpoint), {
|
|
208
219
|
method: "POST",
|
|
209
220
|
headers,
|
|
210
221
|
body: new URLSearchParams(params).toString(),
|
|
@@ -270,7 +281,7 @@ export async function revokeToken(auth) {
|
|
|
270
281
|
const token = auth.refreshToken ?? auth.accessToken;
|
|
271
282
|
const hint = auth.refreshToken ? "refresh_token" : "access_token";
|
|
272
283
|
try {
|
|
273
|
-
const res = await fetch(auth.revocationEndpoint, {
|
|
284
|
+
const res = await fetch(assertSafeUrl("OAuth revocation endpoint", auth.revocationEndpoint), {
|
|
274
285
|
method: "POST",
|
|
275
286
|
headers: { ...ua(), "content-type": "application/x-www-form-urlencoded" },
|
|
276
287
|
body: new URLSearchParams({
|
package/dist/net/urlSafety.js
CHANGED
|
@@ -156,3 +156,36 @@ export function isLoopbackHost(rawHost) {
|
|
|
156
156
|
return isLoopbackIPv6Groups(v6);
|
|
157
157
|
return false;
|
|
158
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* Reject a URL reachable only over plaintext, or pointed at a private/
|
|
161
|
+
* internal address — the shared choke point for every URL kritya's own
|
|
162
|
+
* process fetches on the user's behalf with credentials attached (MCP
|
|
163
|
+
* server URLs, OAuth discovery/token/revocation endpoints, …). `label`
|
|
164
|
+
* identifies the source in the thrown error (a server name, "OAuth token
|
|
165
|
+
* endpoint", etc). Loopback is exempt from the https requirement: that's
|
|
166
|
+
* this app talking to itself, nothing on the wire to sniff.
|
|
167
|
+
*/
|
|
168
|
+
export function assertSafeUrl(label, url) {
|
|
169
|
+
let parsed;
|
|
170
|
+
try {
|
|
171
|
+
parsed = new URL(url);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
throw new Error(`${label} has an invalid url: ${url}`);
|
|
175
|
+
}
|
|
176
|
+
// https alone doesn't mean the traffic (incl. any bearer token) stays
|
|
177
|
+
// where the caller intends — a URL pointing at 169.254.169.254 or another
|
|
178
|
+
// private/internal address would still ship credentials there.
|
|
179
|
+
if (!isLoopbackHost(parsed.hostname) && isPrivateOrLoopbackHost(parsed.hostname)) {
|
|
180
|
+
throw new Error(`${label} points at a private/internal address (${parsed.hostname}) — refusing to connect.`);
|
|
181
|
+
}
|
|
182
|
+
if (parsed.protocol === "https:")
|
|
183
|
+
return parsed;
|
|
184
|
+
if (parsed.protocol === "http:" && isLoopbackHost(parsed.hostname))
|
|
185
|
+
return parsed;
|
|
186
|
+
if (parsed.protocol !== "http:") {
|
|
187
|
+
throw new Error(`${label} uses unsupported scheme "${parsed.protocol}" — use https://`);
|
|
188
|
+
}
|
|
189
|
+
throw new Error(`${label} uses plain http:// (${parsed.host}), which would carry credentials in cleartext. ` +
|
|
190
|
+
`Use https:// (localhost is exempt).`);
|
|
191
|
+
}
|
package/dist/provider/client.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import OpenAI from "openai";
|
|
2
2
|
import { NVIDIA_BASE_URL } from "../config/config.js";
|
|
3
|
+
import { assertSafeUrl } from "../net/urlSafety.js";
|
|
3
4
|
import { NOOP_TRACER } from "../telemetry/tracer.js";
|
|
4
5
|
import { recoverToolCalls } from "./textToolCalls.js";
|
|
5
6
|
/** Retry transient provider failures (429 / 5xx / network) with backoff. */
|
|
@@ -192,6 +193,10 @@ export class ProviderClient {
|
|
|
192
193
|
maxTokens;
|
|
193
194
|
streamIdleTimeoutMs;
|
|
194
195
|
constructor(apiKey, baseURL = NVIDIA_BASE_URL, sampling = {}, timeouts = {}) {
|
|
196
|
+
// Provider URLs can carry an API key, so apply the same scheme and
|
|
197
|
+
// private-network protections used by MCP HTTP endpoints before handing
|
|
198
|
+
// the URL to the SDK.
|
|
199
|
+
assertSafeUrl("provider base URL", baseURL);
|
|
195
200
|
// We do our own streaming-aware retry loop, so disable the SDK's.
|
|
196
201
|
this.client = new OpenAI({
|
|
197
202
|
apiKey,
|
package/dist/session/store.js
CHANGED
|
@@ -159,15 +159,47 @@ export class SessionStore {
|
|
|
159
159
|
* accept a session path from outside the process (e.g. the Electron
|
|
160
160
|
* renderer over IPC) must check this before loading it: without it,
|
|
161
161
|
* "load this session" is really "read any file the OS user can read".
|
|
162
|
+
*
|
|
163
|
+
* The lexical check (path.relative on the unresolved names) only rules out
|
|
164
|
+
* `..` segments in the string itself — it doesn't notice a symlink planted
|
|
165
|
+
* inside the session directory that points somewhere else entirely. Such a
|
|
166
|
+
* symlink would have a path that lexically resolves inside the session dir
|
|
167
|
+
* while the filesystem happily follows it out. Canonicalizing with
|
|
168
|
+
* fs.realpathSync and re-checking containment against that catches it: a
|
|
169
|
+
* symlink escaping the directory resolves to a real path outside it and
|
|
170
|
+
* gets rejected, exactly like an out-of-tree file would.
|
|
162
171
|
*/
|
|
163
172
|
static isSessionFile(workspace, filePath) {
|
|
164
173
|
const dir = sessionDir(workspace);
|
|
165
174
|
const resolved = path.resolve(dir, filePath);
|
|
166
175
|
const relative = path.relative(dir, resolved);
|
|
167
|
-
|
|
168
|
-
relative
|
|
169
|
-
|
|
170
|
-
|
|
176
|
+
if (!resolved.endsWith(".jsonl") ||
|
|
177
|
+
relative === "" ||
|
|
178
|
+
relative.startsWith("..") ||
|
|
179
|
+
path.isAbsolute(relative)) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
let realDir;
|
|
183
|
+
let realFile;
|
|
184
|
+
try {
|
|
185
|
+
realDir = fs.realpathSync(dir);
|
|
186
|
+
realFile = fs.realpathSync(resolved);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// Doesn't exist, or a component along the way isn't accessible —
|
|
190
|
+
// either way there's nothing real to load.
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
const realRelative = path.relative(realDir, realFile);
|
|
194
|
+
if (realRelative === "" || realRelative.startsWith("..") || path.isAbsolute(realRelative)) {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
return fs.statSync(realFile).isFile();
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
171
203
|
}
|
|
172
204
|
static loadFile(filePath) {
|
|
173
205
|
const messages = [];
|
package/dist/shell/background.js
CHANGED
|
@@ -2,7 +2,10 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import { scrubbedShellEnv } from "../config/config.js";
|
|
4
4
|
import { buildSandboxedCommand, requiresSandbox, sandboxUnavailableReason, shouldSandbox, } from "./sandbox.js";
|
|
5
|
+
import { redactSecrets } from "../tools/secretScan.js";
|
|
5
6
|
const MAX_BUFFER_CHARS = 50_000;
|
|
7
|
+
const MAX_COMPLETED_PROCESSES = 100;
|
|
8
|
+
const COMPLETED_PROCESS_RETENTION_MS = 5 * 60_000;
|
|
6
9
|
/**
|
|
7
10
|
* Registry of long-running processes the agent started with shell
|
|
8
11
|
* background:true (dev servers, watchers). Captures a rolling tail of
|
|
@@ -54,9 +57,19 @@ class BackgroundManager {
|
|
|
54
57
|
// approach) mis-parses commands that themselves contain quotes.
|
|
55
58
|
proc = spawn(command, { ...spawnOpts, shell: true });
|
|
56
59
|
}
|
|
57
|
-
|
|
60
|
+
// Keep secrets out of the long-lived registry. The child still receives
|
|
61
|
+
// the original command, but the retained command is redacted.
|
|
62
|
+
const entry = {
|
|
63
|
+
proc,
|
|
64
|
+
command: redactSecrets(command).redacted,
|
|
65
|
+
buffer: "",
|
|
66
|
+
exitCode: null,
|
|
67
|
+
running: true,
|
|
68
|
+
};
|
|
58
69
|
const append = (chunk) => {
|
|
59
|
-
|
|
70
|
+
// Re-scan the rolling buffer so a secret split across stream chunks is
|
|
71
|
+
// redacted before it is retained.
|
|
72
|
+
entry.buffer = redactSecrets(entry.buffer + chunk.toString()).redacted;
|
|
60
73
|
if (entry.buffer.length > MAX_BUFFER_CHARS) {
|
|
61
74
|
entry.buffer = entry.buffer.slice(-MAX_BUFFER_CHARS);
|
|
62
75
|
}
|
|
@@ -68,21 +81,43 @@ class BackgroundManager {
|
|
|
68
81
|
proc.on("exit", (code) => {
|
|
69
82
|
entry.running = false;
|
|
70
83
|
entry.exitCode = code;
|
|
84
|
+
entry.finishedAt = Date.now();
|
|
71
85
|
proc.stdout?.destroy();
|
|
72
86
|
proc.stderr?.destroy();
|
|
73
87
|
cleanup?.();
|
|
88
|
+
this.scheduleCleanup(id, entry);
|
|
74
89
|
});
|
|
75
90
|
proc.on("error", (err) => {
|
|
76
91
|
entry.running = false;
|
|
77
|
-
entry.
|
|
92
|
+
entry.finishedAt = Date.now();
|
|
93
|
+
entry.buffer = redactSecrets(`${entry.buffer}\n[failed to start: ${err.message}]`).redacted;
|
|
78
94
|
// If the sandbox binary itself fails to spawn, "exit" may never fire, so
|
|
79
95
|
// the macOS temp profile would leak. cleanup is fs.rm({force:true}),
|
|
80
96
|
// which is idempotent, so running it from both handlers is safe.
|
|
81
97
|
cleanup?.();
|
|
98
|
+
this.scheduleCleanup(id, entry);
|
|
82
99
|
});
|
|
83
100
|
this.procs.set(id, entry);
|
|
101
|
+
this.pruneCompleted();
|
|
84
102
|
return { id };
|
|
85
103
|
}
|
|
104
|
+
scheduleCleanup(id, entry) {
|
|
105
|
+
const timer = setTimeout(() => {
|
|
106
|
+
if (this.procs.get(id) === entry && !entry.running)
|
|
107
|
+
this.procs.delete(id);
|
|
108
|
+
}, COMPLETED_PROCESS_RETENTION_MS);
|
|
109
|
+
timer.unref();
|
|
110
|
+
this.pruneCompleted();
|
|
111
|
+
}
|
|
112
|
+
pruneCompleted() {
|
|
113
|
+
const completed = [...this.procs.entries()]
|
|
114
|
+
.filter(([, entry]) => !entry.running)
|
|
115
|
+
.sort(([, a], [, b]) => (a.finishedAt ?? 0) - (b.finishedAt ?? 0));
|
|
116
|
+
while (completed.length > MAX_COMPLETED_PROCESSES) {
|
|
117
|
+
const [id] = completed.shift();
|
|
118
|
+
this.procs.delete(id);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
86
121
|
signal(entry, sig) {
|
|
87
122
|
try {
|
|
88
123
|
if (os.platform() !== "win32" && entry.proc.pid) {
|
package/dist/tools/fetchUrl.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { lookup as dnsLookup } from "node:dns/promises";
|
|
2
|
+
import { Agent } from "undici";
|
|
2
3
|
import { truncateResult } from "./common.js";
|
|
3
4
|
import { isPrivateOrLoopbackHost } from "../net/urlSafety.js";
|
|
4
5
|
/** Cap on how much text a single fetch returns, unless the caller asks for less. */
|
|
@@ -46,10 +47,13 @@ function assertPublicUrl(raw) {
|
|
|
46
47
|
/**
|
|
47
48
|
* Resolve `hostname` and check whether any answer is private/loopback.
|
|
48
49
|
* assertPublicUrl only checks the literal hostname string, so a
|
|
49
|
-
* public-looking name that resolves
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
50
|
+
* public-looking name that resolves to an internal IP — DNS rebinding, or
|
|
51
|
+
* simply attacker-controlled DNS — slips past it. This is only a fast,
|
|
52
|
+
* friendly pre-check (a clear "refusing to fetch" error instead of a raw
|
|
53
|
+
* connection failure); the actual boundary is the pinned lookup on
|
|
54
|
+
* `pinnedDispatcher` below, which validates the one DNS answer that's
|
|
55
|
+
* actually connected to rather than resolving twice. lookupFn is injectable
|
|
56
|
+
* for tests, and defaults to a real DNS resolution.
|
|
53
57
|
*/
|
|
54
58
|
export async function hostResolvesToPrivateAddress(hostname, lookupFn = dnsLookup) {
|
|
55
59
|
try {
|
|
@@ -63,22 +67,61 @@ export async function hostResolvesToPrivateAddress(hostname, lookupFn = dnsLooku
|
|
|
63
67
|
}
|
|
64
68
|
/** Refuse to keep chasing redirects forever. */
|
|
65
69
|
const MAX_REDIRECTS = 5;
|
|
70
|
+
/**
|
|
71
|
+
* The actual SSRF boundary: a connect-time `lookup` on a dedicated undici
|
|
72
|
+
* Agent, so the address that gets validated is the exact address the socket
|
|
73
|
+
* connects to — one DNS answer, not two. Checking the hostname up front and
|
|
74
|
+
* then letting `fetch` re-resolve it independently (the previous approach)
|
|
75
|
+
* left a TOCTOU window: attacker-controlled or short-TTL DNS can answer
|
|
76
|
+
* differently the second time (classic DNS rebinding). Pinning the lookup
|
|
77
|
+
* closes that regardless of hop, hostname, or TTL.
|
|
78
|
+
*/
|
|
79
|
+
const pinnedDispatcher = new Agent({
|
|
80
|
+
connect: {
|
|
81
|
+
lookup(hostname, options, callback) {
|
|
82
|
+
dnsLookup(hostname, { all: true })
|
|
83
|
+
.then((addresses) => {
|
|
84
|
+
if (addresses.length === 0) {
|
|
85
|
+
callback(new Error(`Could not resolve ${hostname}`), []);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const bad = addresses.find((a) => isPrivateOrLoopbackHost(a.address));
|
|
89
|
+
if (bad) {
|
|
90
|
+
callback(new Error(`Refusing to connect to ${hostname}: resolves to private/internal address ${bad.address}`), []);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (options.all) {
|
|
94
|
+
callback(null, addresses);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
const chosen = addresses[0];
|
|
98
|
+
callback(null, chosen.address, chosen.family);
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
.catch((err) => callback(err, []));
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
});
|
|
66
105
|
/**
|
|
67
106
|
* Fetch `url`, following redirects manually so every hop — not just the
|
|
68
|
-
* original URL — is checked against assertPublicUrl
|
|
69
|
-
* 302s to 169.254.169.254
|
|
70
|
-
* the initial check, since
|
|
107
|
+
* original URL — is checked against assertPublicUrl before the (DNS-pinned)
|
|
108
|
+
* connection is even attempted. A public URL that 302s to 169.254.169.254
|
|
109
|
+
* or localhost would otherwise sail straight past the initial check, since
|
|
110
|
+
* `redirect: "follow"` never re-validates.
|
|
71
111
|
*/
|
|
72
|
-
async function assertNotDnsRebound(url) {
|
|
73
|
-
if (await hostResolvesToPrivateAddress(url.hostname)) {
|
|
74
|
-
throw new Error(`Refusing to fetch ${url.hostname}: resolves to a private/internal address`);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
112
|
async function fetchFollowingRedirects(url, signal, headers) {
|
|
78
113
|
let current = url;
|
|
79
|
-
await assertNotDnsRebound(current);
|
|
80
114
|
for (let hop = 0;; hop++) {
|
|
81
|
-
|
|
115
|
+
if (await hostResolvesToPrivateAddress(current.hostname)) {
|
|
116
|
+
throw new Error(`Refusing to fetch ${current.hostname}: resolves to a private/internal address`);
|
|
117
|
+
}
|
|
118
|
+
const init = {
|
|
119
|
+
redirect: "manual",
|
|
120
|
+
signal,
|
|
121
|
+
headers,
|
|
122
|
+
dispatcher: pinnedDispatcher,
|
|
123
|
+
};
|
|
124
|
+
const res = await fetch(current, init);
|
|
82
125
|
const isRedirect = res.status >= 300 && res.status < 400;
|
|
83
126
|
const location = res.headers.get("location");
|
|
84
127
|
if (!isRedirect || !location) {
|
|
@@ -88,7 +131,6 @@ async function fetchFollowingRedirects(url, signal, headers) {
|
|
|
88
131
|
throw new Error(`Too many redirects fetching ${url.href} (stopped at ${current.href})`);
|
|
89
132
|
}
|
|
90
133
|
current = assertPublicUrl(new URL(location, current).href);
|
|
91
|
-
await assertNotDnsRebound(current);
|
|
92
134
|
}
|
|
93
135
|
}
|
|
94
136
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kritya",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.22-beta",
|
|
4
4
|
"description": "Kritya — a lean, provider-agnostic terminal coding agent (NVIDIA, OpenAI, OpenRouter, Groq, Ollama, and more)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -67,7 +67,8 @@
|
|
|
67
67
|
"pdfjs-dist": "^6.3.289",
|
|
68
68
|
"pptxgenjs": "^4.0.1",
|
|
69
69
|
"react": "^19.2.8",
|
|
70
|
-
"string-width": "^8.2.2"
|
|
70
|
+
"string-width": "^8.2.2",
|
|
71
|
+
"undici": "^7.29.0"
|
|
71
72
|
},
|
|
72
73
|
"devDependencies": {
|
|
73
74
|
"@eslint/js": "^10.0.1",
|