From 086e9a92dc8445db794a09efe7ffe4300c0fad82 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 19 Nov 2025 17:42:17 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=92=20feat:=20Enhance=20Actions=20SSRF?= =?UTF-8?q?=20Protection=20with=20Comprehensive=20IP=20and=20Domain=20Vali?= =?UTF-8?q?dation=20(#10583)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔒 feat: Enhance SSRF Protection with Comprehensive IP and Domain Validation * Added extensive tests for validating IP addresses and domains to prevent SSRF attacks, including checks for internal, private, and link-local addresses. * Improved domain validation logic to handle various edge cases, ensuring only legitimate requests are processed. * Implemented security measures against common cloud provider metadata access and internal service exploitation. * Updated existing tests to reflect changes in validation logic and ensure robust security coverage. * chore: cleanup comments * 🔒 feat: Improve Domain Validation Logic for Enhanced Security * Added logic to extract and normalize hostnames from client-provided domains, including handling of URLs and IP addresses. * Implemented checks using Node.js's net module to validate IP addresses, ensuring robust domain validation. * Updated existing validation conditions to enhance security against potential SSRF attacks. * feat: Additional Protocol Checks and IPv6 Support * Added tests to reject unsupported protocols (FTP, WebSocket, file) in client domains to strengthen SSRF protection. * Improved domain extraction logic to preserve brackets for IPv6 addresses, ensuring correct URL formatting. * Updated validation logic to handle various edge cases for client-provided domains, enhancing overall security. * feat: Expand Domain Validation Tests for Enhanced SSRF Protection * Added comprehensive tests for handling various URL formats, including IPv6 addresses, authentication credentials, and special characters in paths. * Implemented additional validation scenarios for client domains, covering edge cases such as malformed URLs, empty strings, and unsupported protocols. * Enhanced handling of internationalized domain names and localhost variations to ensure robust domain extraction and validation. --- packages/data-provider/specs/actions.spec.ts | 618 ++++++++++++++++++- packages/data-provider/src/actions.ts | 120 +++- 2 files changed, 706 insertions(+), 32 deletions(-) diff --git a/packages/data-provider/specs/actions.spec.ts b/packages/data-provider/specs/actions.spec.ts index 4b9239ad1e..08942d5505 100644 --- a/packages/data-provider/specs/actions.spec.ts +++ b/packages/data-provider/specs/actions.spec.ts @@ -1539,6 +1539,60 @@ describe('SSRF Protection', () => { 'http://169.254.169.254', ); }); + + it('handles IPv6 URLs with brackets correctly', () => { + expect(extractDomainFromUrl('http://[::1]/')).toBe('http://[::1]'); + expect(extractDomainFromUrl('http://[::1]:8080')).toBe('http://[::1]'); + expect(extractDomainFromUrl('https://[2001:db8::1]/api')).toBe('https://[2001:db8::1]'); + expect(extractDomainFromUrl('http://[fe80::1]/path')).toBe('http://[fe80::1]'); + }); + + it('handles complex IPv6 addresses', () => { + expect(extractDomainFromUrl('http://[2001:db8:85a3::8a2e:370:7334]/api')).toBe( + 'http://[2001:db8:85a3::8a2e:370:7334]', + ); + // Node.js normalizes IPv4-mapped IPv6 to hex form + expect(extractDomainFromUrl('https://[::ffff:192.168.1.1]:8080')).toBe( + 'https://[::ffff:c0a8:101]', + ); + }); + + it('handles URLs with authentication credentials', () => { + expect(extractDomainFromUrl('https://user:pass@example.com/api')).toBe('https://example.com'); + expect(extractDomainFromUrl('http://admin@192.168.1.1:8080')).toBe('http://192.168.1.1'); + }); + + it('handles URLs with special characters in path', () => { + expect(extractDomainFromUrl('https://example.com/path%20with%20spaces')).toBe( + 'https://example.com', + ); + expect(extractDomainFromUrl('https://example.com/path#fragment')).toBe('https://example.com'); + expect(extractDomainFromUrl('https://example.com/?query=value&other=123')).toBe( + 'https://example.com', + ); + }); + + it('handles localhost variations', () => { + expect(extractDomainFromUrl('http://localhost/')).toBe('http://localhost'); + expect(extractDomainFromUrl('https://localhost:3000')).toBe('https://localhost'); + expect(extractDomainFromUrl('http://localhost.localdomain')).toBe( + 'http://localhost.localdomain', + ); + }); + + it('handles internationalized domain names', () => { + expect(extractDomainFromUrl('https://xn--e1afmkfd.xn--p1ai/api')).toBe( + 'https://xn--e1afmkfd.xn--p1ai', + ); + // Node.js URL parser converts IDN to punycode + expect(extractDomainFromUrl('https://münchen.de')).toBe('https://xn--mnchen-3ya.de'); + }); + + it('throws error for non-HTTP/HTTPS protocols in extractDomainFromUrl', () => { + expect(() => extractDomainFromUrl('ftp://example.com')).not.toThrow(); + expect(extractDomainFromUrl('ftp://example.com')).toBe('ftp://example.com'); + // Note: The function doesn't validate protocol, just extracts domain + }); }); describe('validateAndParseOpenAPISpec - SSRF Prevention', () => { @@ -1738,7 +1792,7 @@ describe('SSRF Protection', () => { expect(result.isValid).toBe(false); expect(result.message).toContain('Domain mismatch'); expect(result.message).toContain('example.com'); - expect(result.message).toContain('https://malicious.com'); + expect(result.message).toContain('malicious.com'); }); it('detects SSRF attempt with internal IP', () => { @@ -1837,5 +1891,567 @@ describe('SSRF Protection', () => { expect(result.isValid).toBe(true); expect(result.normalizedSpecDomain).toBe('https://api.openai.com'); }); + + // Tests for IP address validation (fix for the reported issue) + it('validates matching IP addresses when client provides just IP (no protocol)', () => { + const result = validateActionDomain('10.225.26.25', 'http://10.225.26.25:7894/api'); + expect(result.isValid).toBe(true); + expect(result.normalizedSpecDomain).toBe('http://10.225.26.25'); + expect(result.normalizedClientDomain).toBe('http://10.225.26.25'); + }); + + it('validates matching localhost IP when client provides just IP', () => { + const result = validateActionDomain('127.0.0.1', 'http://127.0.0.1:8080/api'); + expect(result.isValid).toBe(true); + expect(result.normalizedSpecDomain).toBe('http://127.0.0.1'); + expect(result.normalizedClientDomain).toBe('http://127.0.0.1'); + }); + + it('validates matching private network IP when client provides just IP', () => { + const result = validateActionDomain('192.168.1.100', 'https://192.168.1.100:443/api'); + expect(result.isValid).toBe(true); + expect(result.normalizedSpecDomain).toBe('https://192.168.1.100'); + expect(result.normalizedClientDomain).toBe('https://192.168.1.100'); + }); + + it('validates matching IP when client provides full URL with IP', () => { + const result = validateActionDomain('http://10.225.26.25', 'http://10.225.26.25:7894'); + expect(result.isValid).toBe(true); + expect(result.normalizedSpecDomain).toBe('http://10.225.26.25'); + expect(result.normalizedClientDomain).toBe('http://10.225.26.25'); + }); + + it('rejects mismatched IP addresses', () => { + const result = validateActionDomain('10.225.26.25', 'http://10.225.26.26:7894/api'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + expect(result.message).toContain('10.225.26.25'); + expect(result.message).toContain('10.225.26.26'); + }); + + it('rejects IP when domain expected', () => { + const result = validateActionDomain('example.com', 'http://192.168.1.1/api'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + expect(result.normalizedSpecDomain).toBe('http://192.168.1.1'); + }); + + it('rejects domain when IP expected', () => { + const result = validateActionDomain('192.168.1.1', 'http://malicious.com/api'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + expect(result.message).toContain('192.168.1.1'); + expect(result.message).toContain('malicious.com'); + }); + + it('handles IPv6 addresses when client provides just IP', () => { + const result = validateActionDomain('[::1]', 'http://[::1]:8080/api'); + expect(result.isValid).toBe(true); + expect(result.normalizedSpecDomain).toBe('http://[::1]'); + expect(result.normalizedClientDomain).toBe('http://[::1]'); + }); + + // Additional IP-based SSRF tests for comprehensive security coverage + it('prevents using whitelisted IP to access different IP', () => { + const result = validateActionDomain('192.168.1.100', 'http://192.168.1.101/api'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + expect(result.message).toContain('192.168.1.100'); + expect(result.message).toContain('192.168.1.101'); + }); + + it('prevents using external IP to access localhost', () => { + const result = validateActionDomain('8.8.8.8', 'http://127.0.0.1/admin'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + }); + + it('prevents using localhost to access private network', () => { + const result = validateActionDomain('127.0.0.1', 'http://192.168.1.1/admin'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + }); + + it('detects SSRF with 0.0.0.0 binding address', () => { + const result = validateActionDomain('example.com', 'http://0.0.0.0:8080'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + expect(result.normalizedSpecDomain).toBe('http://0.0.0.0'); + }); + + it('validates matching 0.0.0.0 when legitimately used', () => { + const result = validateActionDomain('0.0.0.0', 'http://0.0.0.0:8080'); + expect(result.isValid).toBe(true); + expect(result.normalizedSpecDomain).toBe('http://0.0.0.0'); + }); + + it('prevents link-local address SSRF (169.254.x.x)', () => { + const result = validateActionDomain('api.example.com', 'http://169.254.10.10/'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + expect(result.normalizedSpecDomain).toBe('http://169.254.10.10'); + }); + + it('validates matching link-local when explicitly allowed', () => { + const result = validateActionDomain('169.254.10.10', 'http://169.254.10.10/api'); + expect(result.isValid).toBe(true); + expect(result.normalizedSpecDomain).toBe('http://169.254.10.10'); + }); + + it('prevents Docker internal network access via SSRF', () => { + const result = validateActionDomain('public-api.com', 'http://172.17.0.1/'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + expect(result.normalizedSpecDomain).toBe('http://172.17.0.1'); + }); + + it('prevents Kubernetes service network SSRF', () => { + const result = validateActionDomain('api.company.com', 'http://10.96.0.1/'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + }); + + it('detects protocol mismatch for IP addresses', () => { + const result = validateActionDomain('https://192.168.1.1', 'http://192.168.1.1/api'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + expect(result.normalizedSpecDomain).toBe('http://192.168.1.1'); + expect(result.normalizedClientDomain).toBe('https://192.168.1.1'); + }); + + it('prevents IPv6 localhost bypass attempts', () => { + const result = validateActionDomain('example.com', 'http://[::1]/admin'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + expect(result.normalizedSpecDomain).toBe('http://[::1]'); + }); + + it('prevents IPv6 link-local SSRF (fe80::)', () => { + const result = validateActionDomain('api.example.com', 'http://[fe80::1]/'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + }); + + it('validates matching IPv6 link-local when explicitly allowed', () => { + const result = validateActionDomain('[fe80::1]', 'http://[fe80::1]/api'); + expect(result.isValid).toBe(true); + expect(result.normalizedSpecDomain).toBe('http://[fe80::1]'); + }); + + it('prevents multicast address SSRF', () => { + const result = validateActionDomain('api.example.com', 'http://224.0.0.1/'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + }); + + it('prevents broadcast address SSRF', () => { + const result = validateActionDomain('api.example.com', 'http://255.255.255.255/'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + }); + + // Cloud Provider Metadata Service Tests + it('prevents AWS IMDSv1 metadata access', () => { + const result = validateActionDomain( + 'trusted-api.com', + 'http://169.254.169.254/latest/meta-data/', + ); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + }); + + it('prevents AWS IMDSv2 token endpoint access', () => { + const result = validateActionDomain( + 'api.example.com', + 'http://169.254.169.254/latest/api/token', + ); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + }); + + it('prevents GCP metadata access via metadata.google.internal', () => { + const result = validateActionDomain( + 'api.example.com', + 'http://metadata.google.internal/computeMetadata/v1/', + ); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + }); + + it('prevents Azure IMDS access', () => { + const result = validateActionDomain( + 'api.example.com', + 'http://169.254.169.254/metadata/instance?api-version=2021-02-01', + ); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + }); + + it('prevents DigitalOcean metadata access', () => { + const result = validateActionDomain('api.example.com', 'http://169.254.169.254/metadata/v1/'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + }); + + it('prevents Oracle Cloud metadata access', () => { + const result = validateActionDomain( + 'api.example.com', + 'http://169.254.169.254/opc/v1/instance/', + ); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + }); + + it('prevents Alibaba Cloud metadata access', () => { + const result = validateActionDomain( + 'api.example.com', + 'http://100.100.100.200/latest/meta-data/', + ); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + }); + + // Container & Orchestration Internal Services + it('prevents Kubernetes API server access', () => { + const result = validateActionDomain( + 'api.example.com', + 'https://kubernetes.default.svc.cluster.local/', + ); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + }); + + it('prevents Docker host access from container', () => { + const result = validateActionDomain('api.example.com', 'http://host.docker.internal/'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + }); + + it('prevents Rancher metadata service access', () => { + const result = validateActionDomain('api.example.com', 'http://rancher-metadata/'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + }); + + // Common Internal Service Ports + it('prevents Redis default port access', () => { + const result = validateActionDomain('api.example.com', 'http://10.0.0.5:6379/'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + }); + + it('prevents Elasticsearch default port access', () => { + const result = validateActionDomain('api.example.com', 'http://10.0.0.5:9200/'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + }); + + it('prevents MongoDB default port access', () => { + const result = validateActionDomain('api.example.com', 'http://10.0.0.5:27017/'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + }); + + it('prevents PostgreSQL default port access', () => { + const result = validateActionDomain('api.example.com', 'http://10.0.0.5:5432/'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + }); + + it('prevents MySQL default port access', () => { + const result = validateActionDomain('api.example.com', 'http://10.0.0.5:3306/'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + }); + + // Alternative localhost representations + it('prevents localhost.localdomain SSRF', () => { + const result = validateActionDomain('api.example.com', 'http://localhost.localdomain/admin'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + }); + + it('validates matching localhost.localdomain when explicitly allowed', () => { + const result = validateActionDomain( + 'localhost.localdomain', + 'https://localhost.localdomain/api', + ); + expect(result.isValid).toBe(true); + }); + + // Edge cases with special IPs + it('prevents class E reserved IP range access', () => { + const result = validateActionDomain('api.example.com', 'http://240.0.0.1/'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + }); + + it('prevents TEST-NET-1 range access when not matching', () => { + const result = validateActionDomain('api.example.com', 'http://192.0.2.1/'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Domain mismatch'); + }); + + it('validates TEST-NET-1 when explicitly matching', () => { + const result = validateActionDomain('192.0.2.1', 'http://192.0.2.1/api'); + expect(result.isValid).toBe(true); + }); + + // Mixed protocol and IP scenarios (unsupported protocols) + it('rejects unsupported WebSocket protocol', () => { + const result = validateActionDomain('api.example.com', 'ws://api.example.com:8080/'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Invalid protocol'); + expect(result.message).toContain('ws:'); + }); + + it('rejects unsupported FTP protocol', () => { + const result = validateActionDomain('ftp.example.com', 'ftp://ftp.example.com/'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Invalid protocol'); + expect(result.message).toContain('ftp:'); + }); + + it('rejects WSS (secure WebSocket) protocol', () => { + const result = validateActionDomain('api.example.com', 'wss://api.example.com:8080/'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Invalid protocol'); + expect(result.message).toContain('wss:'); + }); + + it('rejects file:// protocol for local file access', () => { + const result = validateActionDomain('localhost', 'file:///etc/passwd'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Invalid protocol'); + expect(result.message).toContain('file:'); + }); + + it('rejects gopher:// protocol', () => { + const result = validateActionDomain('example.com', 'gopher://example.com/'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Invalid protocol'); + expect(result.message).toContain('gopher:'); + }); + + it('rejects data: URL protocol', () => { + const result = validateActionDomain('example.com', 'data:text/plain,Hello'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Invalid protocol'); + expect(result.message).toContain('data:'); + }); + + // Tests for Copilot second review catches + it('rejects unsupported protocol in client domain', () => { + const result = validateActionDomain('ftp://evil.com', 'https://trusted.com/api'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Invalid protocol'); + expect(result.message).toContain('client domain'); + }); + + it('rejects WebSocket protocol in client domain', () => { + const result = validateActionDomain('ws://evil.com', 'https://trusted.com/api'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Invalid protocol'); + expect(result.message).toContain('client domain'); + }); + + it('rejects file protocol in client domain', () => { + const result = validateActionDomain('file:///etc/passwd', 'https://trusted.com/api'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Invalid protocol'); + expect(result.message).toContain('client domain'); + }); + + it('handles IPv6 address without brackets from client', () => { + const result = validateActionDomain('2001:db8::1', 'http://[2001:db8::1]/api'); + expect(result.isValid).toBe(true); + expect(result.normalizedClientDomain).toBe('http://[2001:db8::1]'); + expect(result.normalizedSpecDomain).toBe('http://[2001:db8::1]'); + }); + + it('handles IPv6 address with brackets from client', () => { + const result = validateActionDomain('[2001:db8::1]', 'http://[2001:db8::1]/api'); + expect(result.isValid).toBe(true); + expect(result.normalizedClientDomain).toBe('http://[2001:db8::1]'); + expect(result.normalizedSpecDomain).toBe('http://[2001:db8::1]'); + }); + + // Ensure legitimate internal use cases still work + it('allows legitimate internal API with matching IP', () => { + const result = validateActionDomain('10.0.0.5', 'http://10.0.0.5:8080/api'); + expect(result.isValid).toBe(true); + }); + + it('allows legitimate Docker internal when explicitly specified', () => { + const result = validateActionDomain( + 'host.docker.internal', + 'https://host.docker.internal:3000/api', + ); + expect(result.isValid).toBe(true); + }); + + it('allows legitimate Kubernetes service when explicitly specified', () => { + const result = validateActionDomain( + 'myservice.default.svc.cluster.local', + 'https://myservice.default.svc.cluster.local/api', + ); + expect(result.isValid).toBe(true); + }); + + // Additional coverage tests for error paths and edge cases + it('handles malformed URL in client domain gracefully', () => { + const result = validateActionDomain('http://[invalid', 'https://example.com/api'); + expect(result.isValid).toBe(false); + }); + + it('handles error in spec URL parsing', () => { + const result = validateActionDomain('example.com', 'not-a-valid-url'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Failed to validate domain'); + }); + + it('validates when client provides HTTP and spec uses HTTP', () => { + const result = validateActionDomain('http://example.com', 'http://example.com/api'); + expect(result.isValid).toBe(true); + expect(result.normalizedClientDomain).toBe('http://example.com'); + expect(result.normalizedSpecDomain).toBe('http://example.com'); + }); + + it('validates when client provides HTTPS and spec uses HTTPS', () => { + const result = validateActionDomain('https://example.com', 'https://example.com/api'); + expect(result.isValid).toBe(true); + expect(result.normalizedClientDomain).toBe('https://example.com'); + expect(result.normalizedSpecDomain).toBe('https://example.com'); + }); + + it('handles IPv4 with explicit protocol from client', () => { + const result = validateActionDomain('http://192.168.1.1', 'http://192.168.1.1:8080'); + expect(result.isValid).toBe(true); + expect(result.normalizedClientDomain).toBe('http://192.168.1.1'); + }); + + it('handles localhost as a domain', () => { + const result = validateActionDomain('localhost', 'https://localhost:3000/api'); + expect(result.isValid).toBe(true); + expect(result.normalizedClientDomain).toBe('https://localhost'); + expect(result.normalizedSpecDomain).toBe('https://localhost'); + }); + + it('rejects javascript: protocol in client domain', () => { + const result = validateActionDomain('javascript:alert(1)', 'https://example.com/api'); + expect(result.isValid).toBe(false); + // javascript: doesn't have :// so it's treated as a hostname mismatch + expect(result.message).toContain('Domain mismatch'); + }); + + it('handles empty string as client domain', () => { + const result = validateActionDomain('', 'https://example.com/api'); + expect(result.isValid).toBe(false); + }); + + it('handles spec URL without path', () => { + const result = validateActionDomain('example.com', 'https://example.com'); + expect(result.isValid).toBe(true); + }); + + it('handles spec URL with query parameters', () => { + const result = validateActionDomain( + 'api.example.com', + 'https://api.example.com/v1?key=value', + ); + expect(result.isValid).toBe(true); + expect(result.normalizedSpecDomain).toBe('https://api.example.com'); + }); + + it('handles subdomain matching correctly', () => { + const result = validateActionDomain( + 'api.v2.example.com', + 'https://api.v2.example.com/endpoint', + ); + expect(result.isValid).toBe(true); + }); + + it('rejects SSH protocol in client domain', () => { + const result = validateActionDomain('ssh://git@github.com', 'https://github.com/api'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Invalid protocol'); + }); + + it('handles punycode/internationalized domains', () => { + const result = validateActionDomain( + 'xn--e1afmkfd.xn--p1ai', + 'https://xn--e1afmkfd.xn--p1ai/api', + ); + expect(result.isValid).toBe(true); + }); + + it('validates IPv6 localhost variations', () => { + const result = validateActionDomain('::1', 'http://[::1]:8080'); + expect(result.isValid).toBe(true); + expect(result.normalizedClientDomain).toBe('http://[::1]'); + }); + + it('handles spec URL with username in URL', () => { + const result = validateActionDomain('example.com', 'https://user@example.com/api'); + expect(result.isValid).toBe(true); + expect(result.normalizedSpecDomain).toBe('https://example.com'); + }); + + it('handles spec URL with username and password', () => { + const result = validateActionDomain('example.com', 'https://user:pass@example.com/api'); + expect(result.isValid).toBe(true); + expect(result.normalizedSpecDomain).toBe('https://example.com'); + }); + + it('handles complex IPv6 addresses', () => { + const result = validateActionDomain( + '2001:db8:85a3::8a2e:370:7334', + 'http://[2001:db8:85a3::8a2e:370:7334]/api', + ); + expect(result.isValid).toBe(true); + expect(result.normalizedClientDomain).toBe('http://[2001:db8:85a3::8a2e:370:7334]'); + }); + + it('handles IPv4-mapped IPv6 addresses', () => { + // Node.js normalizes IPv4-mapped IPv6 differently in URL parsing + const result = validateActionDomain('::ffff:c0a8:101', 'http://[::ffff:c0a8:101]/api'); + expect(result.isValid).toBe(true); + expect(result.normalizedClientDomain).toBe('http://[::ffff:c0a8:101]'); + }); + + it('rejects telnet protocol in client domain', () => { + const result = validateActionDomain('telnet://example.com', 'https://example.com/api'); + expect(result.isValid).toBe(false); + expect(result.message).toContain('Invalid protocol'); + }); + + it('handles client domain with port and no protocol', () => { + const result = validateActionDomain('example.com:443', 'https://example.com:443/api'); + // Port is included in hostname comparison, causing mismatch + expect(result.isValid).toBe(false); + expect(result.normalizedClientDomain).toBe('https://example.com:443'); + expect(result.normalizedSpecDomain).toBe('https://example.com'); + }); + + it('handles TLD-only domains', () => { + const result = validateActionDomain('localhost', 'http://localhost/api'); + expect(result.isValid).toBe(false); // HTTP vs HTTPS mismatch + expect(result.normalizedClientDomain).toBe('https://localhost'); + expect(result.normalizedSpecDomain).toBe('http://localhost'); + }); + + it('validates when both URLs have ports', () => { + const result = validateActionDomain( + 'https://api.example.com:8443', + 'https://api.example.com:8443/v1', + ); + expect(result.isValid).toBe(true); + }); + + it('handles client domain that looks like URL but missing protocol separator', () => { + const result = validateActionDomain('httpexample.com', 'https://httpexample.com/api'); + expect(result.isValid).toBe(true); + expect(result.normalizedClientDomain).toBe('https://httpexample.com'); + }); }); }); diff --git a/packages/data-provider/src/actions.ts b/packages/data-provider/src/actions.ts index e3841f420d..d6230f3f4b 100644 --- a/packages/data-provider/src/actions.ts +++ b/packages/data-provider/src/actions.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; -import _axios from 'axios'; import { URL } from 'url'; +import _axios from 'axios'; +import * as net from 'net'; import crypto from 'crypto'; import { load } from 'js-yaml'; import type { ActionMetadata, ActionMetadataRuntime } from './types/agents'; @@ -567,16 +568,18 @@ export type ValidationResult = { }; /** - * 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). + * Extracts domain from URL (protocol + hostname). + * @param url - URL to extract from + * @returns Protocol and hostname (e.g., "https://example.com") */ export function extractDomainFromUrl(url: string): string { try { + /** Parsed URL object */ 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}`; + // Preserve brackets for IPv6 addresses using net.isIP + const ipVersion = net.isIP(parsedUrl.hostname); + const hostname = ipVersion === 6 ? `[${parsedUrl.hostname}]` : parsedUrl.hostname; + return `${parsedUrl.protocol}//${hostname}`; } catch { throw new Error(`Invalid URL format: ${url}`); } @@ -590,45 +593,100 @@ export type DomainValidationResult = { }; /** - * 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 + * Validates client domain matches OpenAPI spec server URL domain (SSRF prevention). + * @param clientProvidedDomain - Domain from client (with/without protocol) + * @param specServerUrl - Server URL from OpenAPI spec + * @returns 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); + /** Parsed spec URL */ + const specUrl = new URL(specServerUrl); - // 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 - ) { + if (specUrl.protocol !== 'http:' && specUrl.protocol !== 'https:') { return { isValid: false, - message: `Domain mismatch: Client provided '${clientProvidedDomain}', but spec uses '${normalizedSpecDomain}'`, + message: `Invalid protocol: Only HTTP and HTTPS are allowed, got ${specUrl.protocol}`, + }; + } + + /** Spec hostname only */ + const specHostname = specUrl.hostname; + /** Spec domain with protocol (handle IPv6 brackets) */ + const specIpVersion = net.isIP(specHostname); + const normalizedSpecDomain = + specIpVersion === 6 + ? `${specUrl.protocol}//[${specHostname}]` + : `${specUrl.protocol}//${specHostname}`; + + /** Extract hostname from client domain if it's a full URL */ + let clientHostname = clientProvidedDomain; + let clientHasProtocol = false; + + // Check for any protocol in the client domain + if (clientProvidedDomain.includes('://')) { + if ( + !clientProvidedDomain.startsWith('http://') && + !clientProvidedDomain.startsWith('https://') + ) { + return { + isValid: false, + message: `Invalid protocol: Only HTTP and HTTPS are allowed in client domain`, + }; + } + try { + const clientUrl = new URL(clientProvidedDomain); + clientHostname = clientUrl.hostname; + clientHasProtocol = true; + } catch { + // If parsing fails, treat as hostname + clientHasProtocol = false; + } + } + + /** Normalize IPv6 addresses by removing brackets for comparison */ + const normalizedClientHostname = clientHostname.replace(/^\[(.+)\]$/, '$1'); + const normalizedSpecHostname = specHostname.replace(/^\[(.+)\]$/, '$1'); + + /** Check if hostname is valid IP using Node.js built-in net module */ + const isIPAddress = net.isIP(normalizedClientHostname) !== 0; + + /** Normalized client domain */ + let normalizedClientDomain: string; + if (clientHasProtocol) { + normalizedClientDomain = extractDomainFromUrl(clientProvidedDomain); + } else { + // IP addresses inherit protocol from spec, domains default to https + if (isIPAddress) { + // IPv6 addresses need brackets in URLs + const ipVersion = net.isIP(normalizedClientHostname); + const hostname = + ipVersion === 6 && !clientHostname.startsWith('[') + ? `[${normalizedClientHostname}]` + : clientHostname; + normalizedClientDomain = `${specUrl.protocol}//${hostname}`; + } else { + normalizedClientDomain = `https://${clientHostname}`; + } + } + + if ( + normalizedSpecDomain === normalizedClientDomain || + (!clientHasProtocol && isIPAddress && normalizedClientHostname === normalizedSpecHostname) + ) { + return { + isValid: true, normalizedSpecDomain, normalizedClientDomain, }; } return { - isValid: true, + isValid: false, + message: `Domain mismatch: Client provided '${clientProvidedDomain}', but spec uses '${specHostname}'`, normalizedSpecDomain, normalizedClientDomain, };