|
1 | 1 | 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 |
3 | 3 |
|
4 | 4 | export function isHost (host) {
|
| 5 | + if (!host) return false |
5 | 6 | return HOST_REGEX.test(host)
|
6 | 7 | }
|
7 | 8 |
|
@@ -100,3 +101,137 @@ export default function getUserAgent (sdk, application, integration, feature) {
|
100 | 101 |
|
101 | 102 | return `${headerParts.filter((item) => item !== '').join('; ')};`
|
102 | 103 | }
|
| 104 | + |
| 105 | +// URL validation functions to prevent SSRF attacks |
| 106 | +const isValidURL = (url) => { |
| 107 | + try { |
| 108 | + // Reject obviously malicious patterns early |
| 109 | + if (url.includes('@') || url.includes('file://') || url.includes('ftp://')) { |
| 110 | + return false |
| 111 | + } |
| 112 | + |
| 113 | + // Allow relative URLs (they are safe as they use the same origin) |
| 114 | + if (url.startsWith('/') || url.startsWith('./') || url.startsWith('../')) { |
| 115 | + return true |
| 116 | + } |
| 117 | + |
| 118 | + // Only validate absolute URLs for SSRF protection |
| 119 | + const parsedURL = new URL(url) |
| 120 | + |
| 121 | + // Reject non-HTTP(S) protocols |
| 122 | + if (!['http:', 'https:'].includes(parsedURL.protocol)) { |
| 123 | + return false |
| 124 | + } |
| 125 | + |
| 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 | + |
| 141 | + // Prevent IP addresses in URLs to avoid internal network access |
| 142 | + const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/ |
| 143 | + const ipv6Regex = /^\[?([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}\]?$/ |
| 144 | + if (ipv4Regex.test(parsedURL.hostname) || ipv6Regex.test(parsedURL.hostname)) { |
| 145 | + // Only allow localhost IPs in development |
| 146 | + const isDevelopment = process.env.NODE_ENV === 'development' || |
| 147 | + process.env.NODE_ENV === 'test' || |
| 148 | + !process.env.NODE_ENV |
| 149 | + const localhostIPs = ['127.0.0.1', '0.0.0.0', '::1', 'localhost'] |
| 150 | + if (!isDevelopment || !localhostIPs.includes(parsedURL.hostname)) { |
| 151 | + return false |
| 152 | + } |
| 153 | + } |
| 154 | + |
| 155 | + return isAllowedHost(parsedURL.hostname) |
| 156 | + } catch { |
| 157 | + // If URL parsing fails, it might be a relative URL without protocol |
| 158 | + // Allow it if it doesn't contain protocol indicators or suspicious patterns |
| 159 | + return !url?.includes('://') && !url?.includes('\\') && !url?.includes('@') |
| 160 | + } |
| 161 | +} |
| 162 | + |
| 163 | +const isAllowedHost = (hostname) => { |
| 164 | + // Define allowed domains for Contentstack API |
| 165 | + // Official Contentstack domains |
| 166 | + const allowedDomains = [ |
| 167 | + 'api.contentstack.io', |
| 168 | + 'eu-api.contentstack.com', |
| 169 | + 'azure-na-api.contentstack.com', |
| 170 | + 'azure-eu-api.contentstack.com', |
| 171 | + 'gcp-na-api.contentstack.com', |
| 172 | + 'gcp-eu-api.contentstack.com' |
| 173 | + ] |
| 174 | + |
| 175 | + // Check for localhost/development environments |
| 176 | + const localhostPatterns = [ |
| 177 | + 'localhost', |
| 178 | + '127.0.0.1', |
| 179 | + '0.0.0.0' |
| 180 | + ] |
| 181 | + |
| 182 | + // Only allow localhost in development environments to prevent SSRF in production |
| 183 | + const isDevelopment = process.env.NODE_ENV === 'development' || |
| 184 | + process.env.NODE_ENV === 'test' || |
| 185 | + !process.env.NODE_ENV // Default to allowing in non-production if NODE_ENV is not set |
| 186 | + |
| 187 | + if (isDevelopment && localhostPatterns.includes(hostname)) { |
| 188 | + return true |
| 189 | + } |
| 190 | + |
| 191 | + // Check if hostname is in allowed domains or is a subdomain of allowed domains |
| 192 | + const isContentstackDomain = allowedDomains.some(domain => { |
| 193 | + return hostname === domain || hostname.endsWith('.' + domain) |
| 194 | + }) |
| 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 |
| 216 | +} |
| 217 | + |
| 218 | +export const validateAndSanitizeConfig = (config) => { |
| 219 | + if (!config?.url || typeof config?.url !== 'string') { |
| 220 | + throw new Error('Invalid request configuration: missing or invalid URL') |
| 221 | + } |
| 222 | + |
| 223 | + // Validate the URL to prevent SSRF attacks |
| 224 | + if (!isValidURL(config.url)) { |
| 225 | + throw new Error(`SSRF Prevention: URL "${config.url}" is not allowed`) |
| 226 | + } |
| 227 | + |
| 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 | + } |
| 237 | +} |
0 commit comments