From 345141c39512abf916ac4186617fcc224b0e661f Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 19 Jul 2026 17:00:09 +0200 Subject: [PATCH] fix: validate error-derived status codes to prevent process crash (DoS) A thrown error carrying a numeric status/code/statusCode outside the valid HTTP range (100-999) was passed straight to res.statusCode, which throws 'RangeError: Invalid status code' inside the error handler. The exception propagated as an uncaughtException and terminated the process, turning a single request into a full outage (e.g. err.code = 99 as set by some libraries). Add toHttpStatusCode() in libs/utils.js and apply it at all three status-code sinks: the default error handler (index.js), parseErr and the res.send(data, code) path via beforeEnd (libs/response-extensions.js). Regression tests: specs/security.test.js SEC-005/SEC-005b cover out-of-range, oversized and non-integer codes for both custom and default error handlers, plus a server-liveness check. --- index.js | 6 +-- libs/response-extensions.js | 7 ++- libs/utils.js | 13 +++++ specs/security.test.js | 104 ++++++++++++++++++++++++++++++++++++ 4 files changed, 122 insertions(+), 8 deletions(-) diff --git a/index.js b/index.js index 99c3935..1643071 100644 --- a/index.js +++ b/index.js @@ -8,7 +8,7 @@ const requestRouter = require('./libs/request-router') const applySecurityHeaders = require('./libs/security-headers') -const { deepFreezeObject } = require('./libs/utils') +const { deepFreezeObject, toHttpStatusCode } = require('./libs/utils') const exts = { request: {}, response: require('./libs/response-extensions') @@ -18,9 +18,7 @@ module.exports = (options = {}) => { options.errorHandler = options.errorHandler || ((err, req, res) => { - const statusCode = typeof (err.status || err.code || err.statusCode) === 'number' - ? (err.status || err.code || err.statusCode) - : 500 + const statusCode = toHttpStatusCode(err.status || err.code || err.statusCode) res.send({ code: statusCode, message: 'Internal Server Error' }, statusCode) }) diff --git a/libs/response-extensions.js b/libs/response-extensions.js index ba1a4c8..741a01f 100644 --- a/libs/response-extensions.js +++ b/libs/response-extensions.js @@ -1,6 +1,6 @@ 'use strict' -const { forEachObject } = require('./utils') +const { forEachObject, toHttpStatusCode } = require('./utils') const CONTENT_TYPE_HEADER = 'content-type' const TYPE_JSON = 'application/json; charset=utf-8' @@ -32,14 +32,13 @@ const beforeEnd = (res, contentType, statusCode, data) => { if (contentType) { res.setHeader(CONTENT_TYPE_HEADER, contentType) } - res.statusCode = statusCode + res.statusCode = toHttpStatusCode(statusCode, res.statusCode) } const isProduction = () => process.env.NODE_ENV === 'production' const parseErr = error => { - const errorCode = error.status || error.code || error.statusCode - const statusCode = typeof errorCode === 'number' ? errorCode : 500 + const statusCode = toHttpStatusCode(error.status || error.code || error.statusCode) if (isProduction()) { return { diff --git a/libs/utils.js b/libs/utils.js index c3ccb0b..942172f 100644 --- a/libs/utils.js +++ b/libs/utils.js @@ -1,5 +1,18 @@ 'use strict' +/** + * Coerces a value into a valid HTTP status code. + * Returns the fallback when the value is not an integer in the 100-999 range, + * preventing `RangeError: Invalid status code` crashes on res.statusCode. + * + * @param {*} code + * @param {number} [fallback=500] + * @returns {number} + */ +module.exports.toHttpStatusCode = (code, fallback = 500) => { + return Number.isInteger(code) && code >= 100 && code <= 999 ? code : fallback +} + module.exports.forEachObject = (obj, cb) => { const keys = Object.keys(obj) const length = keys.length diff --git a/specs/security.test.js b/specs/security.test.js index a46baf6..12511be 100644 --- a/specs/security.test.js +++ b/specs/security.test.js @@ -310,4 +310,108 @@ describe('Security Fixes', () => { await service.close() }) }) + + describe('SEC-005: Out-of-range error status codes fall back to 500', () => { + let server + const service = require('../index')({ + errorHandler (err, req, res) { + res.send(err) + } + }) + + service.get('/hello', (req, res) => { + res.send('world') + }) + + service.get('/error-code-99', (req, res) => { + const err = new Error('Low code') + err.code = 99 + throw err + }) + + service.get('/error-status-huge', (req, res) => { + const err = new Error('Huge status') + err.status = 12345 + throw err + }) + + service.get('/error-float-code', (req, res) => { + const err = new Error('Float code') + err.code = 200.5 + throw err + }) + + it('should start service', async () => { + server = await service.start(~~process.env.PORT) + }) + + it('should return 500 instead of crashing when err.code is out of range', async () => { + await request(server) + .get('/error-code-99') + .expect(500) + .then((response) => { + expect(response.body.code).to.equal(500) + }) + }) + + it('should return 500 instead of crashing when err.status exceeds 999', async () => { + await request(server) + .get('/error-status-huge') + .expect(500) + .then((response) => { + expect(response.body.code).to.equal(500) + }) + }) + + it('should return 500 instead of crashing when err.code is not an integer', async () => { + await request(server) + .get('/error-float-code') + .expect(500) + .then((response) => { + expect(response.body.code).to.equal(500) + }) + }) + + it('server should still be alive and responsive after out-of-range errors', async () => { + await request(server) + .get('/hello') + .expect(200) + .then((response) => { + expect(response.text).to.equal('world') + }) + }) + + it('should successfully terminate the service', async () => { + await service.close() + }) + }) + + describe('SEC-005b: Default error handler also survives out-of-range codes', () => { + let server + const service = require('../index')() + + service.get('/error-bad-code', (req, res) => { + const err = new Error('boom') + err.code = 99 + throw err + }) + + it('should start service', async () => { + server = await service.start(~~process.env.PORT) + }) + + it('should return 500 with generic message and keep the process alive', async () => { + await request(server) + .get('/error-bad-code') + .expect(500) + .then((response) => { + expect(response.body.code).to.equal(500) + expect(response.body.message).to.equal('Internal Server Error') + }) + }) + + it('should successfully terminate the service', async () => { + await service.close() + }) + }) })