ruflo 3.71.2 → 3.71.4

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
@@ -36,6 +36,8 @@
36
36
  /ruflo
37
37
  ```
38
38
 
39
+ From a terminal on Claude Code 2.1.292 or later, one command adds the marketplace and installs a plugin: `claude plugin install ruflo-core@ruflo --marketplace ruvnet/ruflo`.
40
+
39
41
  **Core** provides foundation tools. **Console** opens the agent cockpit. **Mods** add routing and policy enforcement inside Claude Code. Mods run with your account's permissions; review their code before installing.
40
42
 
41
43
  <img src="docs/assets/readme/icons/terminal.svg" width="28" height="28" alt=""> **Using Codex or another MCP enabled tool?** Use NPX for Ruflo project setup and connect your client to the Ruflo MCP server. Run the setup wizard in your project terminal:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruflo",
3
- "version": "3.71.2",
3
+ "version": "3.71.4",
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.2"
43
+ "@claude-flow/cli": "3.71.4"
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`);