Skip to content

Commit c13c829

Browse files
authored
Merge pull request #120 from BackendStack21/fix/security-audit
Security hardening: safe error defaults, stream handling, immutable config, and promise depth cap
2 parents 2b51eab + fb7d778 commit c13c829

7 files changed

Lines changed: 371 additions & 16 deletions

File tree

.github/workflows/tests.yaml

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@ on: [push, pull_request]
44
jobs:
55
testing:
66
runs-on: ubuntu-latest
7+
strategy:
8+
matrix:
9+
node-version: [20.x, 22.x]
710
steps:
8-
- uses: actions/checkout@v1
9-
- name: Setup Environment (Using NodeJS 16.x)
10-
uses: actions/setup-node@v1
11+
- uses: actions/checkout@v4
12+
- name: Setup Environment (Using NodeJS ${{ matrix.node-version }})
13+
uses: actions/setup-node@v4
1114
with:
12-
node-version: 16.x
15+
node-version: ${{ matrix.node-version }}
1316

1417
- name: Install dependencies
1518
run: npm install

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,5 +57,11 @@ service.get('/hi', (req, res) => res.send('Hello World!'))
5757
http.createServer(service).listen(3000, '0.0.0.0')
5858
```
5959

60+
# Security Defaults
61+
Restana ships with secure defaults out of the box:
62+
- **Error handling**: The default error handler returns a generic `Internal Server Error` message, preventing internal details (stack traces, database errors, file paths) from leaking to clients. Provide a custom `errorHandler` to control what gets exposed.
63+
- **Stream safety**: Stream errors are handled gracefully, preventing connection leaks.
64+
- **Immutable config**: `getConfigOptions()` returns a frozen copy, preventing middleware from mutating internal framework options.
65+
6066
# More
6167
- Website and documentation: https://restana.21no.de

docs/README.md

Lines changed: 22 additions & 3 deletions
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(err)`
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`.
8484
- `routerCacheSize`: The router matching cache size, indicates how many request matches will be kept in memory. Default value: `2000`
8585

8686
# Full service example
@@ -136,6 +136,12 @@ service.start(3000).then((server) => {})
136136
service.close().then(()=> {})
137137
```
138138

139+
## Accessing configuration options
140+
```js
141+
const opts = service.getConfigOptions()
142+
```
143+
> `getConfigOptions()` returns a frozen shallow copy of the configuration options. This prevents third-party middleware from accidentally or maliciously modifying internal framework options at runtime.
144+
139145
## Async / Await support
140146
```js
141147
service.post('/star/:username', async (req, res) => {
@@ -163,8 +169,8 @@ Supported datatypes are:
163169
- String
164170
- Buffer
165171
- Object
166-
- Stream
167-
- Promise
172+
- Stream (errors on the stream are handled gracefully, terminating the response instead of leaving the connection hanging)
173+
- Promise (recursive promise resolution is capped at a depth of 3 to prevent event loop starvation)
168174

169175
Example usage:
170176
```js
@@ -192,6 +198,9 @@ res.send(
192198
> `res.send(401)`
193199
194200
## Global error handling
201+
By default, restana returns a generic `Internal Server Error` message to the client, preventing internal details from being leaked. The HTTP status code is preserved from `err.status`, `err.code`, or `err.statusCode` (defaults to `500`).
202+
203+
To customize error responses, provide your own `errorHandler`:
195204
```js
196205
const service = require('restana')({
197206
errorHandler (err, req, res) {
@@ -204,6 +213,7 @@ service.get('/throw', (req, res) => {
204213
throw new Error('Upps!')
205214
})
206215
```
216+
> **Note:** When using `res.send(err)` in a custom error handler, the error's `message` and `data` properties will be serialized and sent to the client. Make sure your custom handler only exposes information you intend to be public.
207217
### errorHandler not being called?
208218
> Issue: https://github.com/jkyberneees/ana/issues/81
209219
@@ -457,6 +467,15 @@ service.get('/hello', (req, res) => {
457467
https://goo.gl/forms/qlBwrf5raqfQwteH3
458468

459469
# Breaking changes
470+
## 5.2
471+
> Restana version 5.2 includes important security hardening while remaining backward compatible for most users.
472+
473+
Changed:
474+
- The default `errorHandler` no longer sends `err.message` or `err.data` to clients. It now returns a generic `{ code, message: 'Internal Server Error' }` response. If you need the previous behavior, provide a custom `errorHandler`.
475+
- `getConfigOptions()` now returns a frozen shallow copy of the options object instead of a direct mutable reference.
476+
- Stream responses (`res.send(stream)`) now handle stream errors gracefully, terminating the response instead of leaving the connection hanging.
477+
- Promise resolution in `res.send()` is now capped at a depth of 3 to prevent event loop starvation from deeply nested promise chains.
478+
460479
## 4.x
461480
> Restana version 4.x is much more simple to maintain, mature and faster!
462481

index.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ module.exports = (options = {}) => {
1616
options.errorHandler =
1717
options.errorHandler ||
1818
((err, req, res) => {
19-
res.send(err)
19+
const statusCode = typeof (err.status || err.code || err.statusCode) === 'number'
20+
? (err.status || err.code || err.statusCode)
21+
: 500
22+
res.send({ code: statusCode, message: 'Internal Server Error' }, statusCode)
2023
})
2124

2225
const server = options.server || require('http').createServer()
@@ -52,7 +55,7 @@ module.exports = (options = {}) => {
5255
},
5356

5457
getConfigOptions () {
55-
return options
58+
return Object.freeze({ ...options })
5659
},
5760

5861
handle,

libs/response-extensions.js

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,10 @@ const parseErr = error => {
3838
* The friendly 'res.send' method
3939
* No comments needed ;)
4040
*/
41+
const MAX_PROMISE_DEPTH = 3
42+
4143
module.exports.send = (options, req, res) => {
42-
const send = (data = res.statusCode, code = res.statusCode, headers = null, cb = NOOP) => {
44+
const send = (data = res.statusCode, code = res.statusCode, headers = null, cb = NOOP, _promiseDepth = 0) => {
4345
let contentType
4446

4547
if (data instanceof Error) {
@@ -76,13 +78,22 @@ module.exports.send = (options, req, res) => {
7678

7779
data.pipe(res)
7880
data.on('end', cb)
81+
data.on('error', () => {
82+
res.end(cb)
83+
})
7984

8085
return
8186
} else if (Promise.resolve(data) === data) { // http://www.ecma-international.org/ecma-262/6.0/#sec-promise.resolve
82-
headers = null
83-
return data
84-
.then(resolved => send(resolved, code, headers, cb))
85-
.catch(err => send(err, code, headers, cb))
87+
if (_promiseDepth >= MAX_PROMISE_DEPTH) {
88+
data = stringify({ code: 500, message: 'Internal Server Error' })
89+
contentType = TYPE_JSON
90+
code = 500
91+
} else {
92+
headers = null
93+
return data
94+
.then(resolved => send(resolved, code, headers, cb, _promiseDepth + 1))
95+
.catch(err => send(err, code, headers, cb, _promiseDepth + 1))
96+
}
8697
} else {
8798
if (!contentType) contentType = TYPE_JSON
8899
data = stringify(data)

0 commit comments

Comments
 (0)