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.
This commit is contained in:
Danny Avila 2025-11-11 14:14:55 -05:00 committed by GitHub
parent 4e4c8d0c0e
commit b6ba2711f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 497 additions and 2 deletions

View file

@ -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,

View file

@ -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,

View file

@ -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');
});
});
});

View file

@ -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);