Skip to content

Commit d2f0cfc

Browse files
authored
Merge pull request #121 from BackendStack21/feat/security-review-april-2026
feat: security hardening pass for v6.0.0
2 parents 51a2875 + 640b63b commit d2f0cfc

13 files changed

Lines changed: 634 additions & 16 deletions

.devcontainer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
2-
"image": "mcr.microsoft.com/devcontainers/typescript-node:0-18",
2+
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-24",
33
"forwardPorts": [3000, 8080]
44
}

.github/workflows/tests.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ jobs:
66
runs-on: ubuntu-latest
77
strategy:
88
matrix:
9-
node-version: [20.x, 22.x]
9+
node-version: [24.x]
1010
steps:
1111
- uses: actions/checkout@v4
1212
- name: Setup Environment (Using NodeJS ${{ matrix.node-version }})

.travis.yml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
language: node_js
22
node_js:
3-
- "10"
4-
- "12"
5-
- "14"
3+
- "24"
64

75
script:
86
- npx standard

docs/README.md

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,18 @@ 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`
85+
- `enableTrace`: When `TRUE`, the `TRACE` HTTP method handler is available for debugging purposes. Default value: `FALSE`. ⚠️ Not recommended for production deployments.
86+
- `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`.
87+
88+
### Security defaults (v6.0+)
89+
Restana now ships with these security hardening measures enabled by default:
90+
- **Header injection protection**: Security-sensitive and hop-by-hop headers are blocked from the `res.send()` headers parameter.
91+
- **Production error masking**: In `NODE_ENV=production`, `res.send(err)` masks the error message and strips `err.data` to prevent internal details from leaking.
92+
- **Default security headers**: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `X-XSS-Protection: 0`, and `Strict-Transport-Security` (on HTTPS) are set on every response. Disable with `securityHeaders: false`.
93+
- **TRACE method disabled by default**: Eliminates Cross-Site Tracing attack surface. Re-enable for debugging via `enableTrace: true` (not recommended in production).
94+
- **Deep frozen config**: `getConfigOptions()` now freezes nested plain objects, not just the top-level copy.
8595

8696
# Full service example
8797
```js
@@ -115,8 +125,13 @@ service.start(3000)
115125
# API and features
116126
## Supported HTTP methods:
117127
```js
118-
const methods = ['get', 'delete', 'put', 'patch', 'post', 'head', 'options', 'trace']
128+
const methods = ['get', 'delete', 'put', 'patch', 'post', 'head', 'options']
119129
```
130+
> ⚠️ `TRACE` is disabled by default in v6.0.0 to reduce attack surface (Cross-Site Tracing risk). Re-enable explicitly for debugging with `enableTrace: true` in the service constructor:
131+
> ```js
132+
> const service = restana({ enableTrace: true })
133+
> service.trace('/debug', (req, res) => res.send('Echo: ' + req.url))
134+
> ```
120135
121136
### Using .all routes registration
122137
You can also register a route handler for `all` supported HTTP methods:
@@ -140,7 +155,7 @@ service.close().then(()=> {})
140155
```js
141156
const opts = service.getConfigOptions()
142157
```
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.
158+
> `getConfigOptions()` returns a frozen copy of the configuration options. Top-level properties and nested plain objects are frozen, preventing third-party middleware from accidentally or maliciously modifying internal framework options at runtime. The `server` reference is a live object and is excluded from deep freezing.
144159
145160
## Async / Await support
146161
```js
@@ -158,6 +173,8 @@ res.send('Hello World', 200, {
158173
'x-response-time': 100
159174
})
160175
```
176+
> ⚠️ Security-sensitive and hop-by-hop headers are blocked from the `headers` parameter for security reasons:
177+
> `transfer-encoding`, `content-length`, `connection`, `keep-alive`, `host`, `set-cookie`. Use `res.setHeader()` explicitly if you need to set these.
161178
162179
## The "res.send" method
163180
Same as in express, for `restana` we have implemented a handy `send` method that extends
@@ -213,7 +230,7 @@ service.get('/throw', (req, res) => {
213230
throw new Error('Upps!')
214231
})
215232
```
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.
233+
> **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 (in non-production environments). In `NODE_ENV=production`, `res.send(err)` masks the error message and strips `err.data` to prevent internal details from leaking.
217234
### errorHandler not being called?
218235
> Issue: https://github.com/jkyberneees/ana/issues/81
219236
@@ -467,6 +484,20 @@ service.get('/hello', (req, res) => {
467484
https://goo.gl/forms/qlBwrf5raqfQwteH3
468485

469486
# Breaking changes
487+
## 6.0
488+
> Restana version 6.0 focuses on security hardening and reducing attack surface.
489+
490+
Added:
491+
- Minimum Node.js version is now v24.x (current LTS).
492+
493+
Changed:
494+
- `TRACE` HTTP method is no longer supported by default. Removed from `methods.js` to prevent Cross-Site Tracing (XST) risks. Re-enable for debugging with `{ enableTrace: true }` in the service constructor.
495+
- `res.send(data, code, headers)` now validates the `headers` parameter. Security-sensitive and hop-by-hop headers (`transfer-encoding`, `content-length`, `connection`, `keep-alive`, `host`, `set-cookie`) are silently dropped. Invalid header key characters (CRLF, newlines) are caught and skipped instead of crashing with a 500 error.
496+
- `parseErr()` now respects `NODE_ENV`. In `production`, `res.send(err)` masks `err.message` and strips `err.data` to prevent internal details from leaking. In other environments, behavior is unchanged.
497+
- `getConfigOptions()` now deep freezes nested plain objects in addition to the top-level freeze. The `server` reference is a live object and is excluded from freezing.
498+
- Default security headers are now set on every response: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `X-XSS-Protection: 0`. `Strict-Transport-Security` is set automatically when TLS is detected (via `req.socket.encrypted` or `x-forwarded-proto: https` header). Disable with `{ securityHeaders: false }`.
499+
- New `securityHeaders` option allows disabling default security headers entirely (default: `true`).
500+
470501
## 5.2
471502
> Restana version 5.2 includes important security hardening while remaining backward compatible for most users.
472503

index.js

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
*/
88

99
const requestRouter = require('./libs/request-router')
10+
const applySecurityHeaders = require('./libs/security-headers')
11+
const { deepFreezeObject } = require('./libs/utils')
1012
const exts = {
1113
request: {},
1214
response: require('./libs/response-extensions')
@@ -35,13 +37,19 @@ module.exports = (options = {}) => {
3537
}
3638

3739
const handle = (req, res) => {
40+
// Default security headers (can be overridden by application or disabled via options)
41+
if (options.securityHeaders !== false) {
42+
applySecurityHeaders(req, res)
43+
}
44+
3845
// request object population
3946
res.send = exts.response.send(options, req, res)
4047

4148
service.getRouter().lookup(req, res)
4249
}
4350

4451
const service = handle
52+
let frozenConfig = null
4553

4654
const service_ = {
4755
errorHandler: options.errorHandler,
@@ -55,7 +63,18 @@ module.exports = (options = {}) => {
5563
},
5664

5765
getConfigOptions () {
58-
return Object.freeze({ ...options })
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+
}
74+
}
75+
frozenConfig = Object.freeze(copy)
76+
}
77+
return frozenConfig
5978
},
6079

6180
handle,

libs/apm-base.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use strict'
22

3-
const methods = require('./methods')
3+
const methods = require('./methods').BASE
44

55
module.exports = (options) => {
66
const agent = options.apm || options.agent

libs/methods.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,16 @@
33
/**
44
* Supported HTTP methods
55
*/
6-
module.exports = ['get', 'delete', 'patch', 'post', 'put', 'head', 'options', 'trace', 'all']
6+
const BASE_METHODS = ['get', 'delete', 'patch', 'post', 'put', 'head', 'options', 'all']
7+
const TRACE_METHOD = 'trace'
8+
9+
module.exports = (options = {}) => {
10+
const methods = [...BASE_METHODS]
11+
if (options.enableTrace) {
12+
methods.push(TRACE_METHOD)
13+
}
14+
return methods
15+
}
16+
17+
module.exports.BASE = BASE_METHODS
18+
module.exports.TRACE = TRACE_METHOD

libs/request-router.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* @see: https://github.com/jkyberneees/0http#0http---sequential-default-router
55
*/
66
const sequential = require('0http/lib/router/sequential')
7-
const methods = require('./methods')
7+
const getMethods = require('./methods')
88
const EventEmitter = require('events')
99
const BEFORE_ROUTE_REGISTER_EVENT = 'beforeRouteRegister'
1010

@@ -43,6 +43,7 @@ module.exports = (options, service = {}) => {
4343
}
4444

4545
// attach routes registration shortcuts
46+
const methods = getMethods(options)
4647
methods.forEach((method) => {
4748
service[method] = (...args) => {
4849
if (Array.isArray(args[0])) {

libs/response-extensions.js

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,48 @@ const TYPE_OCTET = 'application/octet-stream'
99

1010
const NOOP = () => { }
1111

12+
//
13+
// Headers that MUST NOT be set via the res.send() headers parameter.
14+
// These should only be managed by the framework or explicitly via res.setHeader().
15+
//
16+
const FORBIDDEN_HEADERS = new Set([
17+
'transfer-encoding',
18+
'content-length',
19+
'connection',
20+
'keep-alive',
21+
'host',
22+
'set-cookie'
23+
])
24+
1225
const stringify = obj => {
1326
return JSON.stringify(obj)
1427
}
1528

29+
const STATUS_TEXTS = require('http').STATUS_CODES
30+
1631
const beforeEnd = (res, contentType, statusCode, data) => {
1732
if (contentType) {
1833
res.setHeader(CONTENT_TYPE_HEADER, contentType)
1934
}
2035
res.statusCode = statusCode
2136
}
2237

38+
const isProduction = () => process.env.NODE_ENV === 'production'
39+
2340
const parseErr = error => {
2441
const errorCode = error.status || error.code || error.statusCode
2542
const statusCode = typeof errorCode === 'number' ? errorCode : 500
2643

44+
if (isProduction()) {
45+
return {
46+
statusCode,
47+
data: stringify({
48+
code: statusCode,
49+
message: STATUS_TEXTS[statusCode] || 'Internal Server Error'
50+
})
51+
}
52+
}
53+
2754
return {
2855
statusCode,
2956
data: stringify({
@@ -52,7 +79,19 @@ module.exports.send = (options, req, res) => {
5279
} else {
5380
if (headers && typeof headers === 'object') {
5481
forEachObject(headers, (value, key) => {
55-
res.setHeader(key.toLowerCase(), value)
82+
// Block forbidden headers (hop-by-hop, security-sensitive)
83+
if (typeof key !== 'string' || FORBIDDEN_HEADERS.has(key.toLowerCase())) {
84+
return
85+
}
86+
// Sanitize array values — prevent header injection via arrays
87+
if (Array.isArray(value)) {
88+
return
89+
}
90+
try {
91+
res.setHeader(key.toLowerCase(), value)
92+
} catch (e) {
93+
// Silently skip invalid headers (e.g. CRLF in key or value)
94+
}
5695
})
5796
}
5897

libs/security-headers.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
'use strict'
2+
3+
/**
4+
* Applies default security headers to the response, if not already set.
5+
* Headers can be overridden by the application via res.setHeader().
6+
*
7+
* @param {import('http').IncomingMessage} req
8+
* @param {import('http').ServerResponse} res
9+
*/
10+
module.exports = (req, res) => {
11+
if (!res.getHeader('x-content-type-options')) {
12+
res.setHeader('x-content-type-options', 'nosniff')
13+
}
14+
if (!res.getHeader('x-frame-options')) {
15+
res.setHeader('x-frame-options', 'DENY')
16+
}
17+
if (!res.getHeader('x-xss-protection')) {
18+
res.setHeader('x-xss-protection', '0')
19+
}
20+
21+
// HSTS on HTTPS connections
22+
const isTLS = req.socket && req.socket.encrypted
23+
const forwardedProto = req.headers && req.headers['x-forwarded-proto']
24+
if (isTLS || forwardedProto === 'https') {
25+
if (!res.getHeader('strict-transport-security')) {
26+
res.setHeader('strict-transport-security', 'max-age=15552000; includeSubDomains')
27+
}
28+
}
29+
}

0 commit comments

Comments
 (0)