Skip to content

Commit 946eef7

Browse files
Merge pull request #410 from contentstack/staging
DX | 04-08-2025 | Release
2 parents 874befc + 40906b7 commit 946eef7

File tree

11 files changed

+969
-2557
lines changed

11 files changed

+969
-2557
lines changed

.talismanrc

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,29 @@
11
fileignoreconfig:
2+
- filename: test/unit/globalField-test.js
3+
checksum: 25185e3400a12e10a043dc47502d8f30b7e1c4f2b6b4d3b8b55cdc19850c48bf
4+
- filename: lib/stack/index.js
5+
checksum: 6aab5edf85efb17951418b4dc4402889cd24c8d786c671185074aeb4d50f0242
6+
- filename: test/sanity-check/api/stack-test.js
7+
checksum: 198d5cf7ead33b079249dc3ecdee61a9c57453e93f1073ed0341400983e5aa53
8+
- filename: .github/workflows/secrets-scan.yml
9+
ignore_detectors:
10+
- filecontent
11+
- filename: package-lock.json
12+
checksum: b043facad4b4aca7a013730746bdb9cb9e9dfca1e5d6faf11c068fc2525569c0
213
- filename: .husky/pre-commit
314
checksum: 52a664f536cf5d1be0bea19cb6031ca6e8107b45b6314fe7d47b7fad7d800632
415
- filename: test/sanity-check/api/user-test.js
516
checksum: 6bb8251aad584e09f4d963a913bd0007e5f6e089357a44c3fb1529e3fda5509d
6-
- filename: package-lock.json
7-
checksum: b9068b76378f5cedcae28adfff14b961289b3a0ddcd026fe3d026cfd877178a4
817
- filename: lib/stack/asset/index.js
918
checksum: b3358310e9cb2fb493d70890b7219db71e2202360be764465d505ef71907eefe
10-
version: ""
19+
- filename: test/sanity-check/api/previewToken-test.js
20+
checksum: 9a42e079b7c71f76932896a0d2390d86ac626678ab20d36821dcf962820a886c
21+
- filename: lib/stack/deliveryToken/index.js
22+
checksum: 51ae00f07f4cc75c1cd832b311c2e2482f04a8467a0139da6013ceb88fbdda2f
23+
- filename: lib/stack/deliveryToken/previewToken/index.js
24+
checksum: b506f33bffdd20dfc701f964370707f5d7b28a2c05c70665f0edb7b3c53c165b
25+
- filename: examples/robust-error-handling.js
26+
checksum: e8a32ffbbbdba2a15f3d327273f0a5b4eb33cf84cd346562596ab697125bbbc6
27+
- filename: test/sanity-check/api/bulkOperation-test.js
28+
checksum: f40a14c84ab9a194aaf830ca68e14afde2ef83496a07d4a6393d7e0bed15fb0e
29+
version: "1.0"

examples/robust-error-handling.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Example: Configuring Robust Error Handling for Transient Network Failures
2+
// This example shows how to use the enhanced retry mechanisms in the Contentstack Management SDK
3+
4+
const contentstack = require('../lib/contentstack')
5+
6+
// Example 1: Basic configuration with enhanced network retry
7+
const clientWithBasicRetry = contentstack.client({
8+
api_key: 'your_api_key',
9+
management_token: 'your_management_token',
10+
// Enhanced network retry configuration
11+
retryOnNetworkFailure: true, // Enable network failure retries
12+
maxNetworkRetries: 3, // Max 3 attempts for network failures
13+
networkRetryDelay: 100, // Start with 100ms delay
14+
networkBackoffStrategy: 'exponential' // Use exponential backoff (100ms, 200ms, 400ms)
15+
})
16+
17+
// Example 2: Advanced configuration with fine-grained control
18+
const clientWithAdvancedRetry = contentstack.client({
19+
api_key: 'your_api_key',
20+
management_token: 'your_management_token',
21+
// Network failure retry settings
22+
retryOnNetworkFailure: true,
23+
retryOnDnsFailure: true, // Retry on DNS resolution failures (EAI_AGAIN)
24+
retryOnSocketFailure: true, // Retry on socket errors (ECONNRESET, ETIMEDOUT, etc.)
25+
retryOnHttpServerError: true, // Retry on HTTP 5xx errors
26+
maxNetworkRetries: 5, // Allow up to 5 network retries
27+
networkRetryDelay: 200, // Start with 200ms delay
28+
networkBackoffStrategy: 'exponential',
29+
30+
// Original retry settings (for non-network errors)
31+
retryOnError: true,
32+
retryLimit: 3,
33+
retryDelay: 500,
34+
35+
// Custom logging
36+
logHandler: (level, message) => {
37+
console.log(`[${level.toUpperCase()}] ${new Date().toISOString()}: ${message}`)
38+
}
39+
})
40+
41+
// Example 3: Conservative configuration for production
42+
const clientForProduction = contentstack.client({
43+
api_key: 'your_api_key',
44+
management_token: 'your_management_token',
45+
// Conservative retry settings for production
46+
retryOnNetworkFailure: true,
47+
maxNetworkRetries: 2, // Only 2 retries to avoid long delays
48+
networkRetryDelay: 300, // Longer initial delay
49+
networkBackoffStrategy: 'fixed', // Fixed delay instead of exponential
50+
51+
// Custom retry condition for additional control
52+
retryCondition: (error) => {
53+
// Custom logic: only retry on specific conditions
54+
return error.response && error.response.status >= 500
55+
}
56+
})
57+
58+
// Example usage with error handling
59+
async function demonstrateRobustErrorHandling () {
60+
try {
61+
const stack = clientWithAdvancedRetry.stack('your_stack_api_key')
62+
const contentTypes = await stack.contentType().query().find()
63+
console.log('Content types retrieved successfully:', contentTypes.items.length)
64+
} catch (error) {
65+
if (error.retryAttempts) {
66+
console.error(`Request failed after ${error.retryAttempts} retry attempts:`, error.message)
67+
console.error('Original error:', error.originalError?.code)
68+
} else {
69+
console.error('Request failed:', error.message)
70+
}
71+
}
72+
}
73+
74+
// The SDK will now automatically handle:
75+
// ✅ DNS resolution failures (EAI_AGAIN)
76+
// ✅ Socket errors (ECONNRESET, ETIMEDOUT, ECONNREFUSED)
77+
// ✅ HTTP timeouts (ECONNABORTED)
78+
// ✅ HTTP 5xx server errors (500-599)
79+
// ✅ Exponential backoff with configurable delays
80+
// ✅ Clear logging and user-friendly error messages
81+
82+
module.exports = {
83+
clientWithBasicRetry,
84+
clientWithAdvancedRetry,
85+
clientForProduction,
86+
demonstrateRobustErrorHandling
87+
}

lib/core/Util.js

Lines changed: 136 additions & 1 deletion
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

@@ -100,3 +101,137 @@ export default function getUserAgent (sdk, application, integration, feature) {
100101

101102
return `${headerParts.filter((item) => item !== '').join('; ')};`
102103
}
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

Comments
 (0)