diff --git a/lib/core/Util.js b/lib/core/Util.js index 1132c847..586c9d29 100644 --- a/lib/core/Util.js +++ b/lib/core/Util.js @@ -1,7 +1,8 @@ import { platform, release } from 'os' -const HOST_REGEX = /^(?!\w+:\/\/)([\w-:]+\.)+([\w-:]+)(?::(\d+))?(?!:)$/ +const HOST_REGEX = /^(?!(?:(?:https?|ftp):\/\/|internal|localhost|(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)))(?:[\w-]+\.contentstack\.(?:io|com)(?::[^\/\s:]+)?|[\w-]+(?:\.[\w-]+)*(?::[^\/\s:]+)?)(?![\/?#])$/ // eslint-disable-line export function isHost (host) { + if (!host) return false return HOST_REGEX.test(host) } @@ -122,6 +123,21 @@ const isValidURL = (url) => { return false } + const officialDomains = [ + 'api.contentstack.io', + 'eu-api.contentstack.com', + 'azure-na-api.contentstack.com', + 'azure-eu-api.contentstack.com', + 'gcp-na-api.contentstack.com', + 'gcp-eu-api.contentstack.com' + ] + const isContentstackDomain = officialDomains.some(domain => + parsedURL.hostname === domain || parsedURL.hostname.endsWith('.' + domain) + ) + if (isContentstackDomain && parsedURL.protocol !== 'https:') { + return false + } + // Prevent IP addresses in URLs to avoid internal network access const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/ const ipv6Regex = /^\[?([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}\]?$/ @@ -137,15 +153,16 @@ const isValidURL = (url) => { } return isAllowedHost(parsedURL.hostname) - } catch (error) { + } catch { // If URL parsing fails, it might be a relative URL without protocol // Allow it if it doesn't contain protocol indicators or suspicious patterns - return !url.includes('://') && !url.includes('\\') && !url.includes('@') + return !url?.includes('://') && !url?.includes('\\') && !url?.includes('@') } } const isAllowedHost = (hostname) => { // Define allowed domains for Contentstack API + // Official Contentstack domains const allowedDomains = [ 'api.contentstack.io', 'eu-api.contentstack.com', @@ -172,14 +189,35 @@ const isAllowedHost = (hostname) => { } // Check if hostname is in allowed domains or is a subdomain of allowed domains - return allowedDomains.some(domain => { + const isContentstackDomain = allowedDomains.some(domain => { return hostname === domain || hostname.endsWith('.' + domain) }) + + // If it's not a Contentstack domain, validate custom hostname + if (!isContentstackDomain) { + // Prevent internal/reserved IP ranges and localhost variants + const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/ + if (hostname?.match(ipv4Regex)) { + const parts = hostname.split('.') + const firstOctet = parseInt(parts[0]) + // Only block private IP ranges + if (firstOctet === 10 || firstOctet === 192 || firstOctet === 127) { + return false + } + } + // Allow custom domains that don't match dangerous patterns + return !hostname.includes('file://') && + !hostname.includes('\\') && + !hostname.includes('@') && + hostname !== 'localhost' + } + + return isContentstackDomain } export const validateAndSanitizeConfig = (config) => { - if (!config || !config.url) { - throw new Error('Invalid request configuration: missing URL') + if (!config?.url || typeof config?.url !== 'string') { + throw new Error('Invalid request configuration: missing or invalid URL') } // Validate the URL to prevent SSRF attacks @@ -187,5 +225,13 @@ export const validateAndSanitizeConfig = (config) => { throw new Error(`SSRF Prevention: URL "${config.url}" is not allowed`) } - return config + // Additional validation for baseURL if present + if (config.baseURL && typeof config.baseURL === 'string' && !isValidURL(config.baseURL)) { + throw new Error(`SSRF Prevention: Base URL "${config.baseURL}" is not allowed`) + } + + return { + ...config, + url: config.url.trim() // Sanitize URL by removing whitespace + } } diff --git a/test/unit/Util-test.js b/test/unit/Util-test.js index b7845fff..7d0d8dcf 100644 --- a/test/unit/Util-test.js +++ b/test/unit/Util-test.js @@ -127,4 +127,43 @@ describe('Get User Agent', () => { expect(isHost('contentstack.io/path')).to.be.equal(false, 'contentstack.io/path should not host') done() }) + + describe('Custom domain validation', () => { + it('should validate custom domain hosts', done => { + expect(isHost('dev11-api.csnonprod.com')).to.be.equal(true, 'dev11-api.csnonprod.com should be valid') + expect(isHost('custom-domain.com')).to.be.equal(true, 'custom-domain.com should be valid') + expect(isHost('api.custom-domain.com')).to.be.equal(true, 'api.custom-domain.com should be valid') + expect(isHost('dev11-api.custom-domain.com')).to.be.equal(true, 'dev11-api.custom-domain.com should be valid') + done() + }) + + it('should reject invalid custom domain hosts', done => { + expect(isHost('http://dev11-api.csnonprod.com')).to.be.equal(false, 'should reject URLs with protocol') + expect(isHost('dev11-api.csnonprod.com/v3')).to.be.equal(false, 'should reject URLs with path') + expect(isHost('dev11-api.csnonprod.com?test=1')).to.be.equal(false, 'should reject URLs with query params') + expect(isHost('dev11@api.csnonprod.com')).to.be.equal(false, 'should reject URLs with special chars') + expect(isHost('127.0.0.1')).to.be.equal(false, 'should reject IP addresses') + expect(isHost('internal.domain.com')).to.be.equal(false, 'should reject internal domains') + done() + }) + + it('should handle edge cases correctly', done => { + expect(isHost('')).to.be.equal(false, 'should reject empty string') + expect(isHost('.')).to.be.equal(false, 'should reject single dot') + expect(isHost('.com')).to.be.equal(false, 'should reject domain starting with dot') + expect(isHost('domain.')).to.be.equal(false, 'should reject domain ending with dot') + expect(isHost('domain..com')).to.be.equal(false, 'should reject consecutive dots') + done() + }) + + it('should validate port numbers correctly', done => { + expect(isHost('dev11-api.csnonprod.com:443')).to.be.equal(true, 'should accept valid port') + expect(isHost('dev11-api.csnonprod.com:8080')).to.be.equal(true, 'should accept custom port') + expect(isHost('dev11-api.csnonprod.com:65535')).to.be.equal(true, 'should accept max port') + expect(isHost('dev11-api.csnonprod.com:0')).to.be.equal(true, 'should accept port 0') + expect(isHost('dev11-api.csnonprod.com:65536')).to.be.equal(true, 'should handle port overflow') + expect(isHost('dev11-api.csnonprod.com:abc')).to.be.equal(true, 'should handle non-numeric port') + done() + }) + }) })