From b6ba2711f98aa50a1df3c0e4a9a7176f2b3f8de0 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 11 Nov 2025 14:14:55 -0500 Subject: [PATCH] Merge commit from fork - Implemented validation for OpenAPI specifications to ensure the server URL matches the client-provided domain, preventing SSRF attacks. - Added domain extraction and validation functions to improve security checks. - Updated relevant services and routes to utilize the new validation logic, ensuring robust handling of client-provided domains against the OpenAPI spec. - Introduced comprehensive tests to validate the new security features and ensure correct behavior across various scenarios. --- api/server/routes/agents/actions.js | 28 ++ api/server/services/ToolService.js | 34 +- packages/data-provider/specs/actions.spec.ts | 361 +++++++++++++++++++ packages/data-provider/src/actions.ts | 76 ++++ 4 files changed, 497 insertions(+), 2 deletions(-) diff --git a/api/server/routes/agents/actions.js b/api/server/routes/agents/actions.js index aceaa8d207..12168ba28a 100644 --- a/api/server/routes/agents/actions.js +++ b/api/server/routes/agents/actions.js @@ -9,6 +9,8 @@ const { PermissionTypes, actionDelimiter, removeNullishValues, + validateActionDomain, + validateAndParseOpenAPISpec, } = require('librechat-data-provider'); const { encryptMetadata, domainParser } = require('~/server/services/ActionService'); const { findAccessibleResources } = require('~/server/services/PermissionService'); @@ -83,6 +85,32 @@ router.post( let metadata = await encryptMetadata(removeNullishValues(_metadata, true)); const appConfig = req.config; + + // SECURITY: Validate the OpenAPI spec and extract the server URL + if (metadata.raw_spec) { + const validationResult = validateAndParseOpenAPISpec(metadata.raw_spec); + if (!validationResult.status || !validationResult.serverUrl) { + return res.status(400).json({ + message: validationResult.message || 'Invalid OpenAPI specification', + }); + } + + // SECURITY: Validate the client-provided domain matches the spec's server URL domain + // This prevents SSRF attacks where an attacker provides a whitelisted domain + // but uses a different (potentially internal) URL in the raw_spec + const domainValidation = validateActionDomain(metadata.domain, validationResult.serverUrl); + if (!domainValidation.isValid) { + logger.warn(`Domain mismatch detected: ${domainValidation.message}`, { + userId: req.user.id, + agent_id, + }); + return res.status(400).json({ + message: + 'Domain mismatch: The domain in the OpenAPI spec does not match the provided domain', + }); + } + } + const isDomainAllowed = await isActionDomainAllowed( metadata.domain, appConfig?.actions?.allowedDomains, diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 4b9708861c..fda896ae0b 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -18,6 +18,7 @@ const { ImageVisionTool, openapiToFunction, AgentCapabilities, + validateActionDomain, defaultAgentCapabilities, validateAndParseOpenAPISpec, } = require('librechat-data-provider'); @@ -236,12 +237,26 @@ async function processRequiredActions(client, requiredActions) { // Validate and parse OpenAPI spec const validationResult = validateAndParseOpenAPISpec(action.metadata.raw_spec); - if (!validationResult.spec) { + if (!validationResult.spec || !validationResult.serverUrl) { throw new Error( `Invalid spec: user: ${client.req.user.id} | thread_id: ${requiredActions[0].thread_id} | run_id: ${requiredActions[0].run_id}`, ); } + // SECURITY: Validate the domain from the spec matches the stored domain + // This is defense-in-depth to prevent any stored malicious actions + const domainValidation = validateActionDomain( + action.metadata.domain, + validationResult.serverUrl, + ); + if (!domainValidation.isValid) { + logger.error(`Domain mismatch in stored action: ${domainValidation.message}`, { + userId: client.req.user.id, + action_id: action.action_id, + }); + continue; // Skip this action rather than failing the entire request + } + // Process the OpenAPI spec const { requestBuilders } = openapiToFunction(validationResult.spec); @@ -525,10 +540,25 @@ async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIA // Validate and parse OpenAPI spec once per action set const validationResult = validateAndParseOpenAPISpec(action.metadata.raw_spec); - if (!validationResult.spec) { + if (!validationResult.spec || !validationResult.serverUrl) { continue; } + // SECURITY: Validate the domain from the spec matches the stored domain + // This is defense-in-depth to prevent any stored malicious actions + const domainValidation = validateActionDomain( + action.metadata.domain, + validationResult.serverUrl, + ); + if (!domainValidation.isValid) { + logger.error(`Domain mismatch in stored action: ${domainValidation.message}`, { + userId: req.user.id, + agent_id: agent.id, + action_id: action.action_id, + }); + continue; // Skip this action rather than failing the entire request + } + const encrypted = { oauth_client_id: action.metadata.oauth_client_id, oauth_client_secret: action.metadata.oauth_client_secret, diff --git a/packages/data-provider/specs/actions.spec.ts b/packages/data-provider/specs/actions.spec.ts index 8e48b8e876..4b9239ad1e 100644 --- a/packages/data-provider/specs/actions.spec.ts +++ b/packages/data-provider/specs/actions.spec.ts @@ -9,6 +9,8 @@ import { ActionRequest, openapiToFunction, FunctionSignature, + extractDomainFromUrl, + validateActionDomain, validateAndParseOpenAPISpec, } from '../src/actions'; import { @@ -1478,3 +1480,362 @@ describe('createURL', () => { }); }); }); + +describe('SSRF Protection', () => { + describe('extractDomainFromUrl', () => { + it('extracts domain from valid HTTPS URL', () => { + expect(extractDomainFromUrl('https://example.com')).toBe('https://example.com'); + expect(extractDomainFromUrl('https://example.com/path')).toBe('https://example.com'); + expect(extractDomainFromUrl('https://example.com:8080')).toBe('https://example.com'); + expect(extractDomainFromUrl('https://example.com:8080/path?query=value')).toBe( + 'https://example.com', + ); + }); + + it('extracts domain from valid HTTP URL', () => { + expect(extractDomainFromUrl('http://example.com')).toBe('http://example.com'); + expect(extractDomainFromUrl('http://example.com/api')).toBe('http://example.com'); + }); + + it('handles subdomains correctly', () => { + expect(extractDomainFromUrl('https://api.example.com')).toBe('https://api.example.com'); + expect(extractDomainFromUrl('https://subdomain.api.example.com/path')).toBe( + 'https://subdomain.api.example.com', + ); + }); + + it('throws error for invalid URLs', () => { + expect(() => extractDomainFromUrl('not-a-url')).toThrow('Invalid URL format'); + expect(() => extractDomainFromUrl('')).toThrow('Invalid URL format'); + expect(() => extractDomainFromUrl('example.com')).toThrow('Invalid URL format'); + }); + + it('preserves protocol to prevent HTTP/HTTPS confusion', () => { + const httpsDomain = extractDomainFromUrl('https://example.com/path'); + const httpDomain = extractDomainFromUrl('http://example.com/path'); + expect(httpsDomain).not.toBe(httpDomain); + expect(httpsDomain).toBe('https://example.com'); + expect(httpDomain).toBe('http://example.com'); + }); + + it('handles internal/private IP addresses', () => { + expect(extractDomainFromUrl('http://192.168.1.1')).toBe('http://192.168.1.1'); + expect(extractDomainFromUrl('http://10.0.0.1/admin')).toBe('http://10.0.0.1'); + expect(extractDomainFromUrl('http://172.16.0.1')).toBe('http://172.16.0.1'); + expect(extractDomainFromUrl('http://127.0.0.1:8080')).toBe('http://127.0.0.1'); + }); + + it('handles cloud metadata service URLs', () => { + // AWS EC2 metadata + expect(extractDomainFromUrl('http://169.254.169.254/latest/meta-data/')).toBe( + 'http://169.254.169.254', + ); + // Google Cloud metadata + expect(extractDomainFromUrl('http://metadata.google.internal/computeMetadata/v1/')).toBe( + 'http://metadata.google.internal', + ); + // Azure metadata + expect(extractDomainFromUrl('http://169.254.169.254/metadata/instance')).toBe( + 'http://169.254.169.254', + ); + }); + }); + + describe('validateAndParseOpenAPISpec - SSRF Prevention', () => { + it('returns serverUrl for valid spec', () => { + const validSpec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Test API', version: '1.0.0' }, + servers: [{ url: 'https://example.com' }], + paths: { '/test': {} }, + }); + + const result = validateAndParseOpenAPISpec(validSpec); + expect(result.status).toBe(true); + expect(result.serverUrl).toBe('https://example.com'); + }); + + it('extracts serverUrl even with path in server URL', () => { + const specWithPath = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Test API', version: '1.0.0' }, + servers: [{ url: 'https://example.com/api/v1' }], + paths: { '/test': {} }, + }); + + const result = validateAndParseOpenAPISpec(specWithPath); + expect(result.status).toBe(true); + expect(result.serverUrl).toBe('https://example.com/api/v1'); + }); + + it('detects potential SSRF attempts with internal IPs', () => { + const internalIPSpec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Test API', version: '1.0.0' }, + servers: [{ url: 'http://192.168.1.1' }], + paths: { '/test': {} }, + }); + + const result = validateAndParseOpenAPISpec(internalIPSpec); + expect(result.status).toBe(true); + expect(result.serverUrl).toBe('http://192.168.1.1'); + }); + + it('detects potential SSRF attempts with localhost', () => { + const localhostSpec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Test API', version: '1.0.0' }, + servers: [{ url: 'http://localhost:8080' }], + paths: { '/test': {} }, + }); + + const result = validateAndParseOpenAPISpec(localhostSpec); + expect(result.status).toBe(true); + expect(result.serverUrl).toBe('http://localhost:8080'); + }); + + it('detects potential SSRF attempts with cloud metadata services', () => { + const awsMetadataSpec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Test API', version: '1.0.0' }, + servers: [{ url: 'http://169.254.169.254/latest/meta-data/' }], + paths: { '/test': {} }, + }); + + const result = validateAndParseOpenAPISpec(awsMetadataSpec); + expect(result.status).toBe(true); + expect(result.serverUrl).toBe('http://169.254.169.254/latest/meta-data/'); + }); + + it('handles multiple servers and returns the first one', () => { + const multiServerSpec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Test API', version: '1.0.0' }, + servers: [{ url: 'https://api.example.com' }, { url: 'https://backup.example.com' }], + paths: { '/test': {} }, + }); + + const result = validateAndParseOpenAPISpec(multiServerSpec); + expect(result.status).toBe(true); + expect(result.serverUrl).toBe('https://api.example.com'); + }); + }); + + describe('SSRF Attack Scenarios', () => { + it('scenario: attacker tries to use whitelisted domain but different spec URL', () => { + const maliciousSpec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Malicious API', version: '1.0.0' }, + servers: [{ url: 'http://169.254.169.254/latest/meta-data/' }], // AWS metadata service + paths: { '/': { get: { summary: 'Get metadata', operationId: 'getMetadata' } } }, + }); + + const result = validateAndParseOpenAPISpec(maliciousSpec); + expect(result.status).toBe(true); + expect(result.serverUrl).toBe('http://169.254.169.254/latest/meta-data/'); + + // The fix ensures this serverUrl would be validated against the domain whitelist + const extractedDomain = extractDomainFromUrl(result.serverUrl!); + expect(extractedDomain).toBe('http://169.254.169.254'); + + // In the actual validation, this would not match a whitelisted 'example.com' + expect(extractedDomain).not.toContain('example.com'); + }); + + it('scenario: attacker tries to use internal network IP', () => { + const internalNetworkSpec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Internal API', version: '1.0.0' }, + servers: [{ url: 'http://10.0.0.1:8080/admin' }], + paths: { '/': { get: { summary: 'Admin endpoint', operationId: 'getAdmin' } } }, + }); + + const result = validateAndParseOpenAPISpec(internalNetworkSpec); + expect(result.status).toBe(true); + expect(result.serverUrl).toBe('http://10.0.0.1:8080/admin'); + + const extractedDomain = extractDomainFromUrl(result.serverUrl!); + expect(extractedDomain).toBe('http://10.0.0.1'); + expect(extractedDomain).not.toContain('example.com'); + }); + + it('scenario: attacker tries to access Google Cloud metadata', () => { + const gcpMetadataSpec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'GCP Metadata', version: '1.0.0' }, + servers: [{ url: 'http://metadata.google.internal/computeMetadata/v1/' }], + paths: { '/': { get: { summary: 'Get GCP metadata', operationId: 'getGCPMetadata' } } }, + }); + + const result = validateAndParseOpenAPISpec(gcpMetadataSpec); + expect(result.status).toBe(true); + expect(result.serverUrl).toBe('http://metadata.google.internal/computeMetadata/v1/'); + + const extractedDomain = extractDomainFromUrl(result.serverUrl!); + expect(extractedDomain).toBe('http://metadata.google.internal'); + expect(extractedDomain).not.toContain('example.com'); + }); + + it('scenario: legitimate use case with correct domain matching', () => { + const legitimateSpec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Legitimate API', version: '1.0.0' }, + servers: [{ url: 'https://api.example.com/v1' }], + paths: { '/data': { get: { summary: 'Get data', operationId: 'getData' } } }, + }); + + const result = validateAndParseOpenAPISpec(legitimateSpec); + expect(result.status).toBe(true); + expect(result.serverUrl).toBe('https://api.example.com/v1'); + + const extractedDomain = extractDomainFromUrl(result.serverUrl!); + expect(extractedDomain).toBe('https://api.example.com'); + + // This should match when client provides 'api.example.com' or 'https://api.example.com' + const clientProvidedDomain = 'api.example.com'; + const normalizedClientDomain = `https://${clientProvidedDomain}`; + expect(extractedDomain).toBe(normalizedClientDomain); + }); + + it('scenario: protocol mismatch should be detected', () => { + const httpSpec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'HTTP API', version: '1.0.0' }, + servers: [{ url: 'http://example.com' }], + paths: { '/': { get: { summary: 'Get data', operationId: 'getData' } } }, + }); + + const result = validateAndParseOpenAPISpec(httpSpec); + expect(result.status).toBe(true); + expect(result.serverUrl).toBe('http://example.com'); + + const extractedDomain = extractDomainFromUrl(result.serverUrl!); + expect(extractedDomain).toBe('http://example.com'); + + // If client provided 'https://example.com', there would be a mismatch + const clientProvidedHttps = 'https://example.com'; + expect(extractedDomain).not.toBe(clientProvidedHttps); + }); + }); + + describe('validateActionDomain', () => { + it('validates matching domains with HTTPS protocol', () => { + const result = validateActionDomain('example.com', 'https://example.com/api/v1'); + expect(result.isValid).toBe(true); + expect(result.normalizedSpecDomain).toBe('https://example.com'); + expect(result.normalizedClientDomain).toBe('https://example.com'); + }); + + it('validates matching domains when client provides full URL', () => { + const result = validateActionDomain('https://example.com', 'https://example.com/api'); + expect(result.isValid).toBe(true); + expect(result.normalizedSpecDomain).toBe('https://example.com'); + expect(result.normalizedClientDomain).toBe('https://example.com'); + }); + + it('rejects mismatched domains', () => { + const result = validateActionDomain('example.com', 'https://malicious.com/api'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + expect(result.message).toContain('example.com'); + expect(result.message).toContain('https://malicious.com'); + }); + + it('detects SSRF attempt with internal IP', () => { + const result = validateActionDomain('example.com', 'http://192.168.1.1/admin'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + expect(result.normalizedSpecDomain).toBe('http://192.168.1.1'); + }); + + it('detects SSRF attempt with AWS metadata service', () => { + const result = validateActionDomain( + 'api.example.com', + 'http://169.254.169.254/latest/meta-data/', + ); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + expect(result.normalizedSpecDomain).toBe('http://169.254.169.254'); + }); + + it('detects SSRF attempt with localhost', () => { + const result = validateActionDomain('example.com', 'http://localhost:8080/api'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + expect(result.normalizedSpecDomain).toBe('http://localhost'); + }); + + it('detects protocol mismatch (HTTP vs HTTPS)', () => { + const result = validateActionDomain('https://example.com', 'http://example.com/api'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + expect(result.normalizedSpecDomain).toBe('http://example.com'); + expect(result.normalizedClientDomain).toBe('https://example.com'); + }); + + it('validates matching subdomains', () => { + const result = validateActionDomain('api.example.com', 'https://api.example.com/v1'); + expect(result.isValid).toBe(true); + expect(result.normalizedSpecDomain).toBe('https://api.example.com'); + }); + + it('rejects different subdomains', () => { + const result = validateActionDomain('api.example.com', 'https://admin.example.com/v1'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + }); + + it('handles invalid server URL gracefully', () => { + const result = validateActionDomain('example.com', 'not-a-valid-url'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Failed to validate domain'); + }); + + it('validates with port numbers', () => { + const result = validateActionDomain('example.com', 'https://example.com:8443/api'); + expect(result.isValid).toBe(true); + expect(result.normalizedSpecDomain).toBe('https://example.com'); + }); + + it('detects port-based SSRF attempt', () => { + const result = validateActionDomain('example.com', 'http://example.com:6379/'); + expect(result.isValid).toBe(false); + expect(result.normalizedSpecDomain).toBe('http://example.com'); + expect(result.normalizedClientDomain).toBe('https://example.com'); + }); + + it('validates Google Cloud metadata service detection', () => { + const result = validateActionDomain( + 'example.com', + 'http://metadata.google.internal/computeMetadata/v1/', + ); + expect(result.isValid).toBe(false); + expect(result.normalizedSpecDomain).toBe('http://metadata.google.internal'); + }); + + it('validates Azure metadata service detection', () => { + const result = validateActionDomain( + 'example.com', + 'http://169.254.169.254/metadata/instance', + ); + expect(result.isValid).toBe(false); + expect(result.normalizedSpecDomain).toBe('http://169.254.169.254'); + }); + + it('handles edge case: client provides domain with protocol matching spec', () => { + const result = validateActionDomain('http://example.com', 'http://example.com/api'); + expect(result.isValid).toBe(true); + expect(result.normalizedSpecDomain).toBe('http://example.com'); + expect(result.normalizedClientDomain).toBe('http://example.com'); + }); + + it('validates real-world case: legitimate API with versioned path', () => { + const result = validateActionDomain( + 'api.openai.com', + 'https://api.openai.com/v1/chat/completions', + ); + expect(result.isValid).toBe(true); + expect(result.normalizedSpecDomain).toBe('https://api.openai.com'); + }); + }); +}); diff --git a/packages/data-provider/src/actions.ts b/packages/data-provider/src/actions.ts index 8d9c635c3b..e3841f420d 100644 --- a/packages/data-provider/src/actions.ts +++ b/packages/data-provider/src/actions.ts @@ -563,8 +563,83 @@ export type ValidationResult = { status: boolean; message: string; spec?: OpenAPIV3.Document; + serverUrl?: string; }; +/** + * Extracts the domain from a URL string. + * @param {string} url - The URL to extract the domain from. + * @returns {string} The extracted domain (hostname with protocol). + */ +export function extractDomainFromUrl(url: string): string { + try { + const parsedUrl = new URL(url); + // Return protocol + hostname (e.g., "https://example.com") + // This preserves the protocol which is important for SSRF prevention + return `${parsedUrl.protocol}//${parsedUrl.hostname}`; + } catch { + throw new Error(`Invalid URL format: ${url}`); + } +} + +export type DomainValidationResult = { + isValid: boolean; + message?: string; + normalizedSpecDomain?: string; + normalizedClientDomain?: string; +}; + +/** + * Validates that a client-provided domain matches the domain from an OpenAPI spec server URL. + * This is critical for preventing SSRF attacks where an attacker provides a whitelisted domain + * but uses a different (potentially internal) URL in the raw OpenAPI spec. + * + * @param {string} clientProvidedDomain - The domain provided by the client (may or may not include protocol) + * @param {string} specServerUrl - The server URL from the OpenAPI spec + * @returns {DomainValidationResult} Validation result with normalized domains + */ +export function validateActionDomain( + clientProvidedDomain: string, + specServerUrl: string, +): DomainValidationResult { + try { + // Extract domain from the spec's server URL + const specDomain = extractDomainFromUrl(specServerUrl); + const normalizedSpecDomain = extractDomainFromUrl(specDomain); + + // Normalize client-provided domain (add https:// if no protocol) + const normalizedClientDomain = clientProvidedDomain.startsWith('http') + ? clientProvidedDomain + : `https://${clientProvidedDomain}`; + + // Compare normalized domains + // We check both the normalized client domain and the raw client domain + // to handle cases where the client might provide "example.com" vs "https://example.com" + if ( + normalizedSpecDomain !== normalizedClientDomain && + normalizedSpecDomain !== clientProvidedDomain + ) { + return { + isValid: false, + message: `Domain mismatch: Client provided '${clientProvidedDomain}', but spec uses '${normalizedSpecDomain}'`, + normalizedSpecDomain, + normalizedClientDomain, + }; + } + + return { + isValid: true, + normalizedSpecDomain, + normalizedClientDomain, + }; + } catch (error) { + return { + isValid: false, + message: `Failed to validate domain: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } +} + /** * Validates and parses an OpenAPI spec. */ @@ -626,6 +701,7 @@ export function validateAndParseOpenAPISpec(specString: string): ValidationResul status: true, message: messages.join('\n') || 'OpenAPI spec is valid.', spec: parsedSpec, + serverUrl: parsedSpec.servers[0].url, }; } catch (error) { console.error(error);