🔒 feat: Enhance Actions SSRF Protection with Comprehensive IP and Domain Validation (#10583)

* 🔒 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.
This commit is contained in:
Danny Avila 2025-11-19 17:42:17 -05:00 committed by GitHub
parent 9f2fc25bde
commit 086e9a92dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 706 additions and 32 deletions

View file

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

View file

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