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.
@@ -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
- await fsp.writeFile(tmp, data, options.encoding ?? (typeof data === "string" ? "utf8" : null));
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, options.encoding ?? (typeof data === "string" ? "utf8" : null));
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) {
@@ -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
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
140
- // `mode` on writeFileSync only applies when creating a new file; enforce it
141
- // even if config.json pre-existed with looser permissions.
142
- try {
143
- fs.chmodSync(CONFIG_FILE, 0o600);
144
- }
145
- catch (err) {
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
@@ -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 { isPrivateOrLoopbackHost } from "../net/urlSafety.js";
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. `/mcp add` already
555
- * refuses these, but that guards one entrance: a server hand-written into
556
- * ~/.kritya/config.json or a repo's .mcp.json never passes through it and
557
- * would happily POST a bearer token in the clear. This is the choke point all
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
- let parsed;
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 res = await fetch(url, { headers: ua(), signal: AbortSignal.timeout(timeoutMs) });
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({
@@ -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
+ }
@@ -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,
@@ -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
- return (resolved.endsWith(".jsonl") &&
168
- relative !== "" &&
169
- !relative.startsWith("..") &&
170
- !path.isAbsolute(relative));
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 = [];
@@ -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
- const entry = { proc, command, buffer: "", exitCode: null, running: true };
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
- entry.buffer += chunk.toString();
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.buffer += `\n[failed to start: ${err.message}]`;
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) {
@@ -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 (now, or on a later request) to an
50
- * internal IP — DNS rebinding, or simply attacker-controlled DNS — slips
51
- * past it. This closes that gap for the initial request; lookupFn is
52
- * injectable for tests, and defaults to a real DNS resolution.
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. A public URL that
69
- * 302s to 169.254.169.254 or localhost would otherwise sail straight past
70
- * the initial check, since `redirect: "follow"` never re-validates.
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
- const res = await fetch(current, { redirect: "manual", signal, headers });
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.21-beta",
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",