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 +18 -3
- package/package.json +2 -2
- package/src/mcp-bridge/test-local-browser-guard.mjs +13 -1
- package/src/ruvocal/mcp-bridge/index.js +38 -0
- package/src/ruvocal/src/lib/server/mcp/clientPool.ts +0 -0
- package/src/ruvocal/src/lib/server/mcp/serverTrust.ts +25 -0
- package/src/ruvocal/src/lib/server/mcp/tools.ts +3 -0
- package/src/ruvocal/src/lib/server/textGeneration/mcp/runMcpFlow.ts +2 -2
- package/src/ruvocal/src/lib/server/urlSafety.spec.ts +39 -0
- package/src/ruvocal/src/lib/server/urlSafety.ts +57 -6
- package/src/ruvocal/src/routes/api/fetch-url/+server.ts +2 -33
- package/src/ruvocal/src/routes/api/mcp/health/+server.ts +4 -4
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
|
|
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
|
|
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** |
|
|
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.
|
|
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.
|
|
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
|
|
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();
|
|
Binary file
|
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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 {
|
|
4
|
-
import { isValidUrl,
|
|
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 {
|
|
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 (!
|
|
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`);
|