Skip to content

fixed ssrf #416

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Aug 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 53 additions & 7 deletions lib/core/Util.js
Original file line number Diff line number Diff line change
@@ -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)
}

Expand Down Expand Up @@ -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}\]?$/
Expand All @@ -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',
Expand All @@ -172,20 +189,49 @@ 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
if (!isValidURL(config.url)) {
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
}
}
39 changes: 39 additions & 0 deletions test/unit/Util-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('[email protected]')).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()
})
})
})
Loading