Root cause
SSRF & network boundaries introduced
feat: Add LLM config store and update LLM handling
AI-assisted change: Claude
How AI contributed: Direct introductionPublished Apr 27, 2026 in
joecastrom/mcp-chat-studio
SSRF (CWE-918): GET /api/llm/models accepts attacker-controlled req.query.base_url (Ollama branch) and builds targetUrl = ${baseUrl}/api/tags, then server-side fetch(targetUrl) with no validation of the host, allowing requests to internal/private/cloud-metadata addresses. Reachable remotely, no auth required; affects all versions up to 1.5.0 (package.json version 1.5.0 before and after fix; advisory published 2026-04-27, exploit public).
Root cause
feat: Add LLM config store and update LLM handling
AI-assisted change: Claude
Fix
fix: Add SSRF protection to prevent requests to internal networks
Fix by Ghazi · no AI marker found
Case facts
@@ -8,11 +8,11 @@ import axios from 'axios'; export class LLMClient { constructor(config) { const normalized = (config && config.llm) ? config.llm : (config || {});- this.config = normalized;- this.provider = this.config.provider || 'ollama'; // Default to Ollama+ this.applyConfig(normalized); // Generic API keys (provider-specific take priority) this.apiKey =+ this.config.api_key || process.env.LLM_API_KEY || process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY ||@@ -27,9 +27,27 @@ export class LLMClient { this.azureApiVersion = process.env.AZURE_OPENAI_API_VERSION || '2024-02-15-preview'; this.azureDeployment = process.env.AZURE_OPENAI_DEPLOYMENT; + this.authToken = null;+ this.authTokenExpiresAt = 0;+ console.log(`[LLMClient] Initialized with provider: ${this.provider}`); } + applyConfig(config) {+ this.config = config || {};+ this.provider = this.config.provider || 'ollama';+ }++ updateConfig(config) {+ const normalized = (config && config.llm) ? config.llm : (config || {});+ this.applyConfig(normalized);+ if (this.config.api_key) {+ this.apiKey = this.config.api_key;+ }+ this.authToken = null;+ this.authTokenExpiresAt = 0;+ }+ /** * Validate required environment variables based on provider */@@ -39,36 +57,49 @@ export class LLMClient { switch (this.provider) { case 'openai':- if (!process.env.OPENAI_API_KEY && !process.env.LLM_API_KEY)+ if (!this.config.api_key && !process.env.OPENAI_API_KEY && !process.env.LLM_API_KEY) errors.push('OPENAI_API_KEY is missing'); break; case 'anthropic':- if (!process.env.ANTHROPIC_API_KEY && !process.env.LLM_API_KEY)+ if (!this.config.api_key && !process.env.ANTHROPIC_API_KEY && !process.env.LLM_API_KEY) errors.push('ANTHROPIC_API_KEY is missing'); break; case 'gemini':- if (!process.env.GOOGLE_API_KEY && !process.env.LLM_API_KEY)+ if (!this.config.api_key && !process.env.GOOGLE_API_KEY && !process.env.LLM_API_KEY) errors.push('GOOGLE_API_KEY is missing'); break; case 'azure':- if (!process.env.AZURE_OPENAI_API_KEY && !process.env.LLM_API_KEY)+ if (!this.config.api_key && !process.env.AZURE_OPENAI_API_KEY && !process.env.LLM_API_KEY) errors.push('AZURE_OPENAI_API_KEY is missing'); if (!this.azureEndpoint) errors.push('AZURE_OPENAI_ENDPOINT is missing'); if (!this.azureDeployment) warnings.push('AZURE_OPENAI_DEPLOYMENT not set, using model name'); break; case 'groq':- if (!process.env.GROQ_API_KEY && !process.env.LLM_API_KEY)+ if (!this.config.api_key && !process.env.GROQ_API_KEY && !process.env.LLM_API_KEY) errors.push('GROQ_API_KEY is missing'); break; case 'together':- if (!process.env.TOGETHER_API_KEY && !process.env.LLM_API_KEY)+ if (!this.config.api_key && !process.env.TOGETHER_API_KEY && !process.env.LLM_API_KEY) errors.push('TOGETHER_API_KEY is missing'); break; case 'openrouter':- if (!process.env.OPENROUTER_API_KEY && !process.env.LLM_API_KEY)+ if (!this.config.api_key && !process.env.OPENROUTER_API_KEY && !process.env.LLM_API_KEY) errors.push('OPENROUTER_API_KEY is missing'); break;+ case 'custom': {+ const auth = this.config.auth || {};+ if (auth.type === 'client_credentials') {+ if (!auth.auth_url) errors.push('LLM auth_url is missing');+ if (!auth.client_id) errors.push('LLM auth client_id is missing');+ if (!auth.client_secret) errors.push('LLM auth client_secret is missing');+ } else if (auth.type === 'bearer') {@@ -5,8 +5,35 @@ import express from 'express'; import { getLLMClient } from '../services/LLMClient.js';+import { loadPersistedLLMConfig, savePersistedLLMConfig } from '../services/LLMConfigStore.js'; const router = express.Router();+const VALID_PROVIDERS = [+ 'ollama',+ 'openai',+ 'anthropic',+ 'gemini',+ 'azure',+ 'groq',+ 'together',+ 'openrouter',+ 'custom'+];++function getAllowedProviders() {+ const raw = process.env.LLM_ALLOWED_PROVIDERS || process.env.LLM_PROVIDER_ALLOWLIST;+ if (!raw) return null;+ const normalized = String(raw).trim().toLowerCase();+ if (!normalized || normalized === '*' || normalized === 'all') {+ return null;+ }+ const list = normalized+ .split(/[\s,]+/)+ .map(item => item.trim())+ .filter(Boolean)+ .filter(item => VALID_PROVIDERS.includes(item));+ return list.length > 0 ? list : null;+} /** * GET /api/llm/config@@ -23,7 +50,14 @@ router.get('/config', (req, res) => { // Only return explicit base_url if set, not the computed default base_url: llmClient.config.base_url || '', // Don't expose API keys- hasApiKey: !!llmClient.apiKey,+ hasApiKey: !!(llmClient.config.api_key || llmClient.apiKey),+ auth_type: llmClient.config.auth?.type || 'none',+ auth_url: llmClient.config.auth?.auth_url || '',+ auth_client_id: llmClient.config.auth?.client_id || '',+ auth_scope: llmClient.config.auth?.scope || '',+ auth_audience: llmClient.config.auth?.audience || '',+ hasAuthSecret: !!llmClient.config.auth?.client_secret,+ allowedProviders: getAllowedProviders(), }); } catch (error) { console.error('[LLM/Config] Error:', error.message);@@ -37,11 +71,31 @@ router.get('/config', (req, res) => { */ router.post('/config', async (req, res) => { try {- const { provider, model, temperature, base_url, api_key } = req.body;+ const {+ provider,+ model,+ temperature,+ base_url,+ api_key,+ clear_api_key,+ auth_type,+ auth_url,+ auth_client_id,+ auth_client_secret,+ auth_scope,+ auth_audience,+ clear_auth_secret+ } = req.body; const llmClient = getLLMClient();+ const persisted = await loadPersistedLLMConfig();+ const existing = persisted || llmClient.config || {}; // Supported providers- const validProviders = ['ollama', 'openai', 'anthropic', 'gemini', 'azure', 'groq', 'together', 'openrouter'];+ const validProviders = VALID_PROVIDERS;+ const allowedProviders = getAllowedProviders();+ if (provider && allowedProviders && !allowedProviders.includes(provider)) {+ return res.status(403).json({ error: `Provider "${provider}" is disabled by server policy` });+ } // Update provider if (provider && validProviders.includes(provider)) {@@ -76,9 +130,40 @@ router.post('/config', async (req, res) => { // Update API key if (api_key) { llmClient.apiKey = api_key;@@ -790,6 +790,129 @@ async function saveWorkflow() { } } +async function ensureToolSchemaCache() {+ try {+ const res = await fetch('/api/mcp/tools');+ const data = await res.json();+ const tools = Array.isArray(data.tools) ? data.tools : [];+ const grouped = {};+ tools.forEach(tool => {+ if (!grouped[tool.serverName]) grouped[tool.serverName] = [];+ grouped[tool.serverName].push(tool);+ });+ Object.entries(grouped).forEach(([server, list]) => {+ toolSchemaCache[server] = list;+ });+ } catch (error) {+ console.warn('Failed to load tool schemas for validation:', error.message);+ }+}++async function getToolDef(serverName, toolName) {+ if (!serverName || !toolName) return null;+ if (!toolSchemaCache[serverName]) {+ await ensureToolSchemaCache();+ }+ const tools = toolSchemaCache[serverName] || [];+ return tools.find(tool => tool.name === toolName) || null;+}++function parseToolArgs(rawArgs) {+ if (rawArgs === undefined || rawArgs === null) {+ return { value: {}, error: null };+ }+ if (typeof rawArgs === 'object') {+ if (Array.isArray(rawArgs)) {+ return { value: null, error: 'Args must be a JSON object (not an array).' };+ }+ return { value: rawArgs, error: null };+ }+ const text = String(rawArgs).trim();+ if (!text) {+ return { value: {}, error: null };+ }+ try {+ const parsed = JSON.parse(text);+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {+ return { value: null, error: 'Args must be a JSON object.' };+ }+ return { value: parsed, error: null };+ } catch (error) {+ return { value: null, error: `Args JSON is invalid: ${error.message}` };+ }+}++async function validateWorkflowBeforeRun() {+ const issues = [];+ const incomingMap = {};+ workflowState.edges.forEach(edge => {+ if (!incomingMap[edge.to]) incomingMap[edge.to] = [];+ incomingMap[edge.to].push(edge.from);+ });++ await ensureToolSchemaCache();++ for (const node of workflowState.nodes) {+ if (node.type === 'tool') {+ const server = node.data?.server;+ const tool = node.data?.tool;+ if (!server) {+ issues.push({ nodeId: node.id, message: 'Select a server for this tool node.' });+ continue;+ }+ if (!tool) {+ issues.push({ nodeId: node.id, message: 'Select a tool for this tool node.' });+ continue;+ }++ const parsed = parseToolArgs(node.data?.args);+ if (parsed.error) {+ issues.push({ nodeId: node.id, message: parsed.error });+ continue;+ }++ const toolDef = await getToolDef(server, tool);+ if (!toolDef?.inputSchema) {+ continue;+ }+ const properties = toolDef.inputSchema.properties || {};@@ -0,0 +1,120 @@+/**+ * URL Validator — SSRF Protection+ * Blocks requests to internal/private network destinations.+ */++import { URL } from 'url';+import { isIP } from 'net';++// Private/reserved IPv4 ranges+const PRIVATE_IPV4_RANGES = [+ { start: '10.0.0.0', end: '10.255.255.255' }, // RFC1918+ { start: '172.16.0.0', end: '172.31.255.255' }, // RFC1918+ { start: '192.168.0.0', end: '192.168.255.255' }, // RFC1918+ { start: '127.0.0.0', end: '127.255.255.255' }, // Loopback+ { start: '169.254.0.0', end: '169.254.255.255' }, // Link-local+ { start: '0.0.0.0', end: '0.255.255.255' }, // Current network+ { start: '100.64.0.0', end: '100.127.255.255' }, // Shared address space (CGN)+ { start: '192.0.0.0', end: '192.0.0.255' }, // IETF protocol assignments+ { start: '198.18.0.0', end: '198.19.255.255' }, // Benchmarking+ { start: '224.0.0.0', end: '239.255.255.255' }, // Multicast+ { start: '240.0.0.0', end: '255.255.255.255' }, // Reserved+];++function ipToInt(ip) {+ return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0;+}++function isPrivateIPv4(ip) {+ const ipInt = ipToInt(ip);+ return PRIVATE_IPV4_RANGES.some(range => {+ const startInt = ipToInt(range.start);+ const endInt = ipToInt(range.end);+ return ipInt >= startInt && ipInt <= endInt;+ });+}++function isPrivateIPv6(ip) {+ const normalized = ip.toLowerCase();+ // Loopback+ if (normalized === '::1' || normalized === '0000:0000:0000:0000:0000:0000:0000:0001') return true;+ // Link-local+ if (normalized.startsWith('fe80:')) return true;+ // Unique local (fc00::/7)+ if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true;+ // IPv4-mapped IPv6 (::ffff:x.x.x.x)+ const v4Mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);+ if (v4Mapped) return isPrivateIPv4(v4Mapped[1]);+ return false;+}++/**+ * Validate that a URL is safe for outbound requests (not targeting internal networks).+ * Returns { valid: true } or { valid: false, reason: string }.+ */+export function validateOutboundUrl(urlString) {+ if (!urlString || typeof urlString !== 'string') {+ return { valid: false, reason: 'URL is empty or not a string' };+ }++ let parsed;+ try {+ parsed = new URL(urlString);+ } catch {+ return { valid: false, reason: 'Invalid URL format' };+ }++ // Only allow http and https schemes+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {+ return { valid: false, reason: `Disallowed URL scheme: ${parsed.protocol}` };+ }++ // Block credentials in URL+ if (parsed.username || parsed.password) {+ return { valid: false, reason: 'URLs with embedded credentials are not allowed' };+ }++ const hostname = parsed.hostname;++ // Check if hostname is a raw IP address+ if (isIP(hostname) === 4) {+ if (isPrivateIPv4(hostname)) {+ return { valid: false, reason: `Requests to private IPv4 address ${hostname} are blocked` };+ }+ } else if (isIP(hostname) === 6) {+ if (isPrivateIPv6(hostname)) {+ return { valid: false, reason: `Requests to private IPv6 address ${hostname} are blocked` };+ }+ } else {+ // It's a hostname — block well-known internal hostnames@@ -4,6 +4,7 @@ */ import axios from 'axios';+import { assertSafeUrl } from '../utils/urlValidator.js'; export class LLMClient { constructor(config) {@@ -180,6 +181,8 @@ export class LLMClient { return this.authToken; } + assertSafeUrl(auth.auth_url, 'LLMClient/auth');+ const params = new URLSearchParams({ grant_type: 'client_credentials', client_id: auth.client_id || '',@@ -266,6 +269,7 @@ export class LLMClient { */ getChatEndpoint() { const baseUrl = this.getBaseUrl();+ assertSafeUrl(baseUrl, 'LLMClient/endpoint'); switch (this.provider) { case 'azure':@@ -6,6 +6,7 @@ import { readFileSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import yaml from 'js-yaml';+import { validateOutboundUrl } from '../utils/urlValidator.js'; const router = Router(); const __filename = fileURLToPath(import.meta.url);@@ -137,6 +138,20 @@ router.post('/:id/execute', async (req, res) => { // Use provided config or fallback to file-based load const configToUse = llmConfig || loadLLMConfig(); + // Validate user-supplied URLs against SSRF+ if (configToUse.base_url) {+ const check = validateOutboundUrl(configToUse.base_url);+ if (!check.valid) {+ return res.status(403).json({ error: `SSRF protection: ${check.reason}`, status: 'error' });+ }+ }+ if (configToUse.auth?.auth_url) {+ const check = validateOutboundUrl(configToUse.auth.auth_url);+ if (!check.valid) {+ return res.status(403).json({ error: `SSRF protection: ${check.reason}`, status: 'error' });+ }+ }+ const sessionId = getSessionId(req); const result = await engine.executeWorkflow(req.params.id, input, configToUse, sessionId); res.json(result);@@ -183,10 +198,26 @@ router.post('/:id/debug/start', async (req, res) => { const engine = getWorkflowEngine(); const workflowDebugger = getWorkflowDebugger(engine); + const configToUse = llmConfig || loadLLMConfig();++ // Validate user-supplied URLs against SSRF+ if (configToUse.base_url) {+ const check = validateOutboundUrl(configToUse.base_url);+ if (!check.valid) {+ return res.status(403).json({ error: `SSRF protection: ${check.reason}` });+ }+ }+ if (configToUse.auth?.auth_url) {+ const check = validateOutboundUrl(configToUse.auth.auth_url);+ if (!check.valid) {+ return res.status(403).json({ error: `SSRF protection: ${check.reason}` });+ }+ }+ const session = await workflowDebugger.startDebugSession(req.params.id, input || {}, { breakpoints: breakpoints || [], stepMode: stepMode || false,- llmConfig: llmConfig || loadLLMConfig(),+ llmConfig: configToUse, }); res.json(session);AI-assisted change 6b2e2ae9bc031cc1d2da0129d59c478adf76b1056deaedbf3c221fa111790fa1 · Fix 635b6af40a4723f27545214ae87500fa71883a2accd986419a7dd6fe59cfa1fd