Skip to content

Commit 866a5f1

Browse files
authored
Merge branch 'development' into fix/DX-3749-improve-error-msgs
2 parents e7a1f32 + 138f203 commit 866a5f1

File tree

10 files changed

+632
-102
lines changed

10 files changed

+632
-102
lines changed

.talismanrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ fileignoreconfig:
99
ignore_detectors:
1010
- filecontent
1111
- filename: package-lock.json
12-
checksum: 424e5c45fa8043c95e0da5b215279c41cbe85230f75262ec7ac9ba01520e8821
12+
checksum: 17b5bbabcc58beaa180a7fa931fc3fb407ee0e3447d47da224f60118c0a4c294
1313
- filename: .husky/pre-commit
1414
checksum: 52a664f536cf5d1be0bea19cb6031ca6e8107b45b6314fe7d47b7fad7d800632
1515
- filename: test/sanity-check/api/user-test.js

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## [v1.27.1](https://github.com/contentstack/contentstack-management-javascript/tree/v1.27.1) (2026-01-5)
4+
- Fix
5+
- Resolve qs dependency version
6+
37
## [v1.27.0](https://github.com/contentstack/contentstack-management-javascript/tree/v1.27.0) (2025-12-15)
48
- Enhancement
59
- Refactored region endpoint resolution to use centralized `@contentstack/utils` package

lib/contentstack.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,29 @@ import { getContentstackEndpoint } from '@contentstack/utils'
168168
* const client = contentstack.client({ region: 'eu' })
169169
*
170170
* @prop {string=} params.feature - Feature identifier for user agent header
171+
* @prop {Array<Object>=} params.plugins - Optional array of plugin objects. Each plugin must have `onRequest` and `onResponse` methods.
172+
* @example //Set plugins to intercept and modify requests/responses
173+
* import * as contentstack from '@contentstack/management'
174+
* const client = contentstack.client({
175+
* plugins: [
176+
* {
177+
* onRequest: (request) => {
178+
* // Return modified request
179+
* return {
180+
* ...request,
181+
* headers: {
182+
* ...request.headers,
183+
* 'X-Custom-Header': 'value'
184+
* }
185+
* }
186+
* },
187+
* onResponse: (response) => {
188+
* // Return modified response
189+
* return response
190+
* }
191+
* }
192+
* ]
193+
* })
171194
* @returns {ContentstackClient} Instance of ContentstackClient
172195
*/
173196
export function client (params = {}) {

lib/core/Util.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,3 +237,26 @@ export const validateAndSanitizeConfig = (config) => {
237237
url: config.url.trim() // Sanitize URL by removing whitespace
238238
}
239239
}
240+
241+
/**
242+
* Normalizes and validates plugin array
243+
* @param {Array|undefined} plugins - Array of plugin objects
244+
* @returns {Array} Normalized array of plugins
245+
*/
246+
export function normalizePlugins (plugins) {
247+
if (!plugins) {
248+
return []
249+
}
250+
251+
if (!Array.isArray(plugins)) {
252+
return []
253+
}
254+
255+
return plugins.filter(plugin => {
256+
if (!plugin || typeof plugin !== 'object') {
257+
return false
258+
}
259+
// Plugin must have both onRequest and onResponse methods
260+
return typeof plugin.onRequest === 'function' && typeof plugin.onResponse === 'function'
261+
})
262+
}

lib/core/concurrency-queue.js

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ const defaultConfig = {
2121

2222
/**
2323
* Creates a concurrency queue manager for Axios requests with retry logic and rate limiting.
24+
* SECURITY NOTICE - SSRF Prevention (CWE-918):
25+
* This module implements comprehensive Server-Side Request Forgery (SSRF) protection.
26+
* All axios requests are validated using validateAndSanitizeConfig() which:
27+
* - Restricts requests to approved Contentstack domains only
28+
* - Blocks private IP addresses and internal network access
29+
* - Enforces HTTP/HTTPS protocols only (blocks file://, ftp://, etc.)
30+
* - Validates both URL and baseURL configurations
31+
* - Prevents URL injection attacks through proper sanitization
2432
* @param {Object} options - Configuration options.
2533
* @param {Object} options.axios - Axios instance to manage.
2634
* @param {Object=} options.config - Queue configuration options.
@@ -71,6 +79,30 @@ export function ConcurrencyQueue ({ axios, config }) {
7179
this.running = []
7280
this.paused = false
7381

82+
// SECURITY: Safe axios wrapper that always validates configs to prevent SSRF (CWE-918)
83+
// This ensures ALL axios requests are validated before execution
84+
const safeAxiosRequest = (requestConfig) => {
85+
// Validate and sanitize to prevent SSRF attacks (CWE-918)
86+
// This function throws an error if the URL is not allowed
87+
const sanitized = validateAndSanitizeConfig(requestConfig)
88+
89+
// Additional runtime check: Ensure URL has been validated
90+
if (!sanitized || !sanitized.url) {
91+
throw new Error('Invalid request: URL validation failed')
92+
}
93+
94+
// SECURITY: The axios call below is safe because validateAndSanitizeConfig ensures:
95+
// 1. Only approved Contentstack domains are allowed
96+
// 2. Private IP addresses are blocked
97+
// 3. Only HTTP/HTTPS protocols are permitted
98+
// 4. URL injection attacks are prevented
99+
//
100+
// This axios call is protected by validateAndSanitizeConfig above which validates
101+
// all URLs against SSRF attacks. The function throws an error for any disallowed URLs.
102+
// deepcode ignore Ssrf: URL is validated and sanitized by validateAndSanitizeConfig before use
103+
return axios(sanitized)
104+
}
105+
74106
// Helper function to determine if an error is a transient network failure
75107
const isTransientNetworkError = (error) => {
76108
// DNS resolution failures
@@ -159,12 +191,13 @@ export function ConcurrencyQueue ({ axios, config }) {
159191
setTimeout(() => {
160192
// Keep the request in running queue to maintain maxRequests constraint
161193
// Set retry flags to ensure proper queue handling
162-
const sanitizedConfig = validateAndSanitizeConfig(updateRequestConfig(error, `Network retry ${attempt}`, delay))
163-
sanitizedConfig.retryCount = sanitizedConfig.retryCount || 0
194+
const requestConfig = updateRequestConfig(error, `Network retry ${attempt}`, delay)
195+
requestConfig.retryCount = requestConfig.retryCount || 0
164196

165197
// Use axios directly but ensure the running queue is properly managed
166198
// The request interceptor will handle this retry appropriately
167-
axios(sanitizedConfig)
199+
// SECURITY: Using safeAxiosRequest wrapper that validates against SSRF attacks
200+
safeAxiosRequest(requestConfig)
168201
.then((response) => {
169202
// On successful retry, call the original onComplete to properly clean up
170203
if (error.config.onComplete) {
@@ -316,9 +349,8 @@ export function ConcurrencyQueue ({ axios, config }) {
316349

317350
// Retry the requests that were pending due to token expiration
318351
this.running.forEach(({ request, resolve, reject }) => {
319-
// Retry the request with sanitized configuration to prevent SSRF
320-
const sanitizedConfig = validateAndSanitizeConfig(request)
321-
axios(sanitizedConfig).then(resolve).catch(reject)
352+
// SECURITY: Using safeAxiosRequest wrapper that validates against SSRF attacks
353+
safeAxiosRequest(request).then(resolve).catch(reject)
322354
})
323355
this.running = [] // Clear the running queue after retrying requests
324356
} catch (error) {
@@ -446,9 +478,8 @@ export function ConcurrencyQueue ({ axios, config }) {
446478
// Cool down the running requests
447479
delay(wait, response.status === 401)
448480
error.config.retryCount = networkError
449-
// SSRF Prevention: Validate URL before making request
450-
const sanitizedConfig = validateAndSanitizeConfig(updateRequestConfig(error, retryErrorType, wait))
451-
return axios(sanitizedConfig)
481+
// SECURITY: Using safeAxiosRequest wrapper that validates against SSRF attacks
482+
return safeAxiosRequest(updateRequestConfig(error, retryErrorType, wait))
452483
}
453484
if (this.config.retryCondition && this.config.retryCondition(error)) {
454485
retryErrorType = error.response ? `Error with status: ${response.status}` : `Error Code:${error.code}`
@@ -478,9 +509,8 @@ export function ConcurrencyQueue ({ axios, config }) {
478509
error.config.retryCount = retryCount
479510
return new Promise(function (resolve) {
480511
return setTimeout(function () {
481-
// SSRF Prevention: Validate URL before making request
482-
const sanitizedConfig = validateAndSanitizeConfig(updateRequestConfig(error, retryErrorType, delaytime))
483-
return resolve(axios(sanitizedConfig))
512+
// SECURITY: Using safeAxiosRequest wrapper that validates against SSRF attacks
513+
return resolve(safeAxiosRequest(updateRequestConfig(error, retryErrorType, delaytime)))
484514
}, delaytime)
485515
})
486516
}

lib/core/contentstackHTTPClient.js

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import axios from 'axios'
22
import clonedeep from 'lodash/cloneDeep'
33
import Qs from 'qs'
44
import { ConcurrencyQueue } from './concurrency-queue'
5-
import { isHost } from './Util'
5+
import { isHost, normalizePlugins } from './Util'
66
import { ERROR_MESSAGES } from './errorMessages'
77

88
export default function contentstackHttpClient (options) {
@@ -110,6 +110,11 @@ export default function contentstackHttpClient (options) {
110110
const instance = axios.create(axiosOptions)
111111
instance.httpClientParams = options
112112
instance.concurrencyQueue = new ConcurrencyQueue({ axios: instance, config })
113+
114+
// Normalize and store plugins
115+
const plugins = normalizePlugins(config.plugins)
116+
117+
// Request interceptor for versioning strategy (must run first)
113118
instance.interceptors.request.use((request) => {
114119
if (request.versioningStrategy && request.versioningStrategy === 'path') {
115120
request.baseURL = request.baseURL.replace('{api-version}', version)
@@ -118,5 +123,117 @@ export default function contentstackHttpClient (options) {
118123
}
119124
return request
120125
})
126+
127+
// Request interceptor for plugins (runs after versioning)
128+
if (plugins.length > 0) {
129+
instance.interceptors.request.use(
130+
(request) => {
131+
// Run all onRequest hooks sequentially, using return values
132+
let currentRequest = request
133+
for (const plugin of plugins) {
134+
try {
135+
if (typeof plugin.onRequest === 'function') {
136+
const result = plugin.onRequest(currentRequest)
137+
// Use returned value if provided, otherwise use current request
138+
if (result !== undefined) {
139+
currentRequest = result
140+
}
141+
}
142+
} catch (error) {
143+
// Log error and continue with next plugin
144+
if (config.logHandler) {
145+
config.logHandler('error', {
146+
name: 'PluginError',
147+
message: `Error in plugin onRequest: ${error.message}`,
148+
error: error
149+
})
150+
}
151+
}
152+
}
153+
return currentRequest
154+
},
155+
(error) => {
156+
// Handle request errors - run plugins even on error
157+
let currentConfig = error.config
158+
for (const plugin of plugins) {
159+
try {
160+
if (typeof plugin.onRequest === 'function' && currentConfig) {
161+
const result = plugin.onRequest(currentConfig)
162+
// Use returned value if provided, otherwise use current config
163+
if (result !== undefined) {
164+
currentConfig = result
165+
error.config = currentConfig
166+
}
167+
}
168+
} catch (pluginError) {
169+
if (config.logHandler) {
170+
config.logHandler('error', {
171+
name: 'PluginError',
172+
message: `Error in plugin onRequest (error handler): ${pluginError.message}`,
173+
error: pluginError
174+
})
175+
}
176+
}
177+
}
178+
return Promise.reject(error)
179+
}
180+
)
181+
182+
// Response interceptor for plugins
183+
instance.interceptors.response.use(
184+
(response) => {
185+
// Run all onResponse hooks sequentially for successful responses
186+
// Use return values from plugins
187+
let currentResponse = response
188+
for (const plugin of plugins) {
189+
try {
190+
if (typeof plugin.onResponse === 'function') {
191+
const result = plugin.onResponse(currentResponse)
192+
// Use returned value if provided, otherwise use current response
193+
if (result !== undefined) {
194+
currentResponse = result
195+
}
196+
}
197+
} catch (error) {
198+
// Log error and continue with next plugin
199+
if (config.logHandler) {
200+
config.logHandler('error', {
201+
name: 'PluginError',
202+
message: `Error in plugin onResponse: ${error.message}`,
203+
error: error
204+
})
205+
}
206+
}
207+
}
208+
return currentResponse
209+
},
210+
(error) => {
211+
// Handle response errors - run plugins even on error
212+
// Pass the error object (which may contain error.response if server responded)
213+
let currentError = error
214+
for (const plugin of plugins) {
215+
try {
216+
if (typeof plugin.onResponse === 'function') {
217+
const result = plugin.onResponse(currentError)
218+
// Use returned value if provided, otherwise use current error
219+
if (result !== undefined) {
220+
currentError = result
221+
}
222+
}
223+
} catch (pluginError) {
224+
if (config.logHandler) {
225+
config.logHandler('error', {
226+
name: 'PluginError',
227+
message: `Error in plugin onResponse (error handler): ${pluginError.message}`,
228+
error: pluginError
229+
})
230+
}
231+
}
232+
}
233+
return Promise.reject(currentError)
234+
}
235+
)
236+
}
237+
121238
return instance
122239
}

0 commit comments

Comments
 (0)