Skip to content

Commit 640b63b

Browse files
committed
refactor: extract deepFreezeObject into utils, cache frozenConfig, improve prod error messages
- Move recursive deep-freeze logic into libs/utils.js as deepFreezeObject() — deep-clones, recursively freezes all nesting levels, safe for non-plain values (pass-through) - Cache frozen config in getConfigOptions() after first call to avoid re-cloning and re-freezing on every invocation - Use http.STATUS_CODES in parseErr() production mode so error messages match the status code (e.g. 404 → 'Not Found', 502 → 'Bad Gateway') instead of always 'Internal Server Error' - Fix documented default errorHandler to match actual statusCode computation instead of undefined 'code' variable
1 parent a26cfe5 commit 640b63b

5 files changed

Lines changed: 45 additions & 34 deletions

File tree

docs/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ Optionally, learn through examples:
8080
- `server`: Allows to optionally override the HTTP server instance to be used.
8181
- `prioRequestsProcessing`: If `TRUE`, HTTP requests processing/handling is prioritized using `setImmediate`. Default value: `TRUE`
8282
- `defaultRoute`: Optional route handler when no route match occurs. Default value: `((req, res) => res.send(404))`
83-
- `errorHandler`: Optional global error handler function. Default value: `(err, req, res) => res.send({ code, message: 'Internal Server Error' }, code)`. The default handler returns a generic error message to prevent leaking sensitive internal details (e.g. database connection strings, file paths, stack traces). The appropriate HTTP status code is still preserved from `err.status`, `err.code`, or `err.statusCode`.
83+
- `errorHandler`: Optional global error handler function. Default value: `(err, req, res) => { const statusCode = typeof (err.status || err.code || err.statusCode) === 'number' ? (err.status || err.code || err.statusCode) : 500; res.send({ code: statusCode, message: 'Internal Server Error' }, statusCode) }`. The default handler returns a generic error message to prevent leaking sensitive internal details (e.g. database connection strings, file paths, stack traces). The appropriate HTTP status code is still preserved from `err.status`, `err.code`, or `err.statusCode`.
8484
- `routerCacheSize`: The router matching cache size, indicates how many request matches will be kept in memory. Default value: `2000`
8585
- `enableTrace`: When `TRUE`, the `TRACE` HTTP method handler is available for debugging purposes. Default value: `FALSE`. ⚠️ Not recommended for production deployments.
8686
- `securityHeaders`: When `TRUE`, default security headers are set on every response. Set to `FALSE` to disable (e.g. when using Helmet or serving non-browser clients). Default value: `TRUE`.

index.js

Lines changed: 12 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,12 @@
88

99
const requestRouter = require('./libs/request-router')
1010
const applySecurityHeaders = require('./libs/security-headers')
11-
const { deepObjectClone } = require('./libs/utils')
11+
const { deepFreezeObject } = require('./libs/utils')
1212
const exts = {
1313
request: {},
1414
response: require('./libs/response-extensions')
1515
}
1616

17-
/**
18-
* Recursively freezes a plain object and all nested plain objects.
19-
* Skips arrays, Buffers, class instances, and other non-plain types.
20-
*/
21-
function deepFreezePlain (obj) {
22-
if (obj && typeof obj === 'object' && obj.constructor === Object && !Object.isFrozen(obj)) {
23-
Object.freeze(obj)
24-
for (const key of Object.keys(obj)) {
25-
deepFreezePlain(obj[key])
26-
}
27-
}
28-
return obj
29-
}
30-
3117
module.exports = (options = {}) => {
3218
options.errorHandler =
3319
options.errorHandler ||
@@ -63,6 +49,7 @@ module.exports = (options = {}) => {
6349
}
6450

6551
const service = handle
52+
let frozenConfig = null
6653

6754
const service_ = {
6855
errorHandler: options.errorHandler,
@@ -76,17 +63,18 @@ module.exports = (options = {}) => {
7663
},
7764

7865
getConfigOptions () {
79-
const copy = { ...options }
80-
// Deep-clone + deep-freeze nested plain objects so the user's originals
81-
// are not mutated as a side effect of calling getConfigOptions().
82-
for (const key of Object.keys(copy)) {
83-
const val = copy[key]
84-
if (val && typeof val === 'object' && !Array.isArray(val) &&
85-
key !== 'server' && val.constructor === Object) {
86-
copy[key] = deepFreezePlain(deepObjectClone(val))
66+
if (!frozenConfig) {
67+
const copy = { ...options }
68+
// Deep-clone + deep-freeze nested plain objects so the user's originals
69+
// are not mutated as a side effect of calling getConfigOptions().
70+
for (const key of Object.keys(copy)) {
71+
if (key !== 'server') {
72+
copy[key] = deepFreezeObject(copy[key])
73+
}
8774
}
75+
frozenConfig = Object.freeze(copy)
8876
}
89-
return Object.freeze(copy)
77+
return frozenConfig
9078
},
9179

9280
handle,

libs/response-extensions.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ const stringify = obj => {
2626
return JSON.stringify(obj)
2727
}
2828

29+
const STATUS_TEXTS = require('http').STATUS_CODES
30+
2931
const beforeEnd = (res, contentType, statusCode, data) => {
3032
if (contentType) {
3133
res.setHeader(CONTENT_TYPE_HEADER, contentType)
@@ -44,7 +46,7 @@ const parseErr = error => {
4446
statusCode,
4547
data: stringify({
4648
code: statusCode,
47-
message: 'Internal Server Error'
49+
message: STATUS_TEXTS[statusCode] || 'Internal Server Error'
4850
})
4951
}
5052
}

libs/utils.js

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
'use strict'
2+
13
module.exports.forEachObject = (obj, cb) => {
24
const keys = Object.keys(obj)
35
const length = keys.length
@@ -8,13 +10,32 @@ module.exports.forEachObject = (obj, cb) => {
810
}
911

1012
/**
11-
* Creates a deep clone of a serializable plain object.
12-
* Uses JSON.parse/stringify for a clean, immutable copy.
13-
* Skips non-serializable values (functions, symbols, undefined).
13+
* Deep-clones a serializable plain object, then recursively freezes
14+
* the clone and all nested plain objects. Skips arrays, Buffers,
15+
* class instances, and other non-plain types.
16+
*
17+
* The original object is never mutated — safe to call on user-provided config.
1418
*
1519
* @param {Object} obj
16-
* @returns {Object}
20+
* @returns {Object} Deep-cloned, deeply frozen copy
1721
*/
18-
module.exports.deepObjectClone = (obj) => {
19-
return JSON.parse(JSON.stringify(obj))
22+
module.exports.deepFreezeObject = (obj) => {
23+
// Pass through non-plain values (functions, arrays, primitives, etc.)
24+
if (!obj || typeof obj !== 'object' || obj.constructor !== Object) {
25+
return obj
26+
}
27+
28+
const clone = JSON.parse(JSON.stringify(obj))
29+
30+
function freeze (val) {
31+
if (val && typeof val === 'object' && val.constructor === Object && !Object.isFrozen(val)) {
32+
Object.freeze(val)
33+
for (const key of Object.keys(val)) {
34+
freeze(val[key])
35+
}
36+
}
37+
return val
38+
}
39+
40+
return freeze(clone)
2041
}

specs/security-review-2026.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,8 +250,8 @@ describe('Security Review — April 2026', () => {
250250
.get('/leak')
251251
.expect(502)
252252

253-
// In production, the actual error message must be masked
254-
expect(res.body.message).to.equal('Internal Server Error')
253+
// In production, the actual error message must be masked with a generic status text
254+
expect(res.body.message).to.equal('Bad Gateway')
255255
expect(res.body.message).to.not.include('ECONNREFUSED')
256256
expect(res.body.message).to.not.include('database.internal')
257257
})

0 commit comments

Comments
 (0)