ruflo 3.71.3 → 3.72.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 CHANGED
@@ -112,7 +112,7 @@ User → Ruflo (CLI/MCP) → Router → Swarm → Agents → Memory → LLM prov
112
112
 
113
113
  </details>
114
114
 
115
- > **New to Ruflo?** You don't need to learn 314 MCP tools or 26 CLI commands. After `init`, just use Claude Code normally — the hooks system automatically routes tasks, learns from successful patterns, and coordinates agents in the background.
115
+ > **New to Ruflo?** You don't need to learn 406 MCP tools or 61 CLI commands. After `init`, just use Claude Code normally — the hooks system automatically routes tasks, learns from successful patterns, and coordinates agents in the background.
116
116
 
117
117
  ### An origin you can inspect
118
118
 
@@ -205,7 +205,7 @@ There are **two different install paths** with very different surface areas. Pic
205
205
  This adds slash commands and agent definitions. `ruflo-core` (installed above) does register its own MCP server on install — its tools are callable as `mcp__plugin_ruflo-core_ruflo__*` (e.g. `mcp__plugin_ruflo-core_ruflo__memory_store`), not the bare `memory_store`/`swarm_init`/`agent_spawn` names the CLI-track scaffold uses. Other plugins generally don't ship their own MCP server. For the full loop with the CLI-track tool names, use Path B below.
206
206
 
207
207
  <details>
208
- <summary><strong>🔌 All 35 plugins</strong></summary>
208
+ <summary><strong>🔌 All 48 plugins</strong> (plus 3 external)</summary>
209
209
 
210
210
  #### Core & Orchestration
211
211
 
@@ -217,6 +217,14 @@ This adds slash commands and agent definitions. `ruflo-core` (installed above) d
217
217
  | [**ruflo-loop-workers**](plugins/ruflo-loop-workers/README.md) | Schedule background tasks on a timer |
218
218
  | [**ruflo-workflows**](plugins/ruflo-workflows/README.md) | Reusable multi-step task templates |
219
219
  | [**ruflo-federation**](plugins/ruflo-federation/README.md) | Agents on different machines collaborate securely |
220
+ | [**ruflo-console**](plugins/ruflo-console/README.md) | Cockpit inside Claude Code, and the `/ruflo` command for ruflo mods |
221
+ | [**ruflo-mods**](plugins/ruflo-mods/README.md) | In-process Claude Code mod — routing, learning signals, and policy checks |
222
+ | [**ruflo-ai-team**](plugins/ruflo-ai-team/README.md) | Tenant-isolated team that divides work and returns evidence |
223
+ | [**ruflo-agntcy**](plugins/ruflo-agntcy/README.md) | AGNTCY/Outshift transport and intent-scoped tool authorization |
224
+ | [**ruflo-bbs-federation**](plugins/ruflo-bbs-federation/README.md) | AgentBBS rooms so agents on different hosts can coordinate |
225
+ | [**ruflo-chatgpt-federation**](plugins/ruflo-chatgpt-federation/README.md) | ChatGPT federation publisher with its own signing keys |
226
+ | [**ruflo-x-gateway**](plugins/ruflo-x-gateway/README.md) | Signed federation messages, channels, and work claims |
227
+ | [**ruflo-business-pods**](plugins/ruflo-business-pods/README.md) | Business-domain pod templates and a dry-run tick runner |
220
228
 
221
229
  #### Memory & Knowledge
222
230
 
@@ -237,6 +245,7 @@ This adds slash commands and agent definitions. `ruflo-core` (installed above) d
237
245
  | [**ruflo-daa**](plugins/ruflo-daa/README.md) | Dynamic agent behavior and cognitive patterns |
238
246
  | [**ruflo-ruvllm**](plugins/ruflo-ruvllm/README.md) | Run local LLMs (Ollama, etc.) with smart routing |
239
247
  | [**ruflo-goals**](plugins/ruflo-goals/README.md) | Break big goals into plans and track progress |
248
+ | [**ruflo-deepseek-harness**](plugins/ruflo-deepseek-harness/README.md) | Agents, commands, and skills for DeepSeek-backed workflows |
240
249
 
241
250
  #### Code Quality & Testing
242
251
 
@@ -245,6 +254,7 @@ This adds slash commands and agent definitions. `ruflo-core` (installed above) d
245
254
  | [**ruflo-testgen**](plugins/ruflo-testgen/README.md) | Find missing tests and generate them automatically |
246
255
  | [**ruflo-browser**](plugins/ruflo-browser/README.md) | Automate browser testing with Playwright |
247
256
  | [**ruflo-jujutsu**](plugins/ruflo-jujutsu/README.md) | Analyze git diffs, score risk, suggest reviewers |
257
+ | [**ruflo-rure**](plugins/ruflo-rure/) | Static analysis of a local JS/Electron app you are authorized to study, with file and line evidence |
248
258
  | [**ruflo-docs**](plugins/ruflo-docs/README.md) | Generate and maintain documentation automatically |
249
259
  | [**agentic-qe-fleet**](https://github.com/proffesor-for-testing/agentic-qe/tree/v3.15.17/plugins/agentic-qe-fleet) | External, maintained by [Agentic QE](https://github.com/proffesor-for-testing/agentic-qe): 11 QE agents, test generation, coverage, chaos and TDD skills, the `agentic-qe` MCP server, and a guard for AQE learning data (`/plugin install agentic-qe-fleet@ruflo`). Install it from one marketplace only, `agentic-qe` or `ruflo`: enabled from both, its mod and commands load twice |
250
260
  | [**agentdb**](https://github.com/ruvnet/agentdb/tree/plugin-v3.2.0/plugins/agentdb) | External, maintained in [ruvnet/agentdb](https://github.com/ruvnet/agentdb): agentdb's own MCP server (pinned `agentdb@3.2.0`: hybrid keyword + vector search, opt-in cross-encoder rerank), a memory-specialist agent, vector-memory / learning / diagnostics skills, `/agentdb-status`, and a guard that keeps secrets out of memory and refuses delete/reset unless allowed (`/plugin install agentdb@ruflo`). Use `ruflo-agentdb` instead if you want AgentDB through ruflo's server; the two can coexist. Install it from one marketplace only, `agentdb` or `ruflo` |
@@ -256,6 +266,7 @@ This adds slash commands and agent definitions. `ruflo-core` (installed above) d
256
266
  |--------|-------------|
257
267
  | [**ruflo-security-audit**](plugins/ruflo-security-audit/README.md) | Scan for vulnerabilities and CVEs |
258
268
  | [**ruflo-aidefence**](plugins/ruflo-aidefence/README.md) | Block prompt injection, detect PII, safety scanning |
269
+ | [**ruflo-protector**](plugins/ruflo-protector/README.md) | Optional watchdog for unattended agents — learn, notify, or enforce |
259
270
 
260
271
  #### Architecture & Methodology
261
272
 
@@ -274,6 +285,7 @@ This adds slash commands and agent definitions. `ruflo-core` (installed above) d
274
285
  | [**ruflo-migrations**](plugins/ruflo-migrations/README.md) | Manage database schema changes safely |
275
286
  | [**ruflo-observability**](plugins/ruflo-observability/README.md) | Structured logs, traces, and metrics in one place |
276
287
  | [**ruflo-cost-tracker**](plugins/ruflo-cost-tracker/README.md) | Track token usage, set budgets, get cost alerts |
288
+ | [**ruflo-ruos**](plugins/ruflo-ruos/README.md) | Run swarm agents on your own ruOS cloud desktops |
277
289
 
278
290
  #### Extensibility
279
291
 
@@ -289,6 +301,7 @@ This adds slash commands and agent definitions. `ruflo-core` (installed above) d
289
301
  | [**ruflo-iot-cognitum**](plugins/ruflo-iot-cognitum/README.md) | IoT device management — trust scoring, anomaly detection, fleets |
290
302
  | [**ruflo-neural-trader**](plugins/ruflo-neural-trader/README.md) | [`neural-trader`](https://npmjs.com/package/neural-trader) — AI trading with 4 agents, backtesting, 112+ tools |
291
303
  | [**ruflo-market-data**](plugins/ruflo-market-data/README.md) | Ingest market data, vectorize OHLCV, detect patterns |
304
+ | [**ruflo-music**](plugins/ruflo-music/README.md) | Compose and generate music through a Cognitum Music account |
292
305
 
293
306
  </details>
294
307
 
@@ -323,6 +336,8 @@ npm install -g ruflo@latest
323
336
  claude mcp add claude-flow -- npx ruflo@latest mcp start
324
337
  ```
325
338
 
339
+ For static inspection of local applications and binaries, see the [ruRE plugin guide](plugins/ruflo-rure/README.md).
340
+
326
341
  ---
327
342
 
328
343
  ## What You Get
@@ -343,7 +358,7 @@ claude mcp add claude-flow -- npx ruflo@latest mcp start
343
358
  | <img src="docs/assets/readme/icons/learning.svg" width="28" height="28" alt=""> **Self-Learning** | SONA neural patterns, ReasoningBank, trajectory learning |
344
359
  | <img src="docs/assets/readme/icons/memory.svg" width="28" height="28" alt=""> **Vector Memory** | HNSW-indexed AgentDB — measured ~1.9x faster at N=20k, ~3.2x–4.7x at N=5k vs brute force (recall@10 ~0.99); ANN wins above the crossover, ties/loses at small N. See [audit](docs/reviews/intelligence-system-audit-2026-05-29.md) + [`scripts/benchmark-intelligence.mjs`](scripts/benchmark-intelligence.mjs) |
345
360
  | <img src="docs/assets/readme/icons/workers.svg" width="28" height="28" alt=""> **Background Workers** | 12 auto-triggered workers (audit, optimize, testgaps, etc.) |
346
- | <img src="docs/assets/readme/icons/plugins.svg" width="28" height="28" alt=""> **Plugin Marketplace** | 33 native Claude Code plugins + 21 npm plugins |
361
+ | <img src="docs/assets/readme/icons/plugins.svg" width="28" height="28" alt=""> **Plugin Marketplace** | 48 native Claude Code plugins + 14 in-tree npm plugins |
347
362
  | <img src="docs/assets/readme/icons/routing.svg" width="28" height="28" alt=""> **Multi-Provider** | Claude, GPT, Gemini, Cohere, Ollama with smart routing |
348
363
  | <img src="docs/assets/readme/icons/security.svg" width="28" height="28" alt=""> **Security** | AIDefence, input validation, CVE remediation, path traversal prevention |
349
364
  | <img src="docs/assets/readme/icons/federation.svg" width="28" height="28" alt=""> **Agent Federation** | Cross-installation agent collaboration with zero-trust security |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruflo",
3
- "version": "3.71.3",
3
+ "version": "3.72.0",
4
4
  "description": "Ruflo - Enterprise AI agent orchestration platform. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration",
5
5
  "main": "bin/ruflo.js",
6
6
  "type": "module",
@@ -40,7 +40,7 @@
40
40
  "package:rvf": "bash src/scripts/package-rvf.sh"
41
41
  },
42
42
  "dependencies": {
43
- "@claude-flow/cli": "3.71.3"
43
+ "@claude-flow/cli": "3.72.0"
44
44
  },
45
45
  "optionalDependencies": {},
46
46
  "overrides": {
@@ -11,6 +11,8 @@
11
11
  * G6. an Origin listed in MCP_CORS_ORIGIN is still accepted
12
12
  * G7. with MCP_AUTH_TOKEN set the guard is off and bearer auth decides
13
13
  *
14
+ * Runs against both bridges (src/mcp-bridge and src/ruvocal/mcp-bridge).
15
+ *
14
16
  * node test-local-browser-guard.mjs
15
17
  */
16
18
  import { spawn } from "node:child_process";
@@ -19,7 +21,13 @@ import path from "node:path";
19
21
  import { setTimeout as sleep } from "node:timers/promises";
20
22
  import { fileURLToPath } from "node:url";
21
23
 
22
- const ENTRY = path.join(path.dirname(fileURLToPath(import.meta.url)), "index.js");
24
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
25
+ // Both bridges carry the same guard; ruvocal's copy missed #4226 until it was ported.
26
+ const ENTRIES = [
27
+ { label: "src/mcp-bridge", entry: path.join(HERE, "index.js") },
28
+ { label: "src/ruvocal/mcp-bridge", entry: path.join(HERE, "..", "ruvocal", "mcp-bridge", "index.js") },
29
+ ];
30
+ let ENTRY = ENTRIES[0].entry;
23
31
  const QUIET = {
24
32
  MCP_GROUP_INTELLIGENCE: "false",
25
33
  MCP_GROUP_AGENTS: "false",
@@ -64,6 +72,9 @@ function request(port, { method = "POST", path: p = "/mcp", headers = {}, body }
64
72
  const initialize = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} });
65
73
  const json = { "Content-Type": "application/json" };
66
74
 
75
+ for (const { label, entry } of ENTRIES) {
76
+ ENTRY = entry;
77
+ console.log(`\n[${label}]`);
67
78
  console.log("local-only mode (no MCP_AUTH_TOKEN)");
68
79
  await withBridge({ MCP_CORS_ORIGIN: "" }, async (port) => {
69
80
  check((await request(port, { headers: json, body: initialize })).status === 200, "G1 native client reaches /mcp");
@@ -82,6 +93,7 @@ await withBridge({ MCP_AUTH_TOKEN: "t0ken" }, async (port) => {
82
93
  const res = await request(port, { headers: { ...json, Origin: "https://example.com", Authorization: "Bearer t0ken" }, body: initialize });
83
94
  check(res.status === 200, "G7 bearer token decides when set");
84
95
  });
96
+ }
85
97
 
86
98
  console.log(failed ? `\n${failed} check(s) failed` : "\nall checks passed");
87
99
  process.exit(failed ? 1 : 0);
@@ -1069,6 +1069,7 @@ app.use((req, res, next) => {
1069
1069
  const CORS_ALLOWLIST = (process.env.MCP_CORS_ORIGIN || "*")
1070
1070
  .split(",").map(s => s.trim()).filter(Boolean);
1071
1071
  const CORS_WILDCARD = CORS_ALLOWLIST.length === 1 && CORS_ALLOWLIST[0] === "*";
1072
+ app.use((req, res, next) => localBrowserGuard(req, res, next));
1072
1073
  app.use((req, res, next) => {
1073
1074
  const origin = req.get("origin") || "";
1074
1075
  if (CORS_WILDCARD) {
@@ -1087,6 +1088,43 @@ app.use((req, res, next) => {
1087
1088
  // ---------- Auth middleware ----------
1088
1089
  // No-op in local-only mode (MCP_AUTH_TOKEN unset). Enforces 401 when token is set.
1089
1090
  const MCP_TOKEN = process.env.MCP_AUTH_TOKEN || "";
1091
+
1092
+ // ---------- Local-only browser guard (#4226; same guard as ruflo/src/mcp-bridge) ----------
1093
+ // Binding to loopback does not keep web pages out: any page the user opens
1094
+ // can send requests to 127.0.0.1, and a DNS-rebinding page can even make the
1095
+ // request look same-origin. CORS only decides whether the page may READ the
1096
+ // reply; a "simple" cross-origin POST runs without a preflight. So when there
1097
+ // is no MCP_AUTH_TOKEN, refuse requests that a browser sent on behalf of a
1098
+ // foreign page:
1099
+ // - the Host header must name a loopback address (stops DNS rebinding);
1100
+ // - a request carrying an Origin header must come from a loopback origin or
1101
+ // from an origin listed explicitly in MCP_CORS_ORIGIN.
1102
+ // Native MCP clients (CLI, IDE, curl) send no Origin and a loopback Host.
1103
+ const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
1104
+ function hostnameOf(value) {
1105
+ try {
1106
+ return new URL(value.includes("://") ? value : `http://${value}`).hostname.toLowerCase();
1107
+ } catch {
1108
+ return "";
1109
+ }
1110
+ }
1111
+ function isLoopbackHostname(hostname) {
1112
+ return LOOPBACK_HOSTNAMES.has(hostname) || hostname.endsWith(".localhost");
1113
+ }
1114
+ function localBrowserGuard(req, res, next) {
1115
+ if (MCP_TOKEN) return next();
1116
+ if (!isLoopbackHostname(hostnameOf(req.get("host") || ""))) {
1117
+ return res.status(403).json({ error: "forbidden host" });
1118
+ }
1119
+ const origin = req.get("origin");
1120
+ if (origin !== undefined) {
1121
+ const explicitlyAllowed = !CORS_WILDCARD && CORS_ALLOWLIST.includes(origin);
1122
+ if (!explicitlyAllowed && !isLoopbackHostname(hostnameOf(origin))) {
1123
+ return res.status(403).json({ error: "forbidden origin" });
1124
+ }
1125
+ }
1126
+ next();
1127
+ }
1090
1128
  function requireAuth(req, res, next) {
1091
1129
  if (req.path === "/health") return next();
1092
1130
  if (!MCP_TOKEN) return next();
@@ -0,0 +1,25 @@
1
+ import { isValidUrl, ssrfSafeFetch } from "$lib/server/urlSafety";
2
+ import { getMcpServers } from "./registry";
3
+
4
+ /**
5
+ * An MCP server URL the operator configured (MCP_SERVERS). Only these may use
6
+ * loopback, Docker-internal or other private addresses; a URL supplied in a
7
+ * request — including by an anonymous chat session — must be public.
8
+ */
9
+ export function isConfiguredMcpUrl(url: string): boolean {
10
+ return getMcpServers().some((s) => s.url === url);
11
+ }
12
+
13
+ /** Whether an MCP server URL may be contacted at all. */
14
+ export function isAllowedMcpUrl(url: string): boolean {
15
+ return isValidUrl(url, { allowPrivate: isConfiguredMcpUrl(url) });
16
+ }
17
+
18
+ /**
19
+ * The fetch an MCP transport should use: the default for operator-configured
20
+ * servers, and one pinned to public addresses at connect time for everything
21
+ * else, so a public hostname that resolves to a private address is refused.
22
+ */
23
+ export function mcpFetchFor(url: string): typeof fetch | undefined {
24
+ return isConfiguredMcpUrl(url) ? undefined : ssrfSafeFetch;
25
+ }
@@ -2,6 +2,7 @@ import { Client } from "@modelcontextprotocol/sdk/client";
2
2
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
3
  import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
4
4
  import type { McpServerConfig } from "./httpClient";
5
+ import { mcpFetchFor } from "./serverTrust";
5
6
  import { logger } from "$lib/server/logger";
6
7
  // use console.* for lightweight diagnostics in production logs
7
8
 
@@ -71,11 +72,13 @@ async function listServerTools(
71
72
  try {
72
73
  const transport = new StreamableHTTPClientTransport(url, {
73
74
  requestInit: { headers: server.headers, signal: opts.signal },
75
+ fetch: mcpFetchFor(server.url),
74
76
  });
75
77
  await client.connect(transport);
76
78
  } catch {
77
79
  const transport = new SSEClientTransport(url, {
78
80
  requestInit: { headers: server.headers, signal: opts.signal },
81
+ fetch: mcpFetchFor(server.url),
79
82
  });
80
83
  await client.connect(transport);
81
84
  }
@@ -2,7 +2,7 @@ import { config } from "$lib/server/config";
2
2
  import { mergeMcpServers } from "./mergeServers";
3
3
  import { MessageUpdateType, type MessageUpdate } from "$lib/types/MessageUpdate";
4
4
  import { getMcpServers } from "$lib/server/mcp/registry";
5
- import { isValidUrl } from "$lib/server/urlSafety";
5
+ import { isAllowedMcpUrl } from "$lib/server/mcp/serverTrust";
6
6
  import { resetMcpToolsCache } from "$lib/server/mcp/tools";
7
7
  import { getOpenAiToolsForMcp } from "$lib/server/mcp/tools";
8
8
  import type {
@@ -144,7 +144,7 @@ export async function* runMcpFlow({
144
144
  const before = servers.slice();
145
145
  servers = servers.filter((s) => {
146
146
  try {
147
- return isValidUrl(s.url);
147
+ return isAllowedMcpUrl(s.url);
148
148
  } catch {
149
149
  return false;
150
150
  }
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isValidUrl } from "./urlSafety";
3
+
4
+ // URLs from a request (custom MCP servers, fetch-url) must be public HTTPS.
5
+ // The local-development allowances apply only to operator-configured URLs.
6
+ describe("isValidUrl", () => {
7
+ it("accepts public HTTPS URLs", () => {
8
+ expect(isValidUrl("https://mcp.example.com/mcp")).toBe(true);
9
+ expect(isValidUrl("https://8.8.8.8/")).toBe(true);
10
+ });
11
+
12
+ it("refuses loopback, Docker-internal, dotless and private targets by default", () => {
13
+ for (const url of [
14
+ "http://localhost:3001/mcp",
15
+ "https://localhost/mcp",
16
+ "http://127.0.0.1:3001/mcp",
17
+ "https://[::1]/",
18
+ "http://host.docker.internal:3001/mcp",
19
+ "http://mongodb:27017/",
20
+ "http://metadata/computeMetadata/v1/",
21
+ "https://metadata/",
22
+ "https://10.0.0.5/",
23
+ "https://192.168.1.10/",
24
+ "https://169.254.169.254/latest/meta-data/",
25
+ "https://[fd00::1]/",
26
+ "https://bridge.localhost/",
27
+ "https://metadata.google.internal/",
28
+ "http://mcp.example.com/mcp",
29
+ ]) {
30
+ expect(isValidUrl(url), url).toBe(false);
31
+ }
32
+ });
33
+
34
+ it("keeps the local allowances for operator-configured URLs", () => {
35
+ expect(isValidUrl("http://localhost:3001/mcp", { allowPrivate: true })).toBe(true);
36
+ expect(isValidUrl("http://mcp-bridge:3001/mcp", { allowPrivate: true })).toBe(true);
37
+ expect(isValidUrl("https://10.0.0.5/", { allowPrivate: true })).toBe(true);
38
+ });
39
+ });
@@ -1,8 +1,11 @@
1
1
  import { Address4, Address6 } from "ip-address";
2
2
  import { isIP } from "node:net";
3
+ import dns from "node:dns";
4
+ import { Agent, fetch as undiciFetch } from "undici";
3
5
 
4
6
  const UNSAFE_IPV4_SUBNETS = [
5
7
  "0.0.0.0/8",
8
+ "10.0.0.0/8",
6
9
  "100.64.0.0/10",
7
10
  "127.0.0.0/8",
8
11
  "169.254.0.0/16",
@@ -25,30 +28,41 @@ function isUnsafeIp(address: string): boolean {
25
28
  const v4 = addr.to4();
26
29
  return UNSAFE_IPV4_SUBNETS.some((subnet) => v4.isInSubnet(subnet));
27
30
  }
28
- return addr.isLoopback() || addr.isLinkLocal();
31
+ // Unique-local (fc00::/7) is private address space, like 10/8 for IPv4.
32
+ const ula = addr.isInSubnet(new Address6("fc00::/7"));
33
+ return addr.isLoopback() || addr.isLinkLocal() || ula || cleanIsUnspecified(address);
29
34
  }
30
35
 
31
36
  return true; // Unknown format → block
32
37
  }
33
38
 
39
+ function cleanIsUnspecified(address: string): boolean {
40
+ return /^(::|0:0:0:0:0:0:0:0)$/.test(address);
41
+ }
42
+
34
43
  /**
35
44
  * Synchronous URL validation: checks protocol and hostname string.
45
+ *
46
+ * By default only public HTTPS URLs pass: no loopback or Docker-internal
47
+ * names, no dotless hosts, no private IP literals. `allowPrivate` restores the
48
+ * local-development allowances and is only for URLs the operator configured
49
+ * (MCP_SERVERS), never for URLs taken from a request.
36
50
  */
37
- export function isValidUrl(urlString: string): boolean {
51
+ export function isValidUrl(urlString: string, opts: { allowPrivate?: boolean } = {}): boolean {
38
52
  try {
39
53
  const url = new URL(urlString.trim());
40
54
  const hostname = url.hostname.toLowerCase();
41
55
  // Allow HTTP for localhost/loopback/Docker-internal (dev & local MCP bridge)
42
- if (
56
+ if (opts.allowPrivate && (
43
57
  hostname === "localhost" ||
44
58
  hostname === "127.0.0.1" ||
45
59
  hostname === "::1" ||
46
60
  hostname === "host.docker.internal"
47
- ) {
61
+ )) {
48
62
  return url.protocol === "http:" || url.protocol === "https:";
49
63
  }
50
64
  // Allow HTTP for Docker-internal service names (no dots = private network)
51
- if (!hostname.includes(".") && url.protocol === "http:") {
65
+ if (opts.allowPrivate && !hostname.includes(".") && url.protocol === "http:") {
52
66
  return true;
53
67
  }
54
68
  if (url.protocol !== "https:") {
@@ -57,7 +71,10 @@ export function isValidUrl(urlString: string): boolean {
57
71
  // If the hostname is a raw IP literal, validate it
58
72
  const cleanHostname = hostname.replace(/^\[|]$/g, "");
59
73
  if (isIP(cleanHostname)) {
60
- return !isUnsafeIp(cleanHostname);
74
+ return opts.allowPrivate || !isUnsafeIp(cleanHostname);
75
+ }
76
+ if (!opts.allowPrivate && (!hostname.includes(".") || hostname.endsWith(".localhost") || hostname.endsWith(".internal") || hostname.endsWith(".local"))) {
77
+ return false;
61
78
  }
62
79
  return true;
63
80
  } catch {
@@ -75,3 +92,37 @@ export function assertSafeIp(address: string, hostname: string): void {
75
92
  throw new Error(`Resolved IP for ${hostname} is internal (${address})`);
76
93
  }
77
94
  }
95
+
96
+ /**
97
+ * Undici dispatcher that validates resolved IPs at connection time,
98
+ * preventing TOCTOU DNS rebinding attacks.
99
+ */
100
+ export const ssrfSafeAgent = new Agent({
101
+ connect: {
102
+ lookup: (hostname, options, callback) => {
103
+ dns.lookup(hostname, options, (err, address, family) => {
104
+ if (err) return callback(err, "", 4);
105
+ if (typeof address === "string") {
106
+ try {
107
+ assertSafeIp(address, hostname);
108
+ } catch (e) {
109
+ return callback(e as Error, "", 4);
110
+ }
111
+ } else if (Array.isArray(address)) {
112
+ for (const entry of address) {
113
+ try {
114
+ assertSafeIp(entry.address, hostname);
115
+ } catch (e) {
116
+ return callback(e as Error, "", 4);
117
+ }
118
+ }
119
+ }
120
+ return callback(null, address, family);
121
+ });
122
+ },
123
+ },
124
+ });
125
+
126
+ /** fetch() whose connections may only reach public addresses (see ssrfSafeAgent). */
127
+ export const ssrfSafeFetch = ((input: string | URL, init?: RequestInit) =>
128
+ undiciFetch(input, { ...(init as object), dispatcher: ssrfSafeAgent } as Parameters<typeof undiciFetch>[1])) as unknown as typeof fetch;
@@ -1,8 +1,7 @@
1
1
  import { error } from "@sveltejs/kit";
2
2
  import { logger } from "$lib/server/logger.js";
3
- import { Agent, fetch } from "undici";
4
- import { isValidUrl, assertSafeIp } from "$lib/server/urlSafety";
5
- import dns from "node:dns";
3
+ import { fetch } from "undici";
4
+ import { isValidUrl, ssrfSafeAgent } from "$lib/server/urlSafety";
6
5
 
7
6
  const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
8
7
  const FETCH_TIMEOUT = 30000; // 30 seconds
@@ -16,36 +15,6 @@ const SECURITY_HEADERS: HeadersInit = {
16
15
  "Referrer-Policy": "no-referrer",
17
16
  };
18
17
 
19
- /**
20
- * Undici dispatcher that validates resolved IPs at connection time,
21
- * preventing TOCTOU DNS rebinding attacks.
22
- */
23
- const ssrfSafeAgent = new Agent({
24
- connect: {
25
- lookup: (hostname, options, callback) => {
26
- dns.lookup(hostname, options, (err, address, family) => {
27
- if (err) return callback(err, "", 4);
28
- if (typeof address === "string") {
29
- try {
30
- assertSafeIp(address, hostname);
31
- } catch (e) {
32
- return callback(e as Error, "", 4);
33
- }
34
- } else if (Array.isArray(address)) {
35
- for (const entry of address) {
36
- try {
37
- assertSafeIp(entry.address, hostname);
38
- } catch (e) {
39
- return callback(e as Error, "", 4);
40
- }
41
- }
42
- }
43
- return callback(null, address, family);
44
- });
45
- },
46
- },
47
- });
48
-
49
18
  export async function GET({ url }) {
50
19
  const targetUrl = url.searchParams.get("url");
51
20
 
@@ -5,7 +5,7 @@ import type { KeyValuePair } from "$lib/types/Tool";
5
5
  import { config } from "$lib/server/config";
6
6
  import { logger } from "$lib/server/logger";
7
7
  import type { RequestHandler } from "./$types";
8
- import { isValidUrl } from "$lib/server/urlSafety";
8
+ import { isAllowedMcpUrl, mcpFetchFor } from "$lib/server/mcp/serverTrust";
9
9
  import { isStrictHfMcpLogin, hasNonEmptyToken, isExaMcpServer } from "$lib/server/mcp/hf";
10
10
 
11
11
  interface HealthCheckRequest {
@@ -40,7 +40,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
40
40
 
41
41
  // URL validation handled above
42
42
 
43
- if (!isValidUrl(url)) {
43
+ if (!isAllowedMcpUrl(url)) {
44
44
  return new Response(
45
45
  JSON.stringify({
46
46
  ready: false,
@@ -111,7 +111,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
111
111
  version: "1.0.0",
112
112
  });
113
113
 
114
- const transport = new StreamableHTTPClientTransport(baseUrl, { requestInit });
114
+ const transport = new StreamableHTTPClientTransport(baseUrl, { requestInit, fetch: mcpFetchFor(url) });
115
115
  logger.info({}, `[MCP Health] Connecting to ${url}...`);
116
116
  await client.connect(transport);
117
117
  logger.info({}, `[MCP Health] Connected successfully via HTTP`);
@@ -174,7 +174,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
174
174
  version: "1.0.0",
175
175
  });
176
176
 
177
- const sseTransport = new SSEClientTransport(baseUrl, { requestInit });
177
+ const sseTransport = new SSEClientTransport(baseUrl, { requestInit, fetch: mcpFetchFor(url) });
178
178
  logger.info({}, `[MCP Health] Connecting via SSE...`);
179
179
  await client.connect(sseTransport);
180
180
  logger.info({}, `[MCP Health] Connected successfully via SSE`);