diff --git a/packages/api/src/auth/domain.spec.ts b/packages/api/src/auth/domain.spec.ts index 02ca9767d3..9a4c7c37cc 100644 --- a/packages/api/src/auth/domain.spec.ts +++ b/packages/api/src/auth/domain.spec.ts @@ -1,9 +1,10 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ import { - isEmailDomainAllowed, - isActionDomainAllowed, extractMCPServerDomain, + isActionDomainAllowed, + isEmailDomainAllowed, isMCPDomainAllowed, + isSSRFTarget, } from './domain'; describe('isEmailDomainAllowed', () => { @@ -105,11 +106,131 @@ describe('isEmailDomainAllowed', () => { }); }); +describe('isSSRFTarget', () => { + describe('localhost blocking', () => { + it('should block localhost', () => { + expect(isSSRFTarget('localhost')).toBe(true); + expect(isSSRFTarget('LOCALHOST')).toBe(true); + expect(isSSRFTarget('localhost.localdomain')).toBe(true); + expect(isSSRFTarget('sub.localhost')).toBe(true); + }); + }); + + describe('IPv4 private ranges', () => { + it('should block 127.0.0.0/8 (loopback)', () => { + expect(isSSRFTarget('127.0.0.1')).toBe(true); + expect(isSSRFTarget('127.255.255.255')).toBe(true); + }); + + it('should block 10.0.0.0/8 (private)', () => { + expect(isSSRFTarget('10.0.0.1')).toBe(true); + expect(isSSRFTarget('10.255.255.255')).toBe(true); + }); + + it('should block 172.16.0.0/12 (private)', () => { + expect(isSSRFTarget('172.16.0.1')).toBe(true); + expect(isSSRFTarget('172.31.255.255')).toBe(true); + expect(isSSRFTarget('172.15.0.1')).toBe(false); // Outside range + expect(isSSRFTarget('172.32.0.1')).toBe(false); // Outside range + }); + + it('should block 192.168.0.0/16 (private)', () => { + expect(isSSRFTarget('192.168.0.1')).toBe(true); + expect(isSSRFTarget('192.168.255.255')).toBe(true); + }); + + it('should block 169.254.0.0/16 (link-local/cloud metadata)', () => { + expect(isSSRFTarget('169.254.169.254')).toBe(true); // AWS metadata + expect(isSSRFTarget('169.254.0.1')).toBe(true); + }); + + it('should block 0.0.0.0', () => { + expect(isSSRFTarget('0.0.0.0')).toBe(true); + }); + + it('should allow public IPs', () => { + expect(isSSRFTarget('8.8.8.8')).toBe(false); + expect(isSSRFTarget('1.1.1.1')).toBe(false); + expect(isSSRFTarget('203.0.113.1')).toBe(false); + }); + }); + + describe('IPv6 blocking', () => { + it('should block IPv6 loopback', () => { + expect(isSSRFTarget('::1')).toBe(true); + expect(isSSRFTarget('::')).toBe(true); + expect(isSSRFTarget('[::1]')).toBe(true); + }); + + it('should block IPv6 private ranges', () => { + expect(isSSRFTarget('fc00::1')).toBe(true); + expect(isSSRFTarget('fd00::1')).toBe(true); + expect(isSSRFTarget('fe80::1')).toBe(true); + }); + }); + + describe('internal hostnames', () => { + it('should block common internal service names', () => { + expect(isSSRFTarget('rag_api')).toBe(true); + expect(isSSRFTarget('rag-api')).toBe(true); + expect(isSSRFTarget('redis')).toBe(true); + expect(isSSRFTarget('mongodb')).toBe(true); + expect(isSSRFTarget('postgres')).toBe(true); + expect(isSSRFTarget('elasticsearch')).toBe(true); + }); + + it('should block .internal and .local TLDs', () => { + expect(isSSRFTarget('api.internal')).toBe(true); + expect(isSSRFTarget('service.local')).toBe(true); + }); + + it('should allow legitimate domains', () => { + expect(isSSRFTarget('api.example.com')).toBe(false); + expect(isSSRFTarget('swagger.io')).toBe(false); + expect(isSSRFTarget('openai.com')).toBe(false); + }); + }); +}); + describe('isActionDomainAllowed', () => { afterEach(() => { jest.clearAllMocks(); }); + // SSRF Protection Tests + describe('SSRF protection', () => { + it('should block SSRF targets when no allowedDomains configured', async () => { + // These should be blocked when no explicit allowlist + expect(await isActionDomainAllowed('localhost', null)).toBe(false); + expect(await isActionDomainAllowed('127.0.0.1', null)).toBe(false); + expect(await isActionDomainAllowed('10.0.0.1', null)).toBe(false); + expect(await isActionDomainAllowed('192.168.1.1', null)).toBe(false); + expect(await isActionDomainAllowed('169.254.169.254', null)).toBe(false); + expect(await isActionDomainAllowed('rag_api', null)).toBe(false); + expect(await isActionDomainAllowed('http://rag_api:8000', null)).toBe(false); + }); + + it('should allow public domains with no restrictions', async () => { + expect(await isActionDomainAllowed('api.example.com', null)).toBe(true); + expect(await isActionDomainAllowed('https://openai.com', null)).toBe(true); + }); + + it('should allow SSRF targets when explicitly in allowedDomains (admin override)', async () => { + // Admins can explicitly allow internal targets if needed + const allowedDomains = ['localhost', '127.0.0.1', 'rag_api']; + expect(await isActionDomainAllowed('localhost', allowedDomains)).toBe(true); + expect(await isActionDomainAllowed('127.0.0.1', allowedDomains)).toBe(true); + expect(await isActionDomainAllowed('rag_api', allowedDomains)).toBe(true); + }); + + it('should still block SSRF targets not in allowedDomains even when list is configured', async () => { + // Only explicitly allowed domains should work + const allowedDomains = ['example.com']; + expect(await isActionDomainAllowed('localhost', allowedDomains)).toBe(false); + expect(await isActionDomainAllowed('127.0.0.1', allowedDomains)).toBe(false); + }); + }); + // Basic Input Validation Tests describe('input validation', () => { it('should return false for falsy values', async () => { @@ -217,6 +338,98 @@ describe('isActionDomainAllowed', () => { expect(await isActionDomainAllowed('test.com', invalidAllowedDomains)).toBe(true); }); }); + + // Protocol and Port Restrictions (Recommendation #2) + describe('protocol and port restrictions', () => { + describe('protocol-only restrictions', () => { + const httpsOnlyDomains = ['https://api.example.com', 'https://secure.test.com']; + + it('should allow HTTPS when HTTPS is required', async () => { + expect(await isActionDomainAllowed('https://api.example.com', httpsOnlyDomains)).toBe(true); + expect(await isActionDomainAllowed('https://secure.test.com', httpsOnlyDomains)).toBe(true); + }); + + it('should deny HTTP when HTTPS is required', async () => { + expect(await isActionDomainAllowed('http://api.example.com', httpsOnlyDomains)).toBe(false); + expect(await isActionDomainAllowed('http://secure.test.com', httpsOnlyDomains)).toBe(false); + }); + + it('should deny domain without protocol when protocol is required', async () => { + // When allowedDomains specifies protocol, input should also have protocol + expect(await isActionDomainAllowed('api.example.com', httpsOnlyDomains)).toBe(false); + }); + }); + + describe('port restrictions', () => { + const portRestrictedDomains = ['https://api.example.com:443', 'http://internal:8080']; + + it('should allow matching port', async () => { + expect( + await isActionDomainAllowed('https://api.example.com:443', portRestrictedDomains), + ).toBe(true); + expect(await isActionDomainAllowed('http://internal:8080', portRestrictedDomains)).toBe( + true, + ); + }); + + it('should deny different port', async () => { + expect( + await isActionDomainAllowed('https://api.example.com:8443', portRestrictedDomains), + ).toBe(false); + expect(await isActionDomainAllowed('http://internal:9000', portRestrictedDomains)).toBe( + false, + ); + }); + + it('should deny when no port specified but port required', async () => { + expect(await isActionDomainAllowed('https://api.example.com', portRestrictedDomains)).toBe( + false, + ); + }); + }); + + describe('mixed restrictions', () => { + const mixedDomains = [ + 'example.com', // Any protocol, any port + 'https://secure.example.com', // HTTPS only, default port + 'https://api.example.com:8443', // HTTPS only, specific port + 'http://localhost:3000', // HTTP only, specific port (admin override for internal) + ]; + + it('should allow any protocol/port for unrestricted domain', async () => { + expect(await isActionDomainAllowed('http://example.com', mixedDomains)).toBe(true); + expect(await isActionDomainAllowed('https://example.com', mixedDomains)).toBe(true); + expect(await isActionDomainAllowed('https://example.com:8080', mixedDomains)).toBe(true); + expect(await isActionDomainAllowed('example.com', mixedDomains)).toBe(true); + }); + + it('should enforce protocol for protocol-restricted domain', async () => { + expect(await isActionDomainAllowed('https://secure.example.com', mixedDomains)).toBe(true); + expect(await isActionDomainAllowed('http://secure.example.com', mixedDomains)).toBe(false); + }); + + it('should enforce both protocol and port when both specified', async () => { + expect(await isActionDomainAllowed('https://api.example.com:8443', mixedDomains)).toBe( + true, + ); + expect(await isActionDomainAllowed('http://api.example.com:8443', mixedDomains)).toBe( + false, + ); + expect(await isActionDomainAllowed('https://api.example.com:443', mixedDomains)).toBe( + false, + ); + expect(await isActionDomainAllowed('https://api.example.com', mixedDomains)).toBe(false); + }); + + it('should allow internal targets with explicit protocol/port (admin override)', async () => { + expect(await isActionDomainAllowed('http://localhost:3000', mixedDomains)).toBe(true); + // Different port should fail + expect(await isActionDomainAllowed('http://localhost:8080', mixedDomains)).toBe(false); + // Different protocol should fail + expect(await isActionDomainAllowed('https://localhost:3000', mixedDomains)).toBe(false); + }); + }); + }); }); describe('extractMCPServerDomain', () => { @@ -343,7 +556,8 @@ describe('isMCPDomainAllowed', () => { expect(await isMCPDomainAllowed(config, allowedDomains)).toBe(true); }); - it('should allow localhost', async () => { + it('should allow localhost when explicitly in allowedDomains (admin override)', async () => { + // Admins can explicitly allow localhost for local MCP servers const config = { url: 'http://localhost:3001/sse' }; expect(await isMCPDomainAllowed(config, allowedDomains)).toBe(true); }); diff --git a/packages/api/src/auth/domain.ts b/packages/api/src/auth/domain.ts index 851d3678dc..377213199c 100644 --- a/packages/api/src/auth/domain.ts +++ b/packages/api/src/auth/domain.ts @@ -23,11 +23,134 @@ export function isEmailDomainAllowed(email: string, allowedDomains?: string[] | } /** - * Normalizes a domain string. If the domain is invalid, returns null. - * Normalized === lowercase, trimmed, and protocol added if missing. - * @param domain + * SSRF Protection: Checks if a hostname/IP is a potentially dangerous internal target. + * Blocks private IPs, localhost, cloud metadata IPs, and common internal hostnames. + * @param hostname - The hostname or IP to check + * @returns true if the target is blocked (SSRF risk), false if safe */ -function normalizeDomain(domain: string): string | null { +export function isSSRFTarget(hostname: string): boolean { + const normalizedHost = hostname.toLowerCase().trim(); + + // Block localhost variations + if ( + normalizedHost === 'localhost' || + normalizedHost === 'localhost.localdomain' || + normalizedHost.endsWith('.localhost') + ) { + return true; + } + + // Check if it's an IP address and block private/internal ranges + const ipv4Match = normalizedHost.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (ipv4Match) { + const [, a, b, c] = ipv4Match.map(Number); + + // 127.0.0.0/8 - Loopback + if (a === 127) { + return true; + } + + // 10.0.0.0/8 - Private + if (a === 10) { + return true; + } + + // 172.16.0.0/12 - Private (172.16.x.x - 172.31.x.x) + if (a === 172 && b >= 16 && b <= 31) { + return true; + } + + // 192.168.0.0/16 - Private + if (a === 192 && b === 168) { + return true; + } + + // 169.254.0.0/16 - Link-local (includes cloud metadata 169.254.169.254) + if (a === 169 && b === 254) { + return true; + } + + // 0.0.0.0 - Special + if (a === 0 && b === 0 && c === 0) { + return true; + } + } + + // IPv6 loopback and private ranges + const ipv6Normalized = normalizedHost.replace(/^\[|\]$/g, ''); // Remove brackets if present + if ( + ipv6Normalized === '::1' || + ipv6Normalized === '::' || + ipv6Normalized.startsWith('fc') || // fc00::/7 - Unique local + ipv6Normalized.startsWith('fd') || // fd00::/8 - Unique local + ipv6Normalized.startsWith('fe80') // fe80::/10 - Link-local + ) { + return true; + } + + // Block common internal Docker/Kubernetes service names + const internalHostnames = [ + 'rag_api', + 'rag-api', + 'api', + 'redis', + 'mongodb', + 'mongo', + 'postgres', + 'postgresql', + 'mysql', + 'database', + 'db', + 'elasticsearch', + 'kibana', + 'grafana', + 'prometheus', + 'rabbitmq', + 'kafka', + 'zookeeper', + 'consul', + 'vault', + 'etcd', + 'minio', + 'internal', + 'backend', + 'metadata', // Common metadata service name + ]; + + if (internalHostnames.includes(normalizedHost)) { + return true; + } + + // Block .internal and .local TLDs (common in internal networks) + if (normalizedHost.endsWith('.internal') || normalizedHost.endsWith('.local')) { + return true; + } + + return false; +} + +/** + * Parsed domain specification including protocol and port constraints. + */ +interface ParsedDomainSpec { + hostname: string; + protocol?: 'http:' | 'https:' | null; // null means any protocol + port?: string | null; // null means any port + explicitPort: boolean; // true if port was explicitly specified in original string + isWildcard: boolean; +} + +/** + * Parses a domain specification into its components. + * Supports formats: + * - `example.com` (any protocol, any port) + * - `https://example.com` (https only, any port) + * - `https://example.com:443` (https only, port 443) + * - `*.example.com` (wildcard subdomain) + * @param domain - Domain specification string + * @returns ParsedDomainSpec or null if invalid + */ +function parseDomainSpec(domain: string): ParsedDomainSpec | null { try { let normalizedDomain = domain.toLowerCase().trim(); @@ -36,27 +159,71 @@ function normalizeDomain(domain: string): string | null { return null; } - // If it's not already a URL, make it one - if (!normalizedDomain.startsWith('http://') && !normalizedDomain.startsWith('https://')) { + // Check for wildcard prefix before parsing + const isWildcard = normalizedDomain.startsWith('*.'); + + // Check if it has a protocol + const hasProtocol = + normalizedDomain.startsWith('http://') || normalizedDomain.startsWith('https://'); + + // Check if port was explicitly specified (e.g., :443, :8080) + // Need to check before URL parsing because URL normalizes default ports + const portMatch = normalizedDomain.match(/:(\d+)(\/|$|\?)/); + const explicitPort = portMatch !== null; + const explicitPortValue = portMatch ? portMatch[1] : null; + + // If no protocol, add one temporarily for URL parsing + if (!hasProtocol) { normalizedDomain = `https://${normalizedDomain}`; } const url = new URL(normalizedDomain); + // Additional validation that hostname isn't just protocol if (!url.hostname || url.hostname === 'http:' || url.hostname === 'https:') { return null; } - return url.hostname.replace(/^www\./i, ''); + const hostname = url.hostname.replace(/^www\./i, ''); + + return { + hostname, + protocol: hasProtocol ? (url.protocol as 'http:' | 'https:') : null, + // Use the explicitly specified port, or null if no port was specified + port: explicitPort ? explicitPortValue : null, + explicitPort, + isWildcard, + }; } catch { return null; } } /** - * Checks if the given domain is allowed. If no restrictions are set, allows all domains. - * @param domain - * @param allowedDomains + * Checks if hostname matches an allowed pattern (supports wildcards). + */ +function hostnameMatches(inputHostname: string, allowedSpec: ParsedDomainSpec): boolean { + if (allowedSpec.isWildcard) { + // Extract base domain from wildcard (e.g., "*.example.com" -> "example.com") + const baseDomain = allowedSpec.hostname.replace(/^\*\./, ''); + return inputHostname === baseDomain || inputHostname.endsWith(`.${baseDomain}`); + } + return inputHostname === allowedSpec.hostname; +} + +/** + * Checks if the given domain is allowed. + * SECURITY: When no allowedDomains is configured, blocks SSRF-prone targets + * (private IPs, localhost, metadata services) to prevent attacks. + * When allowedDomains IS configured, admins can explicitly allow internal targets if needed. + * + * Supports protocol and port restrictions in allowedDomains: + * - `example.com` - allows any protocol/port + * - `https://example.com` - allows only HTTPS on default port + * - `https://example.com:8443` - allows only HTTPS on port 8443 + * + * @param domain - The domain to check (can include protocol/port) + * @param allowedDomains - List of allowed domain patterns */ export async function isActionDomainAllowed( domain?: string | null, @@ -66,32 +233,50 @@ export async function isActionDomainAllowed( return false; } - if (!Array.isArray(allowedDomains) || !allowedDomains.length) { - return true; - } - - const normalizedInputDomain = normalizeDomain(domain); - if (!normalizedInputDomain) { + const inputSpec = parseDomainSpec(domain); + if (!inputSpec) { return false; } + /** If no domain restrictions configured, block SSRF targets but allow all else */ + if (!Array.isArray(allowedDomains) || !allowedDomains.length) { + /** SECURITY: Block SSRF-prone targets when no allowlist is configured */ + if (isSSRFTarget(inputSpec.hostname)) { + return false; + } + return true; + } + + /** When allowedDomains is configured, check against the list with protocol/port matching */ for (const allowedDomain of allowedDomains) { - const normalizedAllowedDomain = normalizeDomain(allowedDomain); - if (!normalizedAllowedDomain) { + const allowedSpec = parseDomainSpec(allowedDomain); + if (!allowedSpec) { continue; } - if (normalizedAllowedDomain.startsWith('*.')) { - const baseDomain = normalizedAllowedDomain.slice(2); - if ( - normalizedInputDomain === baseDomain || - normalizedInputDomain.endsWith(`.${baseDomain}`) - ) { - return true; - } - } else if (normalizedInputDomain === normalizedAllowedDomain) { - return true; + // Check hostname match (with wildcard support) + if (!hostnameMatches(inputSpec.hostname, allowedSpec)) { + continue; } + + // If allowedSpec has protocol restriction, input must match + if (allowedSpec.protocol !== null) { + // Input must have protocol specified to match a protocol-restricted rule + if (inputSpec.protocol === null || inputSpec.protocol !== allowedSpec.protocol) { + continue; + } + } + + // If allowedSpec has explicit port restriction, input must have matching explicit port + if (allowedSpec.explicitPort) { + // Input must also have an explicit port that matches + if (!inputSpec.explicitPort || inputSpec.port !== allowedSpec.port) { + continue; + } + } + + // All specified constraints matched + return true; } return false; diff --git a/packages/data-provider/src/actions.ts b/packages/data-provider/src/actions.ts index f6ab82526c..70bd8fb325 100644 --- a/packages/data-provider/src/actions.ts +++ b/packages/data-provider/src/actions.ts @@ -290,7 +290,21 @@ class RequestExecutor { ...(this.config.contentType ? { 'Content-Type': this.config.contentType } : {}), }; const method = this.config.method.toLowerCase(); - const axios = _axios.create(); + + /** + * SECURITY: Disable automatic redirects to prevent SSRF bypass. + * Attackers could use redirects to access internal services: + * 1. Set action URL to allowed external domain + * 2. External domain redirects to internal service (e.g., 127.0.0.1, rag_api) + * 3. Without this protection, axios would follow the redirect + * + * By setting maxRedirects: 0, we prevent this attack vector. + * The action will receive the redirect response (3xx) instead of following it. + */ + const axios = _axios.create({ + maxRedirects: 0, + validateStatus: (status) => status >= 200 && status < 400, // Accept 3xx but don't follow + }); // Initialize separate containers for query and body parameters. const queryParams: Record = {};