Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .devcontainer.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"image": "mcr.microsoft.com/devcontainers/typescript-node:0-18",
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-24",
"forwardPorts": [3000, 8080]
}
2 changes: 1 addition & 1 deletion .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x, 22.x]
node-version: [24.x]
steps:
- uses: actions/checkout@v4
- name: Setup Environment (Using NodeJS ${{ matrix.node-version }})
Expand Down
4 changes: 1 addition & 3 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
language: node_js
node_js:
- "10"
- "12"
- "14"
- "24"

script:
- npx standard
Expand Down
39 changes: 35 additions & 4 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,18 @@ Optionally, learn through examples:
- `server`: Allows to optionally override the HTTP server instance to be used.
- `prioRequestsProcessing`: If `TRUE`, HTTP requests processing/handling is prioritized using `setImmediate`. Default value: `TRUE`
- `defaultRoute`: Optional route handler when no route match occurs. Default value: `((req, res) => res.send(404))`
- `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`.
- `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`.
- `routerCacheSize`: The router matching cache size, indicates how many request matches will be kept in memory. Default value: `2000`
- `enableTrace`: When `TRUE`, the `TRACE` HTTP method handler is available for debugging purposes. Default value: `FALSE`. ⚠️ Not recommended for production deployments.
- `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`.

### Security defaults (v6.0+)
Restana now ships with these security hardening measures enabled by default:
- **Header injection protection**: Security-sensitive and hop-by-hop headers are blocked from the `res.send()` headers parameter.
- **Production error masking**: In `NODE_ENV=production`, `res.send(err)` masks the error message and strips `err.data` to prevent internal details from leaking.
- **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`.
- **TRACE method disabled by default**: Eliminates Cross-Site Tracing attack surface. Re-enable for debugging via `enableTrace: true` (not recommended in production).
- **Deep frozen config**: `getConfigOptions()` now freezes nested plain objects, not just the top-level copy.

# Full service example
```js
Expand Down Expand Up @@ -115,8 +125,13 @@ service.start(3000)
# API and features
## Supported HTTP methods:
```js
const methods = ['get', 'delete', 'put', 'patch', 'post', 'head', 'options', 'trace']
const methods = ['get', 'delete', 'put', 'patch', 'post', 'head', 'options']
```
> ⚠️ `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:
> ```js
> const service = restana({ enableTrace: true })
> service.trace('/debug', (req, res) => res.send('Echo: ' + req.url))
> ```

### Using .all routes registration
You can also register a route handler for `all` supported HTTP methods:
Expand All @@ -140,7 +155,7 @@ service.close().then(()=> {})
```js
const opts = service.getConfigOptions()
```
> `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.
> `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.

## Async / Await support
```js
Expand All @@ -158,6 +173,8 @@ res.send('Hello World', 200, {
'x-response-time': 100
})
```
> ⚠️ Security-sensitive and hop-by-hop headers are blocked from the `headers` parameter for security reasons:
> `transfer-encoding`, `content-length`, `connection`, `keep-alive`, `host`, `set-cookie`. Use `res.setHeader()` explicitly if you need to set these.

## The "res.send" method
Same as in express, for `restana` we have implemented a handy `send` method that extends
Expand Down Expand Up @@ -213,7 +230,7 @@ service.get('/throw', (req, res) => {
throw new Error('Upps!')
})
```
> **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.
> **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.
### errorHandler not being called?
> Issue: https://github.com/jkyberneees/ana/issues/81

Expand Down Expand Up @@ -467,6 +484,20 @@ service.get('/hello', (req, res) => {
https://goo.gl/forms/qlBwrf5raqfQwteH3

# Breaking changes
## 6.0
> Restana version 6.0 focuses on security hardening and reducing attack surface.

Added:
- Minimum Node.js version is now v24.x (current LTS).

Changed:
- `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.
- `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.
- `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.
- `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.
- 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 }`.
- New `securityHeaders` option allows disabling default security headers entirely (default: `true`).

## 5.2
> Restana version 5.2 includes important security hardening while remaining backward compatible for most users.

Expand Down
21 changes: 20 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
*/

const requestRouter = require('./libs/request-router')
const applySecurityHeaders = require('./libs/security-headers')
const { deepFreezeObject } = require('./libs/utils')
const exts = {
request: {},
response: require('./libs/response-extensions')
Expand Down Expand Up @@ -35,13 +37,19 @@ module.exports = (options = {}) => {
}

const handle = (req, res) => {
// Default security headers (can be overridden by application or disabled via options)
if (options.securityHeaders !== false) {
applySecurityHeaders(req, res)
}

// request object population
res.send = exts.response.send(options, req, res)

service.getRouter().lookup(req, res)
}

const service = handle
let frozenConfig = null

const service_ = {
errorHandler: options.errorHandler,
Expand All @@ -55,7 +63,18 @@ module.exports = (options = {}) => {
},

getConfigOptions () {
return Object.freeze({ ...options })
if (!frozenConfig) {
const copy = { ...options }
// Deep-clone + deep-freeze nested plain objects so the user's originals
// are not mutated as a side effect of calling getConfigOptions().
for (const key of Object.keys(copy)) {
if (key !== 'server') {
copy[key] = deepFreezeObject(copy[key])
}
}
frozenConfig = Object.freeze(copy)
}
return frozenConfig
},

handle,
Expand Down
2 changes: 1 addition & 1 deletion libs/apm-base.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use strict'

const methods = require('./methods')
const methods = require('./methods').BASE

module.exports = (options) => {
const agent = options.apm || options.agent
Expand Down
14 changes: 13 additions & 1 deletion libs/methods.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,16 @@
/**
* Supported HTTP methods
*/
module.exports = ['get', 'delete', 'patch', 'post', 'put', 'head', 'options', 'trace', 'all']
const BASE_METHODS = ['get', 'delete', 'patch', 'post', 'put', 'head', 'options', 'all']
const TRACE_METHOD = 'trace'

module.exports = (options = {}) => {
const methods = [...BASE_METHODS]
if (options.enableTrace) {
methods.push(TRACE_METHOD)
}
return methods
}

module.exports.BASE = BASE_METHODS
module.exports.TRACE = TRACE_METHOD
3 changes: 2 additions & 1 deletion libs/request-router.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* @see: https://github.com/jkyberneees/0http#0http---sequential-default-router
*/
const sequential = require('0http/lib/router/sequential')
const methods = require('./methods')
const getMethods = require('./methods')
const EventEmitter = require('events')
const BEFORE_ROUTE_REGISTER_EVENT = 'beforeRouteRegister'

Expand Down Expand Up @@ -43,6 +43,7 @@ module.exports = (options, service = {}) => {
}

// attach routes registration shortcuts
const methods = getMethods(options)
methods.forEach((method) => {
service[method] = (...args) => {
if (Array.isArray(args[0])) {
Expand Down
41 changes: 40 additions & 1 deletion libs/response-extensions.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,48 @@ const TYPE_OCTET = 'application/octet-stream'

const NOOP = () => { }

//
// Headers that MUST NOT be set via the res.send() headers parameter.
// These should only be managed by the framework or explicitly via res.setHeader().
//
const FORBIDDEN_HEADERS = new Set([
'transfer-encoding',
'content-length',
'connection',
'keep-alive',
'host',
'set-cookie'
])

const stringify = obj => {
return JSON.stringify(obj)
}

const STATUS_TEXTS = require('http').STATUS_CODES

const beforeEnd = (res, contentType, statusCode, data) => {
if (contentType) {
res.setHeader(CONTENT_TYPE_HEADER, contentType)
}
res.statusCode = 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

if (isProduction()) {
return {
statusCode,
data: stringify({
code: statusCode,
message: STATUS_TEXTS[statusCode] || 'Internal Server Error'
})
}
Comment thread
jkyberneees marked this conversation as resolved.
}

return {
statusCode,
data: stringify({
Expand Down Expand Up @@ -52,7 +79,19 @@ module.exports.send = (options, req, res) => {
} else {
if (headers && typeof headers === 'object') {
forEachObject(headers, (value, key) => {
res.setHeader(key.toLowerCase(), value)
// Block forbidden headers (hop-by-hop, security-sensitive)
if (typeof key !== 'string' || FORBIDDEN_HEADERS.has(key.toLowerCase())) {
return
}
// Sanitize array values — prevent header injection via arrays
if (Array.isArray(value)) {
return
}
try {
res.setHeader(key.toLowerCase(), value)
} catch (e) {
// Silently skip invalid headers (e.g. CRLF in key or value)
}
})
}

Expand Down
29 changes: 29 additions & 0 deletions libs/security-headers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use strict'

/**
* Applies default security headers to the response, if not already set.
* Headers can be overridden by the application via res.setHeader().
*
* @param {import('http').IncomingMessage} req
* @param {import('http').ServerResponse} res
*/
module.exports = (req, res) => {
if (!res.getHeader('x-content-type-options')) {
res.setHeader('x-content-type-options', 'nosniff')
}
if (!res.getHeader('x-frame-options')) {
res.setHeader('x-frame-options', 'DENY')
}
if (!res.getHeader('x-xss-protection')) {
res.setHeader('x-xss-protection', '0')
}

// HSTS on HTTPS connections
const isTLS = req.socket && req.socket.encrypted
const forwardedProto = req.headers && req.headers['x-forwarded-proto']
if (isTLS || forwardedProto === 'https') {
if (!res.getHeader('strict-transport-security')) {
res.setHeader('strict-transport-security', 'max-age=15552000; includeSubDomains')
}
}
}
33 changes: 33 additions & 0 deletions libs/utils.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use strict'

module.exports.forEachObject = (obj, cb) => {
const keys = Object.keys(obj)
const length = keys.length
Expand All @@ -6,3 +8,34 @@ module.exports.forEachObject = (obj, cb) => {
cb(obj[keys[i]], keys[i])
}
}

/**
* Deep-clones a serializable plain object, then recursively freezes
* the clone and all nested plain objects. Skips arrays, Buffers,
* class instances, and other non-plain types.
*
* The original object is never mutated — safe to call on user-provided config.
*
* @param {Object} obj
* @returns {Object} Deep-cloned, deeply frozen copy
*/
module.exports.deepFreezeObject = (obj) => {
// Pass through non-plain values (functions, arrays, primitives, etc.)
if (!obj || typeof obj !== 'object' || obj.constructor !== Object) {
return obj
}

const clone = JSON.parse(JSON.stringify(obj))

function freeze (val) {
if (val && typeof val === 'object' && val.constructor === Object && !Object.isFrozen(val)) {
Object.freeze(val)
for (const key of Object.keys(val)) {
freeze(val[key])
}
}
return val
}

return freeze(clone)
}
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "restana",
"version": "v5.2.0",
"version": "v6.0.0",
"description": "Super fast and minimalist web framework for building REST micro-services.",
"main": "index.js",
"types": "index.d.ts",
Expand All @@ -22,7 +22,7 @@
"service"
],
"engines": {
"node": ">=10.x"
"node": ">=24.x"
},
"author": "Rolando Santamaria Maso <kyberneees@gmail.com>",
"license": "MIT",
Expand Down
Loading
Loading