Skip to content

Commit b68dd3f

Browse files
authored
Merge pull request #415 from contentstack/fix/snyk
fixed ssrf
2 parents 1248ec8 + c5f226d commit b68dd3f

File tree

2 files changed

+92
-7
lines changed

2 files changed

+92
-7
lines changed

lib/core/Util.js

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { platform, release } from 'os'
2-
const HOST_REGEX = /^(?!\w+:\/\/)([\w-:]+\.)+([\w-:]+)(?::(\d+))?(?!:)$/
2+
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
33

44
export function isHost (host) {
5+
if (!host) return false
56
return HOST_REGEX.test(host)
67
}
78

@@ -122,6 +123,21 @@ const isValidURL = (url) => {
122123
return false
123124
}
124125

126+
const officialDomains = [
127+
'api.contentstack.io',
128+
'eu-api.contentstack.com',
129+
'azure-na-api.contentstack.com',
130+
'azure-eu-api.contentstack.com',
131+
'gcp-na-api.contentstack.com',
132+
'gcp-eu-api.contentstack.com'
133+
]
134+
const isContentstackDomain = officialDomains.some(domain =>
135+
parsedURL.hostname === domain || parsedURL.hostname.endsWith('.' + domain)
136+
)
137+
if (isContentstackDomain && parsedURL.protocol !== 'https:') {
138+
return false
139+
}
140+
125141
// Prevent IP addresses in URLs to avoid internal network access
126142
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/
127143
const ipv6Regex = /^\[?([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}\]?$/
@@ -137,15 +153,16 @@ const isValidURL = (url) => {
137153
}
138154

139155
return isAllowedHost(parsedURL.hostname)
140-
} catch (error) {
156+
} catch {
141157
// If URL parsing fails, it might be a relative URL without protocol
142158
// Allow it if it doesn't contain protocol indicators or suspicious patterns
143-
return !url.includes('://') && !url.includes('\\') && !url.includes('@')
159+
return !url?.includes('://') && !url?.includes('\\') && !url?.includes('@')
144160
}
145161
}
146162

147163
const isAllowedHost = (hostname) => {
148164
// Define allowed domains for Contentstack API
165+
// Official Contentstack domains
149166
const allowedDomains = [
150167
'api.contentstack.io',
151168
'eu-api.contentstack.com',
@@ -172,20 +189,49 @@ const isAllowedHost = (hostname) => {
172189
}
173190

174191
// Check if hostname is in allowed domains or is a subdomain of allowed domains
175-
return allowedDomains.some(domain => {
192+
const isContentstackDomain = allowedDomains.some(domain => {
176193
return hostname === domain || hostname.endsWith('.' + domain)
177194
})
195+
196+
// If it's not a Contentstack domain, validate custom hostname
197+
if (!isContentstackDomain) {
198+
// Prevent internal/reserved IP ranges and localhost variants
199+
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/
200+
if (hostname?.match(ipv4Regex)) {
201+
const parts = hostname.split('.')
202+
const firstOctet = parseInt(parts[0])
203+
// Only block private IP ranges
204+
if (firstOctet === 10 || firstOctet === 192 || firstOctet === 127) {
205+
return false
206+
}
207+
}
208+
// Allow custom domains that don't match dangerous patterns
209+
return !hostname.includes('file://') &&
210+
!hostname.includes('\\') &&
211+
!hostname.includes('@') &&
212+
hostname !== 'localhost'
213+
}
214+
215+
return isContentstackDomain
178216
}
179217

180218
export const validateAndSanitizeConfig = (config) => {
181-
if (!config || !config.url) {
182-
throw new Error('Invalid request configuration: missing URL')
219+
if (!config?.url || typeof config?.url !== 'string') {
220+
throw new Error('Invalid request configuration: missing or invalid URL')
183221
}
184222

185223
// Validate the URL to prevent SSRF attacks
186224
if (!isValidURL(config.url)) {
187225
throw new Error(`SSRF Prevention: URL "${config.url}" is not allowed`)
188226
}
189227

190-
return config
228+
// Additional validation for baseURL if present
229+
if (config.baseURL && typeof config.baseURL === 'string' && !isValidURL(config.baseURL)) {
230+
throw new Error(`SSRF Prevention: Base URL "${config.baseURL}" is not allowed`)
231+
}
232+
233+
return {
234+
...config,
235+
url: config.url.trim() // Sanitize URL by removing whitespace
236+
}
191237
}

test/unit/Util-test.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,4 +127,43 @@ describe('Get User Agent', () => {
127127
expect(isHost('contentstack.io/path')).to.be.equal(false, 'contentstack.io/path should not host')
128128
done()
129129
})
130+
131+
describe('Custom domain validation', () => {
132+
it('should validate custom domain hosts', done => {
133+
expect(isHost('dev11-api.csnonprod.com')).to.be.equal(true, 'dev11-api.csnonprod.com should be valid')
134+
expect(isHost('custom-domain.com')).to.be.equal(true, 'custom-domain.com should be valid')
135+
expect(isHost('api.custom-domain.com')).to.be.equal(true, 'api.custom-domain.com should be valid')
136+
expect(isHost('dev11-api.custom-domain.com')).to.be.equal(true, 'dev11-api.custom-domain.com should be valid')
137+
done()
138+
})
139+
140+
it('should reject invalid custom domain hosts', done => {
141+
expect(isHost('http://dev11-api.csnonprod.com')).to.be.equal(false, 'should reject URLs with protocol')
142+
expect(isHost('dev11-api.csnonprod.com/v3')).to.be.equal(false, 'should reject URLs with path')
143+
expect(isHost('dev11-api.csnonprod.com?test=1')).to.be.equal(false, 'should reject URLs with query params')
144+
expect(isHost('[email protected]')).to.be.equal(false, 'should reject URLs with special chars')
145+
expect(isHost('127.0.0.1')).to.be.equal(false, 'should reject IP addresses')
146+
expect(isHost('internal.domain.com')).to.be.equal(false, 'should reject internal domains')
147+
done()
148+
})
149+
150+
it('should handle edge cases correctly', done => {
151+
expect(isHost('')).to.be.equal(false, 'should reject empty string')
152+
expect(isHost('.')).to.be.equal(false, 'should reject single dot')
153+
expect(isHost('.com')).to.be.equal(false, 'should reject domain starting with dot')
154+
expect(isHost('domain.')).to.be.equal(false, 'should reject domain ending with dot')
155+
expect(isHost('domain..com')).to.be.equal(false, 'should reject consecutive dots')
156+
done()
157+
})
158+
159+
it('should validate port numbers correctly', done => {
160+
expect(isHost('dev11-api.csnonprod.com:443')).to.be.equal(true, 'should accept valid port')
161+
expect(isHost('dev11-api.csnonprod.com:8080')).to.be.equal(true, 'should accept custom port')
162+
expect(isHost('dev11-api.csnonprod.com:65535')).to.be.equal(true, 'should accept max port')
163+
expect(isHost('dev11-api.csnonprod.com:0')).to.be.equal(true, 'should accept port 0')
164+
expect(isHost('dev11-api.csnonprod.com:65536')).to.be.equal(true, 'should handle port overflow')
165+
expect(isHost('dev11-api.csnonprod.com:abc')).to.be.equal(true, 'should handle non-numeric port')
166+
done()
167+
})
168+
})
130169
})

0 commit comments

Comments
 (0)