diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 9c3c2b2..e523b43 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -12,27 +12,19 @@ A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error +1. Include a minimal Restana service. +2. Include the request that triggers the issue. +3. Include the actual response, error, or stack trace. **Expected behavior** A clear and concise description of what you expected to happen. -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Desktop (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] - -**Smartphone (please complete the following information):** - - Device: [e.g. iPhone6] - - OS: [e.g. iOS8.1] - - Browser [e.g. stock browser, safari] - - Version [e.g. 22] +**Environment** +- Restana version: +- Node.js version: +- Package manager and version: +- Operating system: +- Relevant middleware or reverse proxy: **Additional context** Add any other context about the problem here. diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index f734a05..2365375 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -1,12 +1,23 @@ name: tests -on: [push, pull_request] +on: + push: + branches: [master] + pull_request: + +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read jobs: testing: runs-on: ubuntu-latest + timeout-minutes: 10 strategy: matrix: - node-version: [24.x] + node-version: [24.x, 26.x] steps: - uses: actions/checkout@v4 - name: Setup Environment (Using NodeJS ${{ matrix.node-version }}) @@ -15,10 +26,21 @@ jobs: node-version: ${{ matrix.node-version }} - name: Install dependencies - run: npm install + run: npm ci - name: Linting - run: npx standard + run: npm run lint + + - name: Check TypeScript declarations + run: npm run test:types - name: Run tests - run: npm run test \ No newline at end of file + run: npm run test + + - name: Run performance smoke test + if: matrix.node-version == '24.x' + run: npm run bench:ci + + - name: Audit production dependencies + if: matrix.node-version == '24.x' + run: npm audit --omit=dev \ No newline at end of file diff --git a/.npmrc b/.npmrc index 9cf9495..1e54ebc 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1 @@ -package-lock=false \ No newline at end of file +package-lock=true \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 247be91..0000000 --- a/.travis.yml +++ /dev/null @@ -1,7 +0,0 @@ -language: node_js -node_js: - - "24" - -script: - - npx standard - - npm run test \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..92b7b8a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +## 6.1.0 + +### Added +- Explicit `trustProxy` and `debugErrors` configuration. +- TypeScript declaration tests and performance smoke checks. +- Reproducible dependency installs through `package-lock.json`. + +### Changed +- Listen failures now reject `service.start()` instead of escaping as unhandled errors. +- Error details are masked by default in every environment. +- Boolean bodies, array-valued headers, and `routerCacheSize: 0` behave as documented. +- Configuration snapshots clone and freeze nested arrays and circular plain objects. +- Stream failures use the configured error handler when a response can still be sent. +- Forwarded protocol headers require explicit proxy trust. + +### Removed +- Obsolete `disableResponseEvent` references. +- Install-time survey output, legacy Travis configuration, and a broken performance demo. + +## 6.0.0 + +Security-focused release that introduced safe default errors, response-header validation, +default browser security headers, deeply frozen configuration, and opt-in TRACE support. + +Earlier release notes remain available in [the full documentation](docs/README.md#breaking-changes). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..316ef47 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,27 @@ +# Contributing + +Restana requires Node.js 24 or newer. + +```bash +npm ci +npm run check +``` + +Tests must listen on an ephemeral loopback port: + +```js +const server = await service.start(0, '127.0.0.1') +``` + +Run response-path benchmarks before and after hot-path changes: + +```bash +npm run bench +``` + +When changing the public API, update `index.d.ts`, `specs/types.test.ts`, the root +README, and `docs/README.md`. Security-sensitive behavior requires a regression +test. Performance claims require a repeatable benchmark rather than an isolated +micro-optimization result. + +Keep changes focused and use Conventional Commit-style messages where practical. diff --git a/README.md b/README.md index e80dcc0..3e4fdfd 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![NPM Total Downloads](https://badgen.net/npm/dt/restana)](https://www.npmjs.com/package/restana) [![License](https://badgen.net/npm/license/restana)](https://www.npmjs.com/package/restana) [![TypeScript support](https://badgen.net/npm/types/restana)](https://www.npmjs.com/package/restana) -[![Github stars](https://badgen.net/github/stars/jkyberneees/restana?icon=github)](https://github.com/jkyberneees/restana) +[![Github stars](https://badgen.net/github/stars/BackendStack21/restana?icon=github)](https://github.com/BackendStack21/restana) @@ -21,14 +21,14 @@ Install ```bash npm i restana ``` -Create unsecure API service: +Create an HTTP API service: ```js const restana = require('restana') const service = restana() service.get('/hi', (req, res) => res.send('Hello World!')) -service.start(3000); +service.start(3000) ``` Creating secure API service: ```js @@ -43,7 +43,7 @@ const service = restana({ }) service.get('/hi', (req, res) => res.send('Hello World!')) -service.start(3000); +service.start(3000) ``` Using `http.createServer()`: @@ -57,11 +57,36 @@ service.get('/hi', (req, res) => res.send('Hello World!')) http.createServer(service).listen(3000, '0.0.0.0') ``` -# Security Defaults +# Security defaults Restana ships with secure defaults out of the box: - **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. - **Stream safety**: Stream errors are handled gracefully, preventing connection leaks. - **Immutable config**: `getConfigOptions()` returns a frozen copy, preventing middleware from mutating internal framework options. +- **Response headers**: Browser hardening headers are enabled by default, and connection-specific or cookie headers cannot be injected through `res.send()`. +- **Proxy safety**: Forwarded protocol headers are ignored unless `trustProxy: true` is explicitly configured. + +For local-only development, bind explicitly to loopback: +```js +service.start(3000, '127.0.0.1') +``` + +When TLS terminates at a trusted reverse proxy: +```js +const service = restana({ trustProxy: true }) +``` + +Error details are hidden by default in every environment. For local debugging only, opt in with `debugErrors: true`; production mode always masks details. + +## 6.1 highlights +- Reliable `start()` rejection on port and socket errors. +- Isolated, deeply frozen configuration snapshots. +- Boolean response bodies and array-valued headers. +- Correct `routerCacheSize: 0` behavior. +- Updated TypeScript API, reproducible installs, and route-scaling benchmarks. +- Expanded security and performance regression coverage. # More - Website and documentation: https://restana.21no.de +- [Full API guide](docs/README.md) +- [Changelog](CHANGELOG.md) +- [Contributing](CONTRIBUTING.md) diff --git a/demos/static/app-cache.js b/demos/static/app-cache.js index 7aefd94..841fd62 100644 --- a/demos/static/app-cache.js +++ b/demos/static/app-cache.js @@ -3,9 +3,7 @@ const files = require('serve-static') const path = require('path') -const app = require('../../index')({ - disableResponseEvent: true -}) +const app = require('../../index')() app.use(require('http-cache-middleware')()) const serve = files(path.join(__dirname, 'src'), { diff --git a/docs/README.md b/docs/README.md index 571a436..e969b5a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,7 +3,7 @@ [![NPM Total Downloads](https://badgen.net/npm/dt/restana)](https://www.npmjs.com/package/restana) [![License](https://badgen.net/npm/license/restana)](https://www.npmjs.com/package/restana) [![TypeScript support](https://badgen.net/npm/types/restana)](https://www.npmjs.com/package/restana) -[![Github stars](https://badgen.net/github/stars/jkyberneees/restana?icon=github)](https://github.com/jkyberneees/restana) +[![Github stars](https://badgen.net/github/stars/BackendStack21/restana?icon=github)](https://github.com/BackendStack21/restana) @@ -34,7 +34,7 @@ Install ```bash npm i restana ``` -Create unsecure API service: +Create an HTTP API service: ```js const restana = require('restana') @@ -84,12 +84,14 @@ Optionally, learn through examples: - `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`. +- `trustProxy`: When `TRUE`, trust the first `X-Forwarded-Proto` value when deciding whether to send HSTS. Only enable this behind a reverse proxy that replaces forwarded headers. Default value: `FALSE`. +- `debugErrors`: When `TRUE`, `res.send(error)` includes `error.message` and `error.data` outside production. Use only for local debugging. Default value: `FALSE`. ### 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`. +- **Error masking**: `res.send(err)` masks the error message and strips `err.data` by default. Local development can opt in with `debugErrors: true`; production always masks details. +- **Default security headers**: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `X-XSS-Protection: 0`, and `Strict-Transport-Security` (on direct HTTPS or a trusted HTTPS proxy) are set on responses. 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. @@ -155,7 +157,7 @@ service.close().then(()=> {}) ```js const opts = service.getConfigOptions() ``` -> `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. +> `getConfigOptions()` returns an isolated configuration snapshot. Plain objects and arrays are recursively cloned and frozen, preventing third-party middleware from modifying internal framework options. The `server` and other custom class instances remain live references and should not contain secrets. ## Async / Await support ```js @@ -170,7 +172,8 @@ service.post('/star/:username', async (req, res) => { ## Sending custom headers ```js res.send('Hello World', 200, { - 'x-response-time': 100 + 'x-response-time': 100, + vary: ['accept', 'origin'] }) ``` > ⚠️ Security-sensitive and hop-by-hop headers are blocked from the `headers` parameter for security reasons: @@ -189,6 +192,8 @@ Supported datatypes are: - Stream (errors on the stream are handled gracefully, terminating the response instead of leaving the connection hanging) - Promise (recursive promise resolution is capped at a depth of 3 to prevent event loop starvation) +Boolean payloads are serialized as JSON. A number passed as the first argument remains the shorthand for an HTTP status code. + Example usage: ```js service.get('/promise', (req, res) => { @@ -230,7 +235,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 (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. +> **Note:** `res.send(err)` masks the error's `message` and `data` by default. Set `debugErrors: true` only for local development when detailed responses are required. Production mode always masks details. ### errorHandler not being called? > Issue: https://github.com/jkyberneees/ana/issues/81 @@ -378,7 +383,7 @@ service.get('/hello', (req, res) => { }) // lambda integration -const handler = serverless(app); +const handler = serverless(service) module.exports.handler = async (event, context) => { return await handler(event, context) } @@ -401,7 +406,7 @@ service.get('/hello', (req, res) => { }) // lambda integration -exports = module.exports = functions.https.onRequest(app.callback()); +exports = module.exports = functions.https.onRequest(service.callback()) ``` ## Serving static files @@ -484,6 +489,28 @@ service.get('/hello', (req, res) => { https://goo.gl/forms/qlBwrf5raqfQwteH3 # Breaking changes +## 6.1 +> Restana 6.1 improves lifecycle reliability, secure defaults, performance tooling, and TypeScript support. + +Added: +- `trustProxy` explicitly enables forwarded-protocol handling for HSTS. +- `debugErrors` explicitly enables detailed local error responses. +- TypeScript coverage for lifecycle, events, callback integration, and TRACE opt-in. +- Reproducible installs and performance smoke checks in CI. + +Changed: +- `start()` now rejects on listen errors such as `EADDRINUSE`. +- Error details are masked by default in every environment and remain masked in production. +- Boolean response bodies are serialized as JSON; array-valued response headers are supported. +- `routerCacheSize: 0` now correctly disables route caching. +- Configuration snapshots recursively clone and freeze arrays and support circular plain objects. +- Forwarded protocol headers are ignored unless `trustProxy: true` is configured. +- Connection-specific, proxy-authentication, upgrade, and cookie headers are blocked from the `res.send()` header map. + +Removed: +- The obsolete `disableResponseEvent` example and install-time survey. +- Legacy Travis CI configuration and the broken low-level performance demo. + ## 6.0 > Restana version 6.0 focuses on security hardening and reducing attack surface. diff --git a/docs/index.html b/docs/index.html index 7b6aa9f..258e3da 100644 --- a/docs/index.html +++ b/docs/index.html @@ -199,6 +199,19 @@ } .scroll-top.visible { opacity: 1; transform: translateY(0); } .scroll-top:hover { border-color: var(--accent); color: var(--accent); } + .skip-link { + position: fixed; left: 12px; top: -80px; z-index: 1000; + padding: 10px 14px; background: var(--bg-card); color: var(--text-primary); + } + .skip-link:focus { top: 12px; } + + @media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + } + } /* ── Responsive ── */ @media (max-width: 768px) { @@ -212,6 +225,7 @@ + +
@@ -240,11 +256,11 @@

Blazing fast, tiny, and secure-by-default. Express-inspired API with serverless support, nested routers, and zero bloat.

7
HTTP methods
-
v6.0
latest
+
v6.1
latest
Node 24+
runtime
3
dependencies
TS
types built-in
@@ -252,6 +268,37 @@

+ +
+
+ +

Safer APIs, clearer operations

+

Restana 6.1 strengthens production behavior without trading away routing performance.

+
+
+ +

Reliable lifecycle

+

service.start() now rejects cleanly on socket and port conflicts, making startup failures observable and recoverable.

+
+
+ +

Explicit trust

+

Error details are opt-in for local debugging, while forwarded TLS headers require an explicitly trusted proxy.

+
+
+ +

Stronger API contracts

+

Boolean bodies, array-valued headers, cache disabling, and TypeScript declarations now match documented behavior.

+
+
+ +

Performance guarded

+

Response and routing benchmarks now run with CI budgets, including a direct router-integration overhead check.

+
+
+
+
+
@@ -267,7 +314,7 @@

Blazing Fast

🛡

Secure by Default

-

Production error masking, header injection protection, security headers, TRACE disabled, and deep-frozen config — all on by default.

+

Default error masking, header injection protection, security headers, TRACE disabled, and isolated config snapshots — all on by default.

🔗 @@ -298,13 +345,13 @@

Minimal Footprint

Safe Out of the Box

-

restana v6.0 ships with hardened security defaults. No extra packages needed.

+

restana v6.1 ships with hardened security defaults. No extra packages needed.

🔒
-

Production Error Masking

-

Default error handler returns generic Internal Server Error. In NODE_ENV=production, res.send(err) strips stack traces and internal details. Customize with your own errorHandler.

+

Safe Error Masking

+

Error details are hidden in every environment by default. Local development can explicitly opt in with debugErrors: true; production always masks details.

@@ -318,7 +365,7 @@

Header Injection Protection

🛡

Default Security Headers

-

Every response gets X-Content-Type-Options: nosniff, X-Frame-Options: DENY, and X-XSS-Protection: 0. Strict-Transport-Security is set automatically on TLS connections. Disable with securityHeaders: false.

+

Every response gets X-Content-Type-Options: nosniff, X-Frame-Options: DENY, and X-XSS-Protection: 0. HSTS is set for direct TLS or explicitly trusted proxies.

@@ -332,7 +379,7 @@

TRACE Disabled by Default

❄️

Immutable Configuration

-

getConfigOptions() returns a deep-frozen copy. Nested plain objects are frozen too — middleware can't mutate internal framework options at runtime.

+

getConfigOptions() returns an isolated snapshot. Nested plain objects and arrays are cloned and frozen so middleware cannot mutate framework options.

@@ -452,7 +499,8 @@

1. HTTP Routing

2. The res.send() Method

One method handles every response type. No res.json(), res.text(), or res.stream() needed.

    -
  • null / undefined — empty 204
  • +
  • null / undefined — empty response
  • +
  • Boolean — application/json
  • String — text/plain
  • Buffer — application/octet-stream
  • Object — application/json
  • @@ -495,10 +543,10 @@

    4. Custom Servers & Events

    Get Started

    One command and you're running.

    -
    +
    + Copied! +
@@ -517,10 +565,12 @@

Configuration Options

serverCustom HTTP server instancehttp.createServer() prioRequestsProcessingUse setImmediate for priority processingtrue defaultRouteHandler for unmatched routes (404)res.send(404) - errorHandlerGlobal error handler functionSafe-by-default (v6.0+) + errorHandlerGlobal error handler functionSafe by default routerCacheSizeRoute match cache size2000 enableTraceEnable TRACE HTTP methodfalse securityHeadersSet default security headers on every responsetrue + trustProxyTrust forwarded protocol for HSTSfalse + debugErrorsExpose local error details (never in production)false @@ -531,7 +581,7 @@

Configuration Options

const service = restana({
   errorHandler(err, req, res) {
     console.error(`Error: ${err.message}`)
-    res.send({ error: err.message }, err.status || 500)
+    res.send({ error: 'Internal Server Error' }, 500)
   }
 })
@@ -588,6 +638,23 @@

Configuration Options

// (e.g. with Helmet or for non-browser clients) +
+ trusted-reverse-proxy.js +
const service = restana({
+  trustProxy: true
+})
+// Enable only when your proxy replaces
+// client-supplied forwarded headers.
+
+ +
+ local-error-debugging.js +
const service = restana({
+  debugErrors: process.env.NODE_ENV === 'development'
+})
+// Production always masks error details.
+
+ @@ -626,21 +693,22 @@

📁 Static Files

+
- +