mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
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:
parent
4e4c8d0c0e
commit
b6ba2711f9
4 changed files with 497 additions and 2 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue