Skip to content

Commit 335f376

Browse files
committed
feat: security hardening pass for v6.0.0
- Remove TRACE HTTP method by default; add enableTrace option for debugging - Block forbidden headers (transfer-encoding, content-length, connection, keep-alive, host, set-cookie) in res.send() headers parameter - Gracefully handle invalid header key characters (CRLF, newlines) - Suppress error details in production via parseErr() NODE_ENV check - Set default security headers: X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, and Strict-Transport-Security (on HTTPS) - Add securityHeaders: false option to opt out of default headers - Extract security headers logic into reusable libs/security-headers.js - Deep-freeze nested plain objects in getConfigOptions() - Update APM base to use static methods list - Add comprehensive security review tests (38 tests) BREAKING CHANGE: TRACE method disabled by default, res.send() now validates headers, production error masking on res.send(err), and getConfigOptions() deep-freezes nested plain objects.
1 parent 51a2875 commit 335f376

9 files changed

Lines changed: 566 additions & 9 deletions

File tree

docs/README.md

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,16 @@ Optionally, learn through examples:
8282
- `defaultRoute`: Optional route handler when no route match occurs. Default value: `((req, res) => res.send(404))`
8383
- `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`
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,17 @@ 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+
Changed:
491+
- `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.
492+
- `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.
493+
- `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.
494+
- `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.
495+
- 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 }`.
496+
- New `securityHeaders` option allows disabling default security headers entirely (default: `true`).
497+
470498
## 5.2
471499
> Restana version 5.2 includes important security hardening while remaining backward compatible for most users.
472500

index.js

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

99
const requestRouter = require('./libs/request-router')
10+
const applySecurityHeaders = require('./libs/security-headers')
1011
const exts = {
1112
request: {},
1213
response: require('./libs/response-extensions')
@@ -35,6 +36,11 @@ module.exports = (options = {}) => {
3536
}
3637

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

@@ -55,7 +61,16 @@ module.exports = (options = {}) => {
5561
},
5662

5763
getConfigOptions () {
58-
return Object.freeze({ ...options })
64+
const copy = { ...options }
65+
// Deep-freeze nested plain objects (server is a live reference — exempted)
66+
for (const key of Object.keys(copy)) {
67+
const val = copy[key]
68+
if (val && typeof val === 'object' && !Array.isArray(val) &&
69+
key !== 'server' && val.constructor === Object) {
70+
Object.freeze(val)
71+
}
72+
}
73+
return Object.freeze(copy)
5974
},
6075

6176
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: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,19 @@ 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
}
@@ -20,10 +33,22 @@ const beforeEnd = (res, contentType, statusCode, data) => {
2033
res.statusCode = statusCode
2134
}
2235

36+
const isProduction = () => process.env.NODE_ENV === 'production'
37+
2338
const parseErr = error => {
2439
const errorCode = error.status || error.code || error.statusCode
2540
const statusCode = typeof errorCode === 'number' ? errorCode : 500
2641

42+
if (isProduction()) {
43+
return {
44+
statusCode,
45+
data: stringify({
46+
code: statusCode,
47+
message: 'Internal Server Error'
48+
})
49+
}
50+
}
51+
2752
return {
2853
statusCode,
2954
data: stringify({
@@ -52,7 +77,19 @@ module.exports.send = (options, req, res) => {
5277
} else {
5378
if (headers && typeof headers === 'object') {
5479
forEachObject(headers, (value, key) => {
55-
res.setHeader(key.toLowerCase(), value)
80+
// Block forbidden headers (hop-by-hop, security-sensitive)
81+
if (typeof key !== 'string' || FORBIDDEN_HEADERS.has(key.toLowerCase())) {
82+
return
83+
}
84+
// Sanitize array values — prevent header injection via arrays
85+
if (Array.isArray(value)) {
86+
return
87+
}
88+
try {
89+
res.setHeader(key.toLowerCase(), value)
90+
} catch (e) {
91+
// Silently skip invalid headers (e.g. CRLF in key or value)
92+
}
5693
})
5794
}
5895

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+
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "restana",
3-
"version": "v5.2.0",
3+
"version": "v6.0.0",
44
"description": "Super fast and minimalist web framework for building REST micro-services.",
55
"main": "index.js",
66
"types": "index.d.ts",

0 commit comments

Comments
 (0)