SpaceNode
SpaceNode takes security seriously. The framework includes 12 layers of built-in protection that work out of the box — with zero configuration and zero dependencies. Every protection listed below is implemented in the framework core, battle-tested under stress, and incurs no measurable performance overhead.
Philosophy: Security should be on by default. Developers shouldn't need to remember to enable protections — they should need to explicitly opt out if needed. SpaceNode follows this principle for every layer below.
Attack: An attacker sends GET /../../../etc/passwd or GET /%2e%2e/%2e%2e/etc/passwd to escape the static files directory and read arbitrary server files.
Protection — 3 layers:
decodeURIComponent() is applied first, so %2e%2e tricks are decoded before any checks.path.normalize() resolves all .. segments into a canonical path.// Internal (simplified):
const decoded = decodeURIComponent(urlPath)
const safe = normalize(decoded)
const filePath = join(staticDir, safe)
if (!filePath.startsWith(staticDir)) return false // blocked
Result: No matter how creative the traversal attempt (.., %2e%2e, ..%5c, ..\/), the file path cannot escape the configured directory. This protection is always active when static file serving is enabled.
Attack: An attacker sends a request with field names like __proto__[isAdmin]=true or constructor.prototype.isAdmin=true to modify the JavaScript Object prototype and escalate privileges.
Protection: All body parsers (JSON, URL-encoded forms, and multipart form-data) filter out dangerous keys before they reach your handler:
__proto__ — the prototype chain accessorconstructor — access to constructor functionsprototype — direct prototype accessThese keys are silently dropped during parsing. Your handlers never see them.
// This malicious POST body:
// { "__proto__": { "isAdmin": true }, "name": "hacker" }
//
// Becomes in your handler:
// body = { name: "hacker" } ← __proto__ stripped
Result: Prototype pollution is impossible through SpaceNode's body parsers, regardless of content type. This protection is always active.
SpaceNode automatically sets essential security headers on every response — static files, API routes, error pages, everything:
| Header | Value | Purpose |
|---|---|---|
X-Content-Type-Options | nosniff | Prevents MIME-type sniffing attacks. Browser will not try to guess content types. |
X-Frame-Options | DENY | Prevents clickjacking by disallowing the page from being embedded in iframes. |
These headers have zero performance cost and protect against the most common browser-level attacks.
For stricter protection, add the security pipe. It sets a comprehensive set of headers recommended by OWASP:
const app = await createApp({
pipe: ['security'], // standard mode
// or
pipe: ['security:strict'], // strict mode (adds CSP, Permissions-Policy)
})
| Header | Standard | Strict |
|---|---|---|
X-Content-Type-Options | ✓ nosniff | ✓ |
X-Frame-Options | ✓ DENY | ✓ |
X-XSS-Protection | ✓ 0 | ✓ |
Referrer-Policy | ✓ strict-origin-when-cross-origin | ✓ |
X-Permitted-Cross-Domain-Policies | ✓ none | ✓ |
Strict-Transport-Security | ✓ max-age=31536000; includeSubDomains | ✓ |
Content-Security-Policy | — | ✓ (restrictive) |
Permissions-Policy | — | ✓ (camera, mic, geo, payment disabled) |
Cross-Origin-Opener-Policy | — | ✓ same-origin |
Cross-Origin-Resource-Policy | — | ✓ same-origin |
Recommendation: Use 'security' for APIs, 'security:strict' for web apps serving HTML.
When you set a cookie with request.cookie(), SpaceNode applies secure defaults automatically:
| Flag | Default | Purpose |
|---|---|---|
HttpOnly | ✓ on | Cookie is not accessible via document.cookie — prevents XSS from stealing session tokens. |
SameSite | Lax | Cookie is not sent on cross-site requests — prevents CSRF attacks. |
Path | / | Cookie is available on all paths (explicit is safer than browser guessing). |
// These two are equivalent:
request.cookie('session', token)
request.cookie('session', token, { httpOnly: true, sameSite: 'Lax', path: '/' })
// To opt out (e.g. for a client-side readable cookie):
request.cookie('theme', 'dark', { httpOnly: false })
Result: Session cookies are secure by default. Developers must explicitly opt out to create insecure cookies.
The built-in cors guard reflects the incoming Origin header and adds Vary: Origin for proper cache behavior. If no Origin header is present in the request, it falls back to Access-Control-Allow-Origin: *:
// Reflects the requesting origin (safe — only the caller sees the header)
pipe: ['cors']
// Lock to a specific origin (recommended for production)
pipe: ['cors:https://mysite.com']
The CORS guard also sets Access-Control-Allow-Credentials: true (allows cookies and auth headers cross-origin) and Access-Control-Max-Age: 86400 (browsers cache the preflight for 24 hours). On preflight (OPTIONS), the Access-Control-Allow-Headers value is mirrored from the client’s Access-Control-Request-Headers header.
Why this matters: A wildcard Access-Control-Allow-Origin: * allows any website to make requests to your API. For production, lock the origin to a specific domain with cors:https://mysite.com to control exactly who can call your endpoints from a browser.
Attack: Cross-Site WebSocket Hijacking (CSWSH). A malicious webpage opens a WebSocket to your server. Since browsers send cookies automatically, the attacker can piggyback on the victim's session.
Protection: Configure allowed origins for WebSocket connections:
const app = await createApp({
wsOrigins: ['https://mysite.com', 'https://admin.mysite.com'],
})
app.ws('/chat', (socket, req) => {
// Only connections from allowed origins reach here
})
Any WebSocket upgrade request with a mismatched Origin header receives 403 Forbidden and is immediately destroyed.
wsOrigins value | Behavior |
|---|---|
['https://mysite.com'] | Only this origin allowed |
'https://mysite.com' | Single string — same as above |
['*'] | All origins allowed (development only) |
| Not set | No Origin check (backward compatible) |
Recommendation: Always set wsOrigins in production when using WebSockets.
Attack: Regular Expression Denial of Service. An attacker sends a specially crafted string that makes a regex take exponential time, freezing the Event Loop.
Protection in DTO validation: The pattern validator limits regex patterns to 200 characters and wraps execution in try/catch:
// Safe — pattern is bounded:
export const userDto = {
username: ['string', 'required', 'pattern:^[a-zA-Z0-9_]{3,20}$'],
}
// A 500-character malicious regex → silently fails validation (returns false)
// An invalid regex → caught, returns false (no crash)
Result: DTO pattern validation cannot be exploited to hang the server, regardless of the input.
SpaceNode prevents information leaks at three levels:
When a route is not found, SpaceNode returns:
{ "error": "Not found" }
The requested path is not included in the response. This prevents attackers from enumerating routes or confirming internal paths.
On unhandled errors, the server returns:
{ "error": "Internal Server Error" }
Stack traces, file paths, and error messages are only included when debug: true is explicitly set. In production, attackers see nothing useful.
The /openapi.json endpoint that exposes your entire API schema (routes, DTOs, types) is disabled by default. It only activates when you explicitly enable it:
const app = await createApp({
openapi: true, // enable with defaults
// or
openapi: { title: 'My API' }, // enable with custom config
})
Result: Your API surface is not discoverable unless you intentionally expose it.
Attack: An attacker sends a massive request body (e.g. 1 GB) to exhaust server memory.
Protection: Request bodies are limited to 1 MB by default. The body is read incrementally — if the limit is exceeded mid-stream, the connection is immediately destroyed and a 413 Payload Too Large error is returned. The full body is never buffered.
// Increase if needed:
const app = await createApp({
bodyLimit: 10 * 1024 * 1024, // 10 MB
})
Attack: Brute-force login attempts, credential stuffing, or simple DDoS from single IPs.
Protection: Built-in per-IP rate limiter, configurable per-route or globally:
// Per-route: 10 login attempts per minute
['POST', '/login', 'login', ['rateLimit:10']]
// Global: 500 requests per minute per IP
const app = await createApp({
pipe: ['rateLimit:500'],
})
When the limit is exceeded, the client receives 429 Too Many Requests. The rate limit window is 1 minute, and stale entries are automatically cleaned from memory.
Attack: Slowloris — an attacker opens many connections and sends data very slowly, tying up server resources.
Protection:
timeout + 1s, preventing slow header attacks.app.close(), new connections are refused, existing ones drain, then force-closed after shutdownTimeout.This configuration enables all available protections:
import { createApp, defineAuth } from 'spacenode'
defineAuth(async (token) => {
// Your token verification logic
})
const app = await createApp({
db: dbConnection,
// Security
pipe: [
'security:strict', // HSTS, CSP, X-Frame-Options, etc.
'cors:https://mysite.com', // locked CORS origin
'rateLimit:500', // 500 req/min per IP
'compress', // response compression
'logger', // request logging
],
wsOrigins: ['https://mysite.com'], // WebSocket origin whitelist
bodyLimit: 5 * 1024 * 1024, // 5 MB max body
// Timeouts
timeout: 15000, // 15s request timeout
keepAliveTimeout: 5000,
shutdownTimeout: 10000,
// Debug off in production (no stack traces)
debug: false,
})
app.onError((err, req) => {
// Send to your monitoring service (Sentry, Datadog, etc.)
})
app.listen(process.env.PORT || 3000)
// Graceful shutdown
process.on('SIGINT', () => app.close(() => process.exit(0)))
process.on('SIGTERM', () => app.close(() => process.exit(0)))
| Layer | Default | Action Required? |
|---|---|---|
| Directory Traversal | ✓ Always active | None — automatic |
| Prototype Pollution | ✓ Always active | None — automatic |
| X-Content-Type-Options | ✓ Always active | None — automatic |
| X-Frame-Options | ✓ Always active | None — automatic |
| Cookie HttpOnly + SameSite | ✓ Always active | None — automatic |
| Body Size Limits | ✓ 1 MB default | Adjust bodyLimit if needed |
| ReDoS Protection | ✓ Always active | None — automatic |
| No Info Leaks | ✓ Always active | Keep debug: false in production |
| OpenAPI Hidden | ✓ Disabled | Enable with openapi: true only if needed |
| Request Timeouts | ✓ 30s default | Tune for your use case |
| Security Headers (full) | Opt-in | Add 'security' or 'security:strict' pipe |
| CORS | Opt-in | Add 'cors:https://yoursite.com' pipe |
| Rate Limiting | Opt-in | Add 'rateLimit:N' pipe |
| WebSocket Origin | Opt-in | Set wsOrigins in production |
10 of 14 protections are active with zero configuration. The remaining 4 require a single line each to enable.