Skip to content

Commit 72012da

Browse files
committed
fix: make config deep-freeze truly recursive for all nesting levels
getConfigOptions() only froze the first level of nested plain objects (e.g., customNested was frozen but customNested.nested was still mutable). Replaced Object.freeze() with a recursive deepFreezePlain() that walks all depths, matching the documented 'deep freeze' behavior.
1 parent d2f24d4 commit 72012da

2 files changed

Lines changed: 28 additions & 8 deletions

File tree

index.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,20 @@ const exts = {
1313
response: require('./libs/response-extensions')
1414
}
1515

16+
/**
17+
* Recursively freezes a plain object and all nested plain objects.
18+
* Skips arrays, Buffers, class instances, and other non-plain types.
19+
*/
20+
function deepFreezePlain (obj) {
21+
if (obj && typeof obj === 'object' && obj.constructor === Object && !Object.isFrozen(obj)) {
22+
Object.freeze(obj)
23+
for (const key of Object.keys(obj)) {
24+
deepFreezePlain(obj[key])
25+
}
26+
}
27+
return obj
28+
}
29+
1630
module.exports = (options = {}) => {
1731
options.errorHandler =
1832
options.errorHandler ||
@@ -62,13 +76,13 @@ module.exports = (options = {}) => {
6276

6377
getConfigOptions () {
6478
const copy = { ...options }
65-
// Deep-clone + freeze nested plain objects so the user's originals
79+
// Deep-clone + deep-freeze nested plain objects so the user's originals
6680
// are not mutated as a side effect of calling getConfigOptions().
6781
for (const key of Object.keys(copy)) {
6882
const val = copy[key]
6983
if (val && typeof val === 'object' && !Array.isArray(val) &&
7084
key !== 'server' && val.constructor === Object) {
71-
copy[key] = Object.freeze(JSON.parse(JSON.stringify(val)))
85+
copy[key] = deepFreezePlain(JSON.parse(JSON.stringify(val)))
7286
}
7387
}
7488
return Object.freeze(copy)

specs/security-review-2026.test.js

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -398,29 +398,35 @@ describe('Security Review — April 2026', () => {
398398
expect(opts.server).to.equal(service.getServer())
399399
})
400400

401-
it('should freeze nested plain objects in config options', () => {
401+
it('should freeze nested plain objects in config options (all depths)', () => {
402402
const service = require('../index')({
403-
customNested: { key: 'value', nested: { inner: 'secret' } }
403+
customNested: { key: 'value', nested: { inner: 'secret', deeper: { deepest: true } } }
404404
})
405405
const opts = service.getConfigOptions()
406406

407-
// Nested plain objects should be frozen
407+
// First level
408408
expect(Object.isFrozen(opts.customNested)).to.equal(true)
409+
// Second level
410+
expect(Object.isFrozen(opts.customNested.nested)).to.equal(true)
411+
// Third level
412+
expect(Object.isFrozen(opts.customNested.nested.deeper)).to.equal(true)
409413
})
410414

411415
it('should NOT freeze user-provided original nested objects (no side effects)', () => {
412-
const myConfig = { key: 'value', nested: { inner: 'secret' } }
416+
const myConfig = { key: 'value', nested: { inner: 'secret', deeper: { deepest: true } } }
413417
const service = require('../index')({
414418
customNested: myConfig
415419
})
416420

417-
// Read config — this should NOT freeze the original
421+
// Read config — this should NOT freeze the original at any depth
418422
service.getConfigOptions()
419423

420-
// The user's original object must remain mutable
421424
expect(Object.isFrozen(myConfig)).to.equal(false)
425+
expect(Object.isFrozen(myConfig.nested)).to.equal(false)
426+
expect(Object.isFrozen(myConfig.nested.deeper)).to.equal(false)
422427
expect(() => { myConfig.key = 'updated' }).to.not.throw()
423428
expect(() => { myConfig.nested.inner = 'changed' }).to.not.throw()
429+
expect(() => { myConfig.nested.deeper.deepest = false }).to.not.throw()
424430
})
425431

426432
it('should prevent mutation of top-level config properties', () => {

0 commit comments

Comments
 (0)