@open-mercato/ai-assistant 0.6.6-develop.5675.1.d585628349 → 0.6.6-develop.5706.1.dbef29f570

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/AGENTS.md CHANGED
@@ -1464,6 +1464,19 @@ Agents that need multi-step tool loops configure the `loop` block on `AiAgentDef
1464
1464
 
1465
1465
  ## Changelog
1466
1466
 
1467
+ ### 2026-06-11 - Harden latent MCP server-config module (#2672)
1468
+
1469
+ **What changed** (`lib/mcp-server-config.ts`, currently dead code — no callers):
1470
+ - Added `validateMcpServerUrl()` (exported): restricts external MCP server URLs to `http:`/`https:` (blocks `file:`/`gopher:`/`data:` local-file disclosure) and rejects literal loopback, link-local (`169.254.0.0/16`, `fe80::/10`), and RFC1918 private hosts plus `localhost`/`0.0.0.0`/IPv4-mapped IPv6 — reducing SSRF exposure if a management route is ever wired up. `validateMcpServerConfig` now uses it for HTTP configs.
1471
+ - `saveMcpServerConfig` / `updateMcpServerConfig` now call `validateMcpServerConfig` and throw on invalid input, so persistence is **fail-closed**.
1472
+ - `generateId()` now uses `randomUUID()` (CSPRNG) instead of `Date.now()` + `Math.random()`.
1473
+
1474
+ Note: the guard is intentionally NOT added to `mcp-client.ts` `connectHttp`, which legitimately connects to the app's own loopback MCP server (`localhost:3001`). DNS-rebinding (resolution-time checks) is out of scope for this dead-code hardening.
1475
+
1476
+ **Files**: `lib/mcp-server-config.ts`. Regression test: `lib/__tests__/mcp-server-config-hardening.test.ts`.
1477
+
1478
+ **Backward compatibility**: `validateMcpServerUrl` is additive. The module has no callers, so the tightened HTTP validation + fail-closed persistence change no live behavior.
1479
+
1467
1480
  ### 2026-06-11 - MCP stdio server fails closed without auth (#2673)
1468
1481
 
1469
1482
  **What changed**:
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  const MCP_SERVERS_CONFIG_KEY = "mcp_servers";
2
3
  async function getMcpServerConfigs(resolver) {
3
4
  let service;
@@ -61,6 +62,10 @@ async function saveMcpServerConfig(resolver, config) {
61
62
  };
62
63
  updatedConfigs = [...configs, savedConfig];
63
64
  }
65
+ const validation = validateMcpServerConfig(savedConfig);
66
+ if (!validation.valid) {
67
+ throw new Error(`[internal] Invalid MCP server config: ${validation.error}`);
68
+ }
64
69
  await service.setValue("ai_assistant", MCP_SERVERS_CONFIG_KEY, updatedConfigs);
65
70
  return savedConfig;
66
71
  }
@@ -83,6 +88,10 @@ async function updateMcpServerConfig(resolver, serverId, updates) {
83
88
  // Ensure ID is preserved
84
89
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
85
90
  };
91
+ const validation = validateMcpServerConfig(updatedConfig);
92
+ if (!validation.valid) {
93
+ throw new Error(`[internal] Invalid MCP server config: ${validation.error}`);
94
+ }
86
95
  const updatedConfigs = [
87
96
  ...configs.slice(0, existingIndex),
88
97
  updatedConfig,
@@ -120,7 +129,74 @@ async function toggleMcpServerEnabled(resolver, serverId) {
120
129
  });
121
130
  }
122
131
  function generateId() {
123
- return `mcp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`;
132
+ return `mcp_${randomUUID()}`;
133
+ }
134
+ const ALLOWED_MCP_URL_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
135
+ const BLOCKED_MCP_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "0.0.0.0", "::", "::1"]);
136
+ function parseIpv4Octets(hostname) {
137
+ const parts = hostname.split(".");
138
+ if (parts.length !== 4) return null;
139
+ const octets = parts.map((part) => /^\d{1,3}$/.test(part) ? Number(part) : NaN);
140
+ if (octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) {
141
+ return null;
142
+ }
143
+ return octets;
144
+ }
145
+ function isPrivateOrLocalIpv4(octets) {
146
+ const [first, second] = octets;
147
+ if (first === 0) return true;
148
+ if (first === 127) return true;
149
+ if (first === 10) return true;
150
+ if (first === 172 && second >= 16 && second <= 31) return true;
151
+ if (first === 192 && second === 168) return true;
152
+ if (first === 169 && second === 254) return true;
153
+ return false;
154
+ }
155
+ function isPrivateOrLocalIpv6(hostname) {
156
+ const host = hostname.toLowerCase();
157
+ if (host === "::1" || host === "::") return true;
158
+ if (host.startsWith("fe80")) return true;
159
+ if (host.startsWith("fc") || host.startsWith("fd")) return true;
160
+ const ipv4MappedDotted = host.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
161
+ if (ipv4MappedDotted) {
162
+ const octets = parseIpv4Octets(ipv4MappedDotted[1]);
163
+ if (octets && isPrivateOrLocalIpv4(octets)) return true;
164
+ }
165
+ const ipv4MappedHex = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
166
+ if (ipv4MappedHex) {
167
+ const high = parseInt(ipv4MappedHex[1], 16);
168
+ const low = parseInt(ipv4MappedHex[2], 16);
169
+ const octets = [high >> 8 & 255, high & 255, low >> 8 & 255, low & 255];
170
+ if (isPrivateOrLocalIpv4(octets)) return true;
171
+ }
172
+ return false;
173
+ }
174
+ function validateMcpServerUrl(rawUrl) {
175
+ let parsed;
176
+ try {
177
+ parsed = new URL(rawUrl);
178
+ } catch {
179
+ return { valid: false, error: "Invalid URL format" };
180
+ }
181
+ if (!ALLOWED_MCP_URL_PROTOCOLS.has(parsed.protocol)) {
182
+ return { valid: false, error: "URL must use the http or https protocol" };
183
+ }
184
+ const hostname = parsed.hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
185
+ if (!hostname) {
186
+ return { valid: false, error: "URL host is required" };
187
+ }
188
+ if (BLOCKED_MCP_HOSTNAMES.has(hostname) || hostname === "localhost" || hostname.endsWith(".localhost")) {
189
+ return { valid: false, error: "URL host is not allowed" };
190
+ }
191
+ const ipv4 = parseIpv4Octets(hostname);
192
+ if (ipv4) {
193
+ if (isPrivateOrLocalIpv4(ipv4)) {
194
+ return { valid: false, error: "URL host resolves to a private or loopback address" };
195
+ }
196
+ } else if (hostname.includes(":") && isPrivateOrLocalIpv6(hostname)) {
197
+ return { valid: false, error: "URL host resolves to a private or loopback address" };
198
+ }
199
+ return { valid: true };
124
200
  }
125
201
  function validateMcpServerConfig(config) {
126
202
  if (!config.name?.trim()) {
@@ -133,10 +209,9 @@ function validateMcpServerConfig(config) {
133
209
  if (!config.url?.trim()) {
134
210
  return { valid: false, error: "URL is required for HTTP servers" };
135
211
  }
136
- try {
137
- new URL(config.url);
138
- } catch {
139
- return { valid: false, error: "Invalid URL format" };
212
+ const urlCheck = validateMcpServerUrl(config.url);
213
+ if (!urlCheck.valid) {
214
+ return urlCheck;
140
215
  }
141
216
  }
142
217
  if (config.type === "stdio") {
@@ -155,6 +230,7 @@ export {
155
230
  saveMcpServerConfig,
156
231
  toggleMcpServerEnabled,
157
232
  updateMcpServerConfig,
158
- validateMcpServerConfig
233
+ validateMcpServerConfig,
234
+ validateMcpServerUrl
159
235
  };
160
236
  //# sourceMappingURL=mcp-server-config.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/ai_assistant/lib/mcp-server-config.ts"],
4
- "sourcesContent": ["import type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'\n\n// Types\n\n/**\n * MCP server connection type.\n */\nexport type McpServerType = 'http' | 'stdio'\n\n/**\n * Configuration for an external MCP server.\n */\nexport interface McpServerConfig {\n /** Unique identifier */\n id: string\n /** User-defined name */\n name: string\n /** Connection type */\n type: McpServerType\n /** Server URL (for HTTP type) */\n url?: string\n /** Command to run (for stdio type) */\n command?: string\n /** Command arguments (for stdio type) */\n args?: string[]\n /** API key for authentication (stored as reference, not the actual secret) */\n apiKeyId?: string\n /** Whether the server is enabled */\n enabled: boolean\n /** When the config was created */\n createdAt: string\n /** When the config was last updated */\n updatedAt: string\n}\n\n/**\n * Input for creating a new MCP server config.\n */\nexport type McpServerConfigInput = Omit<McpServerConfig, 'id' | 'createdAt' | 'updatedAt'>\n\n/**\n * Input for updating an MCP server config.\n */\nexport type McpServerConfigUpdate = Partial<Omit<McpServerConfig, 'id' | 'createdAt' | 'updatedAt'>>\n\n// Constants\n\nexport const MCP_SERVERS_CONFIG_KEY = 'mcp_servers'\n\n// Resolver type\n\ntype Resolver = {\n resolve: <T = unknown>(name: string) => T\n}\n\n// Config functions\n\n/**\n * Get all MCP server configurations.\n */\nexport async function getMcpServerConfigs(\n resolver: Resolver\n): Promise<McpServerConfig[]> {\n let service: ModuleConfigService\n try {\n service = resolver.resolve<ModuleConfigService>('moduleConfigService')\n } catch {\n return []\n }\n\n try {\n const value = await service.getValue<McpServerConfig[]>(\n 'ai_assistant',\n MCP_SERVERS_CONFIG_KEY,\n { defaultValue: [] }\n )\n return value ?? []\n } catch {\n return []\n }\n}\n\n/**\n * Get a single MCP server configuration by ID.\n */\nexport async function getMcpServerConfig(\n resolver: Resolver,\n serverId: string\n): Promise<McpServerConfig | null> {\n const configs = await getMcpServerConfigs(resolver)\n return configs.find((c) => c.id === serverId) ?? null\n}\n\n/**\n * Get only enabled MCP server configurations.\n */\nexport async function getEnabledMcpServerConfigs(\n resolver: Resolver\n): Promise<McpServerConfig[]> {\n const configs = await getMcpServerConfigs(resolver)\n return configs.filter((c) => c.enabled)\n}\n\n/**\n * Save an MCP server configuration (create or update).\n */\nexport async function saveMcpServerConfig(\n resolver: Resolver,\n config: McpServerConfigInput & { id?: string }\n): Promise<McpServerConfig> {\n let service: ModuleConfigService\n try {\n service = resolver.resolve<ModuleConfigService>('moduleConfigService')\n } catch {\n throw new Error('Configuration service unavailable')\n }\n\n const configs = await getMcpServerConfigs(resolver)\n const now = new Date().toISOString()\n\n let updatedConfigs: McpServerConfig[]\n let savedConfig: McpServerConfig\n\n if (config.id) {\n // Update existing\n const existingIndex = configs.findIndex((c) => c.id === config.id)\n if (existingIndex === -1) {\n throw new Error(`MCP server config not found: ${config.id}`)\n }\n\n savedConfig = {\n ...configs[existingIndex],\n ...config,\n id: config.id,\n updatedAt: now,\n }\n\n updatedConfigs = [\n ...configs.slice(0, existingIndex),\n savedConfig,\n ...configs.slice(existingIndex + 1),\n ]\n } else {\n // Create new\n savedConfig = {\n ...config,\n id: generateId(),\n createdAt: now,\n updatedAt: now,\n }\n\n updatedConfigs = [...configs, savedConfig]\n }\n\n await service.setValue('ai_assistant', MCP_SERVERS_CONFIG_KEY, updatedConfigs)\n return savedConfig\n}\n\n/**\n * Update an existing MCP server configuration.\n */\nexport async function updateMcpServerConfig(\n resolver: Resolver,\n serverId: string,\n updates: McpServerConfigUpdate\n): Promise<McpServerConfig> {\n let service: ModuleConfigService\n try {\n service = resolver.resolve<ModuleConfigService>('moduleConfigService')\n } catch {\n throw new Error('Configuration service unavailable')\n }\n\n const configs = await getMcpServerConfigs(resolver)\n const existingIndex = configs.findIndex((c) => c.id === serverId)\n\n if (existingIndex === -1) {\n throw new Error(`MCP server config not found: ${serverId}`)\n }\n\n const updatedConfig: McpServerConfig = {\n ...configs[existingIndex],\n ...updates,\n id: serverId, // Ensure ID is preserved\n updatedAt: new Date().toISOString(),\n }\n\n const updatedConfigs = [\n ...configs.slice(0, existingIndex),\n updatedConfig,\n ...configs.slice(existingIndex + 1),\n ]\n\n await service.setValue('ai_assistant', MCP_SERVERS_CONFIG_KEY, updatedConfigs)\n return updatedConfig\n}\n\n/**\n * Delete an MCP server configuration.\n */\nexport async function deleteMcpServerConfig(\n resolver: Resolver,\n serverId: string\n): Promise<boolean> {\n let service: ModuleConfigService\n try {\n service = resolver.resolve<ModuleConfigService>('moduleConfigService')\n } catch {\n throw new Error('Configuration service unavailable')\n }\n\n const configs = await getMcpServerConfigs(resolver)\n const existingIndex = configs.findIndex((c) => c.id === serverId)\n\n if (existingIndex === -1) {\n return false\n }\n\n const updatedConfigs = [\n ...configs.slice(0, existingIndex),\n ...configs.slice(existingIndex + 1),\n ]\n\n await service.setValue('ai_assistant', MCP_SERVERS_CONFIG_KEY, updatedConfigs)\n return true\n}\n\n/**\n * Toggle the enabled state of an MCP server configuration.\n */\nexport async function toggleMcpServerEnabled(\n resolver: Resolver,\n serverId: string\n): Promise<McpServerConfig> {\n const config = await getMcpServerConfig(resolver, serverId)\n if (!config) {\n throw new Error(`MCP server config not found: ${serverId}`)\n }\n\n return updateMcpServerConfig(resolver, serverId, {\n enabled: !config.enabled,\n })\n}\n\n// Helpers\n\n/**\n * Generate a unique ID for a new MCP server config.\n */\nfunction generateId(): string {\n return `mcp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`\n}\n\n/**\n * Validate an MCP server configuration.\n */\nexport function validateMcpServerConfig(\n config: McpServerConfigInput\n): { valid: boolean; error?: string } {\n if (!config.name?.trim()) {\n return { valid: false, error: 'Name is required' }\n }\n\n if (!['http', 'stdio'].includes(config.type)) {\n return { valid: false, error: 'Type must be \"http\" or \"stdio\"' }\n }\n\n if (config.type === 'http') {\n if (!config.url?.trim()) {\n return { valid: false, error: 'URL is required for HTTP servers' }\n }\n\n try {\n new URL(config.url)\n } catch {\n return { valid: false, error: 'Invalid URL format' }\n }\n }\n\n if (config.type === 'stdio') {\n if (!config.command?.trim()) {\n return { valid: false, error: 'Command is required for stdio servers' }\n }\n }\n\n return { valid: true }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { randomUUID } from 'node:crypto'\nimport type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'\n\n// Types\n\n/**\n * MCP server connection type.\n */\nexport type McpServerType = 'http' | 'stdio'\n\n/**\n * Configuration for an external MCP server.\n */\nexport interface McpServerConfig {\n /** Unique identifier */\n id: string\n /** User-defined name */\n name: string\n /** Connection type */\n type: McpServerType\n /** Server URL (for HTTP type) */\n url?: string\n /** Command to run (for stdio type) */\n command?: string\n /** Command arguments (for stdio type) */\n args?: string[]\n /** API key for authentication (stored as reference, not the actual secret) */\n apiKeyId?: string\n /** Whether the server is enabled */\n enabled: boolean\n /** When the config was created */\n createdAt: string\n /** When the config was last updated */\n updatedAt: string\n}\n\n/**\n * Input for creating a new MCP server config.\n */\nexport type McpServerConfigInput = Omit<McpServerConfig, 'id' | 'createdAt' | 'updatedAt'>\n\n/**\n * Input for updating an MCP server config.\n */\nexport type McpServerConfigUpdate = Partial<Omit<McpServerConfig, 'id' | 'createdAt' | 'updatedAt'>>\n\n// Constants\n\nexport const MCP_SERVERS_CONFIG_KEY = 'mcp_servers'\n\n// Resolver type\n\ntype Resolver = {\n resolve: <T = unknown>(name: string) => T\n}\n\n// Config functions\n\n/**\n * Get all MCP server configurations.\n */\nexport async function getMcpServerConfigs(\n resolver: Resolver\n): Promise<McpServerConfig[]> {\n let service: ModuleConfigService\n try {\n service = resolver.resolve<ModuleConfigService>('moduleConfigService')\n } catch {\n return []\n }\n\n try {\n const value = await service.getValue<McpServerConfig[]>(\n 'ai_assistant',\n MCP_SERVERS_CONFIG_KEY,\n { defaultValue: [] }\n )\n return value ?? []\n } catch {\n return []\n }\n}\n\n/**\n * Get a single MCP server configuration by ID.\n */\nexport async function getMcpServerConfig(\n resolver: Resolver,\n serverId: string\n): Promise<McpServerConfig | null> {\n const configs = await getMcpServerConfigs(resolver)\n return configs.find((c) => c.id === serverId) ?? null\n}\n\n/**\n * Get only enabled MCP server configurations.\n */\nexport async function getEnabledMcpServerConfigs(\n resolver: Resolver\n): Promise<McpServerConfig[]> {\n const configs = await getMcpServerConfigs(resolver)\n return configs.filter((c) => c.enabled)\n}\n\n/**\n * Save an MCP server configuration (create or update).\n */\nexport async function saveMcpServerConfig(\n resolver: Resolver,\n config: McpServerConfigInput & { id?: string }\n): Promise<McpServerConfig> {\n let service: ModuleConfigService\n try {\n service = resolver.resolve<ModuleConfigService>('moduleConfigService')\n } catch {\n throw new Error('Configuration service unavailable')\n }\n\n const configs = await getMcpServerConfigs(resolver)\n const now = new Date().toISOString()\n\n let updatedConfigs: McpServerConfig[]\n let savedConfig: McpServerConfig\n\n if (config.id) {\n // Update existing\n const existingIndex = configs.findIndex((c) => c.id === config.id)\n if (existingIndex === -1) {\n throw new Error(`MCP server config not found: ${config.id}`)\n }\n\n savedConfig = {\n ...configs[existingIndex],\n ...config,\n id: config.id,\n updatedAt: now,\n }\n\n updatedConfigs = [\n ...configs.slice(0, existingIndex),\n savedConfig,\n ...configs.slice(existingIndex + 1),\n ]\n } else {\n // Create new\n savedConfig = {\n ...config,\n id: generateId(),\n createdAt: now,\n updatedAt: now,\n }\n\n updatedConfigs = [...configs, savedConfig]\n }\n\n const validation = validateMcpServerConfig(savedConfig)\n if (!validation.valid) {\n throw new Error(`[internal] Invalid MCP server config: ${validation.error}`)\n }\n\n await service.setValue('ai_assistant', MCP_SERVERS_CONFIG_KEY, updatedConfigs)\n return savedConfig\n}\n\n/**\n * Update an existing MCP server configuration.\n */\nexport async function updateMcpServerConfig(\n resolver: Resolver,\n serverId: string,\n updates: McpServerConfigUpdate\n): Promise<McpServerConfig> {\n let service: ModuleConfigService\n try {\n service = resolver.resolve<ModuleConfigService>('moduleConfigService')\n } catch {\n throw new Error('Configuration service unavailable')\n }\n\n const configs = await getMcpServerConfigs(resolver)\n const existingIndex = configs.findIndex((c) => c.id === serverId)\n\n if (existingIndex === -1) {\n throw new Error(`MCP server config not found: ${serverId}`)\n }\n\n const updatedConfig: McpServerConfig = {\n ...configs[existingIndex],\n ...updates,\n id: serverId, // Ensure ID is preserved\n updatedAt: new Date().toISOString(),\n }\n\n const validation = validateMcpServerConfig(updatedConfig)\n if (!validation.valid) {\n throw new Error(`[internal] Invalid MCP server config: ${validation.error}`)\n }\n\n const updatedConfigs = [\n ...configs.slice(0, existingIndex),\n updatedConfig,\n ...configs.slice(existingIndex + 1),\n ]\n\n await service.setValue('ai_assistant', MCP_SERVERS_CONFIG_KEY, updatedConfigs)\n return updatedConfig\n}\n\n/**\n * Delete an MCP server configuration.\n */\nexport async function deleteMcpServerConfig(\n resolver: Resolver,\n serverId: string\n): Promise<boolean> {\n let service: ModuleConfigService\n try {\n service = resolver.resolve<ModuleConfigService>('moduleConfigService')\n } catch {\n throw new Error('Configuration service unavailable')\n }\n\n const configs = await getMcpServerConfigs(resolver)\n const existingIndex = configs.findIndex((c) => c.id === serverId)\n\n if (existingIndex === -1) {\n return false\n }\n\n const updatedConfigs = [\n ...configs.slice(0, existingIndex),\n ...configs.slice(existingIndex + 1),\n ]\n\n await service.setValue('ai_assistant', MCP_SERVERS_CONFIG_KEY, updatedConfigs)\n return true\n}\n\n/**\n * Toggle the enabled state of an MCP server configuration.\n */\nexport async function toggleMcpServerEnabled(\n resolver: Resolver,\n serverId: string\n): Promise<McpServerConfig> {\n const config = await getMcpServerConfig(resolver, serverId)\n if (!config) {\n throw new Error(`MCP server config not found: ${serverId}`)\n }\n\n return updateMcpServerConfig(resolver, serverId, {\n enabled: !config.enabled,\n })\n}\n\n// Helpers\n\n/**\n * Generate a unique ID for a new MCP server config.\n *\n * Uses a CSPRNG (`randomUUID`) instead of `Math.random()` so the suffix is not\n * predictable if the id ever surfaces in URLs, logs, or cache keys.\n */\nfunction generateId(): string {\n return `mcp_${randomUUID()}`\n}\n\nconst ALLOWED_MCP_URL_PROTOCOLS = new Set(['http:', 'https:'])\nconst BLOCKED_MCP_HOSTNAMES = new Set(['localhost', '0.0.0.0', '::', '::1'])\n\nfunction parseIpv4Octets(hostname: string): number[] | null {\n const parts = hostname.split('.')\n if (parts.length !== 4) return null\n const octets = parts.map((part) => (/^\\d{1,3}$/.test(part) ? Number(part) : NaN))\n if (octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) {\n return null\n }\n return octets\n}\n\nfunction isPrivateOrLocalIpv4(octets: number[]): boolean {\n const [first, second] = octets\n if (first === 0) return true // \"this\" network / 0.0.0.0\n if (first === 127) return true // loopback 127.0.0.0/8\n if (first === 10) return true // RFC1918 10.0.0.0/8\n if (first === 172 && second >= 16 && second <= 31) return true // RFC1918 172.16.0.0/12\n if (first === 192 && second === 168) return true // RFC1918 192.168.0.0/16\n if (first === 169 && second === 254) return true // link-local 169.254.0.0/16\n return false\n}\n\nfunction isPrivateOrLocalIpv6(hostname: string): boolean {\n const host = hostname.toLowerCase()\n if (host === '::1' || host === '::') return true // loopback / unspecified\n if (host.startsWith('fe80')) return true // link-local fe80::/10\n if (host.startsWith('fc') || host.startsWith('fd')) return true // unique-local fc00::/7\n const ipv4MappedDotted = host.match(/^::ffff:(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3})$/)\n if (ipv4MappedDotted) {\n const octets = parseIpv4Octets(ipv4MappedDotted[1]!)\n if (octets && isPrivateOrLocalIpv4(octets)) return true\n }\n // Node normalizes ::ffff:127.0.0.1 to its hex form (::ffff:7f00:1).\n const ipv4MappedHex = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/)\n if (ipv4MappedHex) {\n const high = parseInt(ipv4MappedHex[1]!, 16)\n const low = parseInt(ipv4MappedHex[2]!, 16)\n const octets = [(high >> 8) & 0xff, high & 0xff, (low >> 8) & 0xff, low & 0xff]\n if (isPrivateOrLocalIpv4(octets)) return true\n }\n return false\n}\n\n/**\n * Validate that an external MCP server URL is safe to connect to.\n *\n * Rejects non-http(s) protocols (blocking `file:`/`gopher:`/`data:` local-file\n * disclosure) and literal loopback, link-local, and RFC1918 private hosts to\n * reduce SSRF exposure. Hostnames that are not IP literals are allowed here\n * (DNS-rebinding to a private address would still need a resolution-time guard,\n * which is out of scope for this dead-code hardening).\n */\nexport function validateMcpServerUrl(\n rawUrl: string\n): { valid: boolean; error?: string } {\n let parsed: URL\n try {\n parsed = new URL(rawUrl)\n } catch {\n return { valid: false, error: 'Invalid URL format' }\n }\n\n if (!ALLOWED_MCP_URL_PROTOCOLS.has(parsed.protocol)) {\n return { valid: false, error: 'URL must use the http or https protocol' }\n }\n\n const hostname = parsed.hostname.toLowerCase().replace(/^\\[/, '').replace(/\\]$/, '')\n if (!hostname) {\n return { valid: false, error: 'URL host is required' }\n }\n\n if (BLOCKED_MCP_HOSTNAMES.has(hostname) || hostname === 'localhost' || hostname.endsWith('.localhost')) {\n return { valid: false, error: 'URL host is not allowed' }\n }\n\n const ipv4 = parseIpv4Octets(hostname)\n if (ipv4) {\n if (isPrivateOrLocalIpv4(ipv4)) {\n return { valid: false, error: 'URL host resolves to a private or loopback address' }\n }\n } else if (hostname.includes(':') && isPrivateOrLocalIpv6(hostname)) {\n return { valid: false, error: 'URL host resolves to a private or loopback address' }\n }\n\n return { valid: true }\n}\n\n/**\n * Validate an MCP server configuration.\n */\nexport function validateMcpServerConfig(\n config: McpServerConfigInput\n): { valid: boolean; error?: string } {\n if (!config.name?.trim()) {\n return { valid: false, error: 'Name is required' }\n }\n\n if (!['http', 'stdio'].includes(config.type)) {\n return { valid: false, error: 'Type must be \"http\" or \"stdio\"' }\n }\n\n if (config.type === 'http') {\n if (!config.url?.trim()) {\n return { valid: false, error: 'URL is required for HTTP servers' }\n }\n\n const urlCheck = validateMcpServerUrl(config.url)\n if (!urlCheck.valid) {\n return urlCheck\n }\n }\n\n if (config.type === 'stdio') {\n if (!config.command?.trim()) {\n return { valid: false, error: 'Command is required for stdio servers' }\n }\n }\n\n return { valid: true }\n}\n"],
5
+ "mappings": "AAAA,SAAS,kBAAkB;AAgDpB,MAAM,yBAAyB;AAatC,eAAsB,oBACpB,UAC4B;AAC5B,MAAI;AACJ,MAAI;AACF,cAAU,SAAS,QAA6B,qBAAqB;AAAA,EACvE,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AAEA,MAAI;AACF,UAAM,QAAQ,MAAM,QAAQ;AAAA,MAC1B;AAAA,MACA;AAAA,MACA,EAAE,cAAc,CAAC,EAAE;AAAA,IACrB;AACA,WAAO,SAAS,CAAC;AAAA,EACnB,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;AAKA,eAAsB,mBACpB,UACA,UACiC;AACjC,QAAM,UAAU,MAAM,oBAAoB,QAAQ;AAClD,SAAO,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,QAAQ,KAAK;AACnD;AAKA,eAAsB,2BACpB,UAC4B;AAC5B,QAAM,UAAU,MAAM,oBAAoB,QAAQ;AAClD,SAAO,QAAQ,OAAO,CAAC,MAAM,EAAE,OAAO;AACxC;AAKA,eAAsB,oBACpB,UACA,QAC0B;AAC1B,MAAI;AACJ,MAAI;AACF,cAAU,SAAS,QAA6B,qBAAqB;AAAA,EACvE,QAAQ;AACN,UAAM,IAAI,MAAM,mCAAmC;AAAA,EACrD;AAEA,QAAM,UAAU,MAAM,oBAAoB,QAAQ;AAClD,QAAM,OAAM,oBAAI,KAAK,GAAE,YAAY;AAEnC,MAAI;AACJ,MAAI;AAEJ,MAAI,OAAO,IAAI;AAEb,UAAM,gBAAgB,QAAQ,UAAU,CAAC,MAAM,EAAE,OAAO,OAAO,EAAE;AACjE,QAAI,kBAAkB,IAAI;AACxB,YAAM,IAAI,MAAM,gCAAgC,OAAO,EAAE,EAAE;AAAA,IAC7D;AAEA,kBAAc;AAAA,MACZ,GAAG,QAAQ,aAAa;AAAA,MACxB,GAAG;AAAA,MACH,IAAI,OAAO;AAAA,MACX,WAAW;AAAA,IACb;AAEA,qBAAiB;AAAA,MACf,GAAG,QAAQ,MAAM,GAAG,aAAa;AAAA,MACjC;AAAA,MACA,GAAG,QAAQ,MAAM,gBAAgB,CAAC;AAAA,IACpC;AAAA,EACF,OAAO;AAEL,kBAAc;AAAA,MACZ,GAAG;AAAA,MACH,IAAI,WAAW;AAAA,MACf,WAAW;AAAA,MACX,WAAW;AAAA,IACb;AAEA,qBAAiB,CAAC,GAAG,SAAS,WAAW;AAAA,EAC3C;AAEA,QAAM,aAAa,wBAAwB,WAAW;AACtD,MAAI,CAAC,WAAW,OAAO;AACrB,UAAM,IAAI,MAAM,yCAAyC,WAAW,KAAK,EAAE;AAAA,EAC7E;AAEA,QAAM,QAAQ,SAAS,gBAAgB,wBAAwB,cAAc;AAC7E,SAAO;AACT;AAKA,eAAsB,sBACpB,UACA,UACA,SAC0B;AAC1B,MAAI;AACJ,MAAI;AACF,cAAU,SAAS,QAA6B,qBAAqB;AAAA,EACvE,QAAQ;AACN,UAAM,IAAI,MAAM,mCAAmC;AAAA,EACrD;AAEA,QAAM,UAAU,MAAM,oBAAoB,QAAQ;AAClD,QAAM,gBAAgB,QAAQ,UAAU,CAAC,MAAM,EAAE,OAAO,QAAQ;AAEhE,MAAI,kBAAkB,IAAI;AACxB,UAAM,IAAI,MAAM,gCAAgC,QAAQ,EAAE;AAAA,EAC5D;AAEA,QAAM,gBAAiC;AAAA,IACrC,GAAG,QAAQ,aAAa;AAAA,IACxB,GAAG;AAAA,IACH,IAAI;AAAA;AAAA,IACJ,YAAW,oBAAI,KAAK,GAAE,YAAY;AAAA,EACpC;AAEA,QAAM,aAAa,wBAAwB,aAAa;AACxD,MAAI,CAAC,WAAW,OAAO;AACrB,UAAM,IAAI,MAAM,yCAAyC,WAAW,KAAK,EAAE;AAAA,EAC7E;AAEA,QAAM,iBAAiB;AAAA,IACrB,GAAG,QAAQ,MAAM,GAAG,aAAa;AAAA,IACjC;AAAA,IACA,GAAG,QAAQ,MAAM,gBAAgB,CAAC;AAAA,EACpC;AAEA,QAAM,QAAQ,SAAS,gBAAgB,wBAAwB,cAAc;AAC7E,SAAO;AACT;AAKA,eAAsB,sBACpB,UACA,UACkB;AAClB,MAAI;AACJ,MAAI;AACF,cAAU,SAAS,QAA6B,qBAAqB;AAAA,EACvE,QAAQ;AACN,UAAM,IAAI,MAAM,mCAAmC;AAAA,EACrD;AAEA,QAAM,UAAU,MAAM,oBAAoB,QAAQ;AAClD,QAAM,gBAAgB,QAAQ,UAAU,CAAC,MAAM,EAAE,OAAO,QAAQ;AAEhE,MAAI,kBAAkB,IAAI;AACxB,WAAO;AAAA,EACT;AAEA,QAAM,iBAAiB;AAAA,IACrB,GAAG,QAAQ,MAAM,GAAG,aAAa;AAAA,IACjC,GAAG,QAAQ,MAAM,gBAAgB,CAAC;AAAA,EACpC;AAEA,QAAM,QAAQ,SAAS,gBAAgB,wBAAwB,cAAc;AAC7E,SAAO;AACT;AAKA,eAAsB,uBACpB,UACA,UAC0B;AAC1B,QAAM,SAAS,MAAM,mBAAmB,UAAU,QAAQ;AAC1D,MAAI,CAAC,QAAQ;AACX,UAAM,IAAI,MAAM,gCAAgC,QAAQ,EAAE;AAAA,EAC5D;AAEA,SAAO,sBAAsB,UAAU,UAAU;AAAA,IAC/C,SAAS,CAAC,OAAO;AAAA,EACnB,CAAC;AACH;AAUA,SAAS,aAAqB;AAC5B,SAAO,OAAO,WAAW,CAAC;AAC5B;AAEA,MAAM,4BAA4B,oBAAI,IAAI,CAAC,SAAS,QAAQ,CAAC;AAC7D,MAAM,wBAAwB,oBAAI,IAAI,CAAC,aAAa,WAAW,MAAM,KAAK,CAAC;AAE3E,SAAS,gBAAgB,UAAmC;AAC1D,QAAM,QAAQ,SAAS,MAAM,GAAG;AAChC,MAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,QAAM,SAAS,MAAM,IAAI,CAAC,SAAU,YAAY,KAAK,IAAI,IAAI,OAAO,IAAI,IAAI,GAAI;AAChF,MAAI,OAAO,KAAK,CAAC,UAAU,CAAC,OAAO,UAAU,KAAK,KAAK,QAAQ,KAAK,QAAQ,GAAG,GAAG;AAChF,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAEA,SAAS,qBAAqB,QAA2B;AACvD,QAAM,CAAC,OAAO,MAAM,IAAI;AACxB,MAAI,UAAU,EAAG,QAAO;AACxB,MAAI,UAAU,IAAK,QAAO;AAC1B,MAAI,UAAU,GAAI,QAAO;AACzB,MAAI,UAAU,OAAO,UAAU,MAAM,UAAU,GAAI,QAAO;AAC1D,MAAI,UAAU,OAAO,WAAW,IAAK,QAAO;AAC5C,MAAI,UAAU,OAAO,WAAW,IAAK,QAAO;AAC5C,SAAO;AACT;AAEA,SAAS,qBAAqB,UAA2B;AACvD,QAAM,OAAO,SAAS,YAAY;AAClC,MAAI,SAAS,SAAS,SAAS,KAAM,QAAO;AAC5C,MAAI,KAAK,WAAW,MAAM,EAAG,QAAO;AACpC,MAAI,KAAK,WAAW,IAAI,KAAK,KAAK,WAAW,IAAI,EAAG,QAAO;AAC3D,QAAM,mBAAmB,KAAK,MAAM,+CAA+C;AACnF,MAAI,kBAAkB;AACpB,UAAM,SAAS,gBAAgB,iBAAiB,CAAC,CAAE;AACnD,QAAI,UAAU,qBAAqB,MAAM,EAAG,QAAO;AAAA,EACrD;AAEA,QAAM,gBAAgB,KAAK,MAAM,0CAA0C;AAC3E,MAAI,eAAe;AACjB,UAAM,OAAO,SAAS,cAAc,CAAC,GAAI,EAAE;AAC3C,UAAM,MAAM,SAAS,cAAc,CAAC,GAAI,EAAE;AAC1C,UAAM,SAAS,CAAE,QAAQ,IAAK,KAAM,OAAO,KAAO,OAAO,IAAK,KAAM,MAAM,GAAI;AAC9E,QAAI,qBAAqB,MAAM,EAAG,QAAO;AAAA,EAC3C;AACA,SAAO;AACT;AAWO,SAAS,qBACd,QACoC;AACpC,MAAI;AACJ,MAAI;AACF,aAAS,IAAI,IAAI,MAAM;AAAA,EACzB,QAAQ;AACN,WAAO,EAAE,OAAO,OAAO,OAAO,qBAAqB;AAAA,EACrD;AAEA,MAAI,CAAC,0BAA0B,IAAI,OAAO,QAAQ,GAAG;AACnD,WAAO,EAAE,OAAO,OAAO,OAAO,0CAA0C;AAAA,EAC1E;AAEA,QAAM,WAAW,OAAO,SAAS,YAAY,EAAE,QAAQ,OAAO,EAAE,EAAE,QAAQ,OAAO,EAAE;AACnF,MAAI,CAAC,UAAU;AACb,WAAO,EAAE,OAAO,OAAO,OAAO,uBAAuB;AAAA,EACvD;AAEA,MAAI,sBAAsB,IAAI,QAAQ,KAAK,aAAa,eAAe,SAAS,SAAS,YAAY,GAAG;AACtG,WAAO,EAAE,OAAO,OAAO,OAAO,0BAA0B;AAAA,EAC1D;AAEA,QAAM,OAAO,gBAAgB,QAAQ;AACrC,MAAI,MAAM;AACR,QAAI,qBAAqB,IAAI,GAAG;AAC9B,aAAO,EAAE,OAAO,OAAO,OAAO,qDAAqD;AAAA,IACrF;AAAA,EACF,WAAW,SAAS,SAAS,GAAG,KAAK,qBAAqB,QAAQ,GAAG;AACnE,WAAO,EAAE,OAAO,OAAO,OAAO,qDAAqD;AAAA,EACrF;AAEA,SAAO,EAAE,OAAO,KAAK;AACvB;AAKO,SAAS,wBACd,QACoC;AACpC,MAAI,CAAC,OAAO,MAAM,KAAK,GAAG;AACxB,WAAO,EAAE,OAAO,OAAO,OAAO,mBAAmB;AAAA,EACnD;AAEA,MAAI,CAAC,CAAC,QAAQ,OAAO,EAAE,SAAS,OAAO,IAAI,GAAG;AAC5C,WAAO,EAAE,OAAO,OAAO,OAAO,iCAAiC;AAAA,EACjE;AAEA,MAAI,OAAO,SAAS,QAAQ;AAC1B,QAAI,CAAC,OAAO,KAAK,KAAK,GAAG;AACvB,aAAO,EAAE,OAAO,OAAO,OAAO,mCAAmC;AAAA,IACnE;AAEA,UAAM,WAAW,qBAAqB,OAAO,GAAG;AAChD,QAAI,CAAC,SAAS,OAAO;AACnB,aAAO;AAAA,IACT;AAAA,EACF;AAEA,MAAI,OAAO,SAAS,SAAS;AAC3B,QAAI,CAAC,OAAO,SAAS,KAAK,GAAG;AAC3B,aAAO,EAAE,OAAO,OAAO,OAAO,wCAAwC;AAAA,IACxE;AAAA,EACF;AAEA,SAAO,EAAE,OAAO,KAAK;AACvB;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ai-assistant",
3
- "version": "0.6.6-develop.5675.1.d585628349",
3
+ "version": "0.6.6-develop.5706.1.dbef29f570",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22.0.0"
@@ -98,16 +98,16 @@
98
98
  "zod-to-json-schema": "^3.25.2"
99
99
  },
100
100
  "peerDependencies": {
101
- "@open-mercato/shared": "0.6.6-develop.5675.1.d585628349",
102
- "@open-mercato/ui": "0.6.6-develop.5675.1.d585628349",
101
+ "@open-mercato/shared": "0.6.6-develop.5706.1.dbef29f570",
102
+ "@open-mercato/ui": "0.6.6-develop.5706.1.dbef29f570",
103
103
  "react": "^19.0.0",
104
104
  "react-dom": "^19.0.0",
105
105
  "zod": ">=3.23.0"
106
106
  },
107
107
  "devDependencies": {
108
- "@open-mercato/cli": "0.6.6-develop.5675.1.d585628349",
109
- "@open-mercato/shared": "0.6.6-develop.5675.1.d585628349",
110
- "@open-mercato/ui": "0.6.6-develop.5675.1.d585628349",
108
+ "@open-mercato/cli": "0.6.6-develop.5706.1.dbef29f570",
109
+ "@open-mercato/shared": "0.6.6-develop.5706.1.dbef29f570",
110
+ "@open-mercato/ui": "0.6.6-develop.5706.1.dbef29f570",
111
111
  "@types/react": "^19.2.17",
112
112
  "@types/react-dom": "^19.2.3",
113
113
  "react": "19.2.7",
@@ -0,0 +1,148 @@
1
+ import {
2
+ validateMcpServerUrl,
3
+ validateMcpServerConfig,
4
+ saveMcpServerConfig,
5
+ updateMcpServerConfig,
6
+ type McpServerConfig,
7
+ } from '../mcp-server-config'
8
+
9
+ function makeResolver(initial: McpServerConfig[] = []) {
10
+ const store: { value: McpServerConfig[] } = { value: initial }
11
+ const service = {
12
+ async getValue<T>(_module: string, _key: string, opts?: { defaultValue?: T }) {
13
+ return (store.value as unknown as T) ?? (opts?.defaultValue as T)
14
+ },
15
+ async setValue(_module: string, _key: string, value: McpServerConfig[]) {
16
+ store.value = value
17
+ },
18
+ }
19
+ const resolver = {
20
+ resolve: <T = unknown>(name: string): T => {
21
+ if (name === 'moduleConfigService') return service as unknown as T
22
+ throw new Error(`unexpected resolve: ${name}`)
23
+ },
24
+ }
25
+ return { resolver, store }
26
+ }
27
+
28
+ describe('issue #2672 — MCP server-config hardening', () => {
29
+ describe('validateMcpServerUrl — protocol allowlist', () => {
30
+ it.each(['file:///etc/passwd', 'gopher://example.com', 'data:text/plain,hi', 'ftp://example.com'])(
31
+ 'rejects non-http(s) protocol: %s',
32
+ (url) => {
33
+ expect(validateMcpServerUrl(url).valid).toBe(false)
34
+ },
35
+ )
36
+
37
+ it('accepts a public https URL', () => {
38
+ expect(validateMcpServerUrl('https://mcp.example.com/sse')).toEqual({ valid: true })
39
+ })
40
+
41
+ it('accepts a public http URL', () => {
42
+ expect(validateMcpServerUrl('http://203.0.113.5:3001/mcp')).toEqual({ valid: true })
43
+ })
44
+
45
+ it('rejects a malformed URL', () => {
46
+ expect(validateMcpServerUrl('not a url').valid).toBe(false)
47
+ })
48
+ })
49
+
50
+ describe('validateMcpServerUrl — private / loopback / link-local hosts', () => {
51
+ it.each([
52
+ 'http://localhost:3001/mcp',
53
+ 'http://sub.localhost/mcp',
54
+ 'http://127.0.0.1/mcp',
55
+ 'http://127.5.6.7/mcp',
56
+ 'http://0.0.0.0/mcp',
57
+ 'http://10.0.0.5/mcp',
58
+ 'http://172.16.0.9/mcp',
59
+ 'http://172.31.255.1/mcp',
60
+ 'http://192.168.1.1/mcp',
61
+ 'http://169.254.169.254/latest/meta-data',
62
+ 'http://[::1]/mcp',
63
+ 'http://[fe80::1]/mcp',
64
+ 'http://[fc00::1]/mcp',
65
+ 'http://[::ffff:127.0.0.1]/mcp',
66
+ ])('rejects blocked host: %s', (url) => {
67
+ expect(validateMcpServerUrl(url).valid).toBe(false)
68
+ })
69
+
70
+ it('does not block a public host in the 172.x range outside RFC1918', () => {
71
+ expect(validateMcpServerUrl('http://172.32.0.1/mcp')).toEqual({ valid: true })
72
+ })
73
+ })
74
+
75
+ describe('validateMcpServerConfig', () => {
76
+ it('rejects an http config whose URL is a loopback address', () => {
77
+ const result = validateMcpServerConfig({
78
+ name: 'evil',
79
+ type: 'http',
80
+ url: 'http://127.0.0.1/mcp',
81
+ enabled: true,
82
+ })
83
+ expect(result.valid).toBe(false)
84
+ })
85
+
86
+ it('accepts an http config with a public URL', () => {
87
+ const result = validateMcpServerConfig({
88
+ name: 'good',
89
+ type: 'http',
90
+ url: 'https://mcp.example.com/sse',
91
+ enabled: true,
92
+ })
93
+ expect(result).toEqual({ valid: true })
94
+ })
95
+
96
+ it('requires a command for stdio configs', () => {
97
+ expect(validateMcpServerConfig({ name: 'x', type: 'stdio', enabled: true }).valid).toBe(false)
98
+ })
99
+ })
100
+
101
+ describe('saveMcpServerConfig — fail-closed + CSPRNG id', () => {
102
+ it('generates a UUID-based id and persists a valid config', async () => {
103
+ const { resolver, store } = makeResolver()
104
+ const saved = await saveMcpServerConfig(resolver, {
105
+ name: 'good',
106
+ type: 'http',
107
+ url: 'https://mcp.example.com/sse',
108
+ enabled: true,
109
+ })
110
+ expect(saved.id).toMatch(
111
+ /^mcp_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
112
+ )
113
+ expect(store.value).toHaveLength(1)
114
+ })
115
+
116
+ it('refuses to persist a config with an SSRF-prone URL', async () => {
117
+ const { resolver, store } = makeResolver()
118
+ await expect(
119
+ saveMcpServerConfig(resolver, {
120
+ name: 'evil',
121
+ type: 'http',
122
+ url: 'http://169.254.169.254/latest/meta-data',
123
+ enabled: true,
124
+ }),
125
+ ).rejects.toThrow(/Invalid MCP server config/i)
126
+ expect(store.value).toHaveLength(0)
127
+ })
128
+ })
129
+
130
+ describe('updateMcpServerConfig — fail-closed', () => {
131
+ it('refuses an update that introduces a blocked URL', async () => {
132
+ const existing: McpServerConfig = {
133
+ id: 'mcp_existing',
134
+ name: 'good',
135
+ type: 'http',
136
+ url: 'https://mcp.example.com/sse',
137
+ enabled: true,
138
+ createdAt: '2026-01-01T00:00:00.000Z',
139
+ updatedAt: '2026-01-01T00:00:00.000Z',
140
+ }
141
+ const { resolver, store } = makeResolver([existing])
142
+ await expect(
143
+ updateMcpServerConfig(resolver, 'mcp_existing', { url: 'file:///etc/passwd' }),
144
+ ).rejects.toThrow(/Invalid MCP server config/i)
145
+ expect(store.value[0]!.url).toBe('https://mcp.example.com/sse')
146
+ })
147
+ })
148
+ })
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto'
1
2
  import type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
2
3
 
3
4
  // Types
@@ -152,6 +153,11 @@ export async function saveMcpServerConfig(
152
153
  updatedConfigs = [...configs, savedConfig]
153
154
  }
154
155
 
156
+ const validation = validateMcpServerConfig(savedConfig)
157
+ if (!validation.valid) {
158
+ throw new Error(`[internal] Invalid MCP server config: ${validation.error}`)
159
+ }
160
+
155
161
  await service.setValue('ai_assistant', MCP_SERVERS_CONFIG_KEY, updatedConfigs)
156
162
  return savedConfig
157
163
  }
@@ -185,6 +191,11 @@ export async function updateMcpServerConfig(
185
191
  updatedAt: new Date().toISOString(),
186
192
  }
187
193
 
194
+ const validation = validateMcpServerConfig(updatedConfig)
195
+ if (!validation.valid) {
196
+ throw new Error(`[internal] Invalid MCP server config: ${validation.error}`)
197
+ }
198
+
188
199
  const updatedConfigs = [
189
200
  ...configs.slice(0, existingIndex),
190
201
  updatedConfig,
@@ -246,9 +257,101 @@ export async function toggleMcpServerEnabled(
246
257
 
247
258
  /**
248
259
  * Generate a unique ID for a new MCP server config.
260
+ *
261
+ * Uses a CSPRNG (`randomUUID`) instead of `Math.random()` so the suffix is not
262
+ * predictable if the id ever surfaces in URLs, logs, or cache keys.
249
263
  */
250
264
  function generateId(): string {
251
- return `mcp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`
265
+ return `mcp_${randomUUID()}`
266
+ }
267
+
268
+ const ALLOWED_MCP_URL_PROTOCOLS = new Set(['http:', 'https:'])
269
+ const BLOCKED_MCP_HOSTNAMES = new Set(['localhost', '0.0.0.0', '::', '::1'])
270
+
271
+ function parseIpv4Octets(hostname: string): number[] | null {
272
+ const parts = hostname.split('.')
273
+ if (parts.length !== 4) return null
274
+ const octets = parts.map((part) => (/^\d{1,3}$/.test(part) ? Number(part) : NaN))
275
+ if (octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) {
276
+ return null
277
+ }
278
+ return octets
279
+ }
280
+
281
+ function isPrivateOrLocalIpv4(octets: number[]): boolean {
282
+ const [first, second] = octets
283
+ if (first === 0) return true // "this" network / 0.0.0.0
284
+ if (first === 127) return true // loopback 127.0.0.0/8
285
+ if (first === 10) return true // RFC1918 10.0.0.0/8
286
+ if (first === 172 && second >= 16 && second <= 31) return true // RFC1918 172.16.0.0/12
287
+ if (first === 192 && second === 168) return true // RFC1918 192.168.0.0/16
288
+ if (first === 169 && second === 254) return true // link-local 169.254.0.0/16
289
+ return false
290
+ }
291
+
292
+ function isPrivateOrLocalIpv6(hostname: string): boolean {
293
+ const host = hostname.toLowerCase()
294
+ if (host === '::1' || host === '::') return true // loopback / unspecified
295
+ if (host.startsWith('fe80')) return true // link-local fe80::/10
296
+ if (host.startsWith('fc') || host.startsWith('fd')) return true // unique-local fc00::/7
297
+ const ipv4MappedDotted = host.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/)
298
+ if (ipv4MappedDotted) {
299
+ const octets = parseIpv4Octets(ipv4MappedDotted[1]!)
300
+ if (octets && isPrivateOrLocalIpv4(octets)) return true
301
+ }
302
+ // Node normalizes ::ffff:127.0.0.1 to its hex form (::ffff:7f00:1).
303
+ const ipv4MappedHex = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/)
304
+ if (ipv4MappedHex) {
305
+ const high = parseInt(ipv4MappedHex[1]!, 16)
306
+ const low = parseInt(ipv4MappedHex[2]!, 16)
307
+ const octets = [(high >> 8) & 0xff, high & 0xff, (low >> 8) & 0xff, low & 0xff]
308
+ if (isPrivateOrLocalIpv4(octets)) return true
309
+ }
310
+ return false
311
+ }
312
+
313
+ /**
314
+ * Validate that an external MCP server URL is safe to connect to.
315
+ *
316
+ * Rejects non-http(s) protocols (blocking `file:`/`gopher:`/`data:` local-file
317
+ * disclosure) and literal loopback, link-local, and RFC1918 private hosts to
318
+ * reduce SSRF exposure. Hostnames that are not IP literals are allowed here
319
+ * (DNS-rebinding to a private address would still need a resolution-time guard,
320
+ * which is out of scope for this dead-code hardening).
321
+ */
322
+ export function validateMcpServerUrl(
323
+ rawUrl: string
324
+ ): { valid: boolean; error?: string } {
325
+ let parsed: URL
326
+ try {
327
+ parsed = new URL(rawUrl)
328
+ } catch {
329
+ return { valid: false, error: 'Invalid URL format' }
330
+ }
331
+
332
+ if (!ALLOWED_MCP_URL_PROTOCOLS.has(parsed.protocol)) {
333
+ return { valid: false, error: 'URL must use the http or https protocol' }
334
+ }
335
+
336
+ const hostname = parsed.hostname.toLowerCase().replace(/^\[/, '').replace(/\]$/, '')
337
+ if (!hostname) {
338
+ return { valid: false, error: 'URL host is required' }
339
+ }
340
+
341
+ if (BLOCKED_MCP_HOSTNAMES.has(hostname) || hostname === 'localhost' || hostname.endsWith('.localhost')) {
342
+ return { valid: false, error: 'URL host is not allowed' }
343
+ }
344
+
345
+ const ipv4 = parseIpv4Octets(hostname)
346
+ if (ipv4) {
347
+ if (isPrivateOrLocalIpv4(ipv4)) {
348
+ return { valid: false, error: 'URL host resolves to a private or loopback address' }
349
+ }
350
+ } else if (hostname.includes(':') && isPrivateOrLocalIpv6(hostname)) {
351
+ return { valid: false, error: 'URL host resolves to a private or loopback address' }
352
+ }
353
+
354
+ return { valid: true }
252
355
  }
253
356
 
254
357
  /**
@@ -270,10 +373,9 @@ export function validateMcpServerConfig(
270
373
  return { valid: false, error: 'URL is required for HTTP servers' }
271
374
  }
272
375
 
273
- try {
274
- new URL(config.url)
275
- } catch {
276
- return { valid: false, error: 'Invalid URL format' }
376
+ const urlCheck = validateMcpServerUrl(config.url)
377
+ if (!urlCheck.valid) {
378
+ return urlCheck
277
379
  }
278
380
  }
279
381