Techniques
Security
What Knead protects by default, how the CORS, security-header and rate-limit modules fit together, and what to configure before going to production.
On this page 11 sections
Knead is secure by default: request limits, input hardening, masked production errors and
fail-closed guards are on without any configuration, and the settings that would weaken them are
explicit opt-ins. Three optional packages add the browser-facing layer:
@knead-js/cors, @knead-js/security-headers
and @knead-js/rate-limit. @knead-js/sanitize
cleans untrusted HTML.
This page explains each default and the reasoning behind it, then shows a hardened application and a checklist for production.
#What is on by default
#Request limits
Every input path has a bound, so one client cannot make the server buffer or parse unbounded data:
| Limit | Default | Over the limit | Option |
|---|---|---|---|
| Request body size | 1 MiB | 413 | listen({ maxRequestBodySize }) |
| Query parameters | 1000 | 400 | listen({ maxParams }) |
| urlencoded or multipart body fields | 1000 | 413 | listen({ maxParams }) |
| WebSocket message size | 256 KiB | connection dropped | listen({ websocket: { maxPayloadLength } }) |
| WebSocket buffered output (backpressure) | 1 MiB | socket closed | listen({ websocket: { backpressureLimit } }) |
| WebSocket idle time | 120 s | socket closed | listen({ websocket: { idleTimeout } }) |
| WebSockets per client IP | 100 | 429 | listen({ websocket: { maxConnectionsPerIp } }) |
| WebSocket messages per socket | 100 per 10 s | socket closed with 1008 | listen({ websocket: { messageRate } }) |
Field counts are checked before the parser builds the fields, so an oversized query string or form is rejected without being parsed. GraphQL adds its own bounds on every operation: depth 10, complexity 1000, 3000 tokens, 100 KiB of query text, 30 aliases, 1000 fields and an introspection depth of 20. See server, WebSockets and GraphQL.
Raise a limit deliberately when a route needs it. Do not disable one in production.
#Untrusted input
- Prototype pollution. Query strings, urlencoded and multipart bodies are parsed into objects
without a prototype, and the keys
__proto__,constructorandprototypeare dropped. JSON bodies drop__proto__at every depth, andconstructorandprototypewhen they hold an object. Still, neverObject.assign()request data onto your own objects: copy the fields you expect, ideally after a validation schema has checked them. - Content types. A body is parsed only as JSON (
application/jsonor+json), urlencoded, multipart or text. Any other declared type is a415, and malformed JSON is a400. - Validation messages. Issues found by a
headersschema always get the generic messageInvalid header value, because header schemas tend to quote tokens and cookies.listen({ validation: { redactIssueMessages: true } })does the same (Invalid value) for body, query and params issues.
#Hosts and proxies
- Host validation.
listen({ allowedHosts })lists the host names the server answers to. A request for any otherHostgets421 Misdirected Requestbefore middleware, routes and WebSocket upgrades run, which defeats DNS rebinding. In development the default islocalhost,127.0.0.1,[::1]and the bound hostname; in production there is no default, because the public names are not known, so set it yourself. - Proxies.
trustProxyis off by default:X-Forwarded-ForandX-Forwarded-Protoare ignored, andctx.ipandctx.securecome from the socket. A request that carriesX-Forwarded-*headers whiletrustProxyis off logs one warning, since that usually means a proxy you forgot to configure. Behind a proxy, settrustProxyto the proxy CIDRs (preferred) or the exact hop count. A hop count on a server that listens on every interface logs a warning, because a client that reaches the port directly could choose its ownctx.ip.
ctx.ip feeds the rate limiter and the WebSocket per-IP limit, and ctx.secure decides whether
HSTS is sent, so a wrong trustProxy weakens all three. See server and
request context.
#Errors and logs
- Masked 5xx responses. Outside development, a 5xx response carries only
type,title,status,instanceand acorrelationId: no message, stack or extensions, which tend to reveal internals. The same id is in the log line, so you can find the details. 4xx responses keep theirdetail, which is your own message. - Development mode shows error details (redacted) in 500 responses. It is on only when
NODE_ENV === 'development'or you passdevelopment: truetoKneadFactory.create(), andlisten()warns when development mode runs on an address reachable from other hosts. - Logs. Outside development an error is logged as
Name: messageonly, without the stack, cause or properties. Credentials of a recognizable shape are redacted everywhere: URL userinfo (redis://***@host), secretkey=valuepairs (password=,token=,api_key=and similar),BearerandBasiccredentials of 8 or more characters, and sensitive keys in nested objects. Short tokens such asBearer abc123and secrets without a recognizable shape or key name are not redacted; see the known limits in the logging guide.
#Fail-closed guards
A guard passes only when it returns or resolves to exactly true. 1, 'yes', an object or a
promise of 'true' is a 403. A guard that throws turns into that error's response. So a guard
with a bug, such as one that forgets to return, denies access instead of granting it. See
guards and authorization.
GraphQL follows the same rule: playground, introspection and unmasked errors are on only in
development, and GET operations must carry an x-graphql-request header so a cross-site form or
image cannot trigger them.
#Linear parsing
The framework's own parsers and regular expressions (body and query parsing, host and proxy
header handling, log redaction) run in time linear in their input, so crafted input cannot stall
the event loop. Patterns you supply, such as a CORS origin RegExp, must be linear too: avoid
nested quantifiers like (a+)+.
#The security modules
Each module registers global middleware with a fixed priority. Global middleware runs outermost first, in descending priority, before any module, controller or route middleware and before guards:
request
-> Host check (421) listen({ allowedHosts })
-> SecurityHeadersModule priority 1100
-> CorsModule priority 1000 (answers preflights here)
-> RateLimitModule priority 900 (answers 429 here)
-> your global middleware priority 0 (default)
-> module, controller and route middleware
-> guards -> validation -> authorize guards -> handlerEvery layer turns its own errors into a response, and the layers outside it still see that response. The order therefore gives you:
- Security headers on every response, including CORS preflights, 429s, 404s and errors raised by the inner layers. The 421 from the host check is the exception: it is answered before any middleware.
- CORS headers on 429s and errors, so a browser app can read them, and preflights that are never counted against a rate limit.
- Rate limiting before routing work, guards and validation. The middleware's key function cannot
see what a guard stores (such as the authenticated user);
RateLimitGuardcounts in the guard phase for per-user limits.
Your own global middleware registered with APP_MIDDLEWARE or app.useGlobalMiddleware() gets
priority 0 unless you pass one; give it a priority above 1100 to wrap the security modules, for
example to log every response. See middleware.
#A hardened application
The three modules together, with a stricter limit on the login route:
@Controller('/')
export class ApiController {
@Get('/health')
@SkipRateLimit()
health() {
return { ok: true };
}
@Post('/login')
@RateLimit({ limit: 5, window: '15m' })
login() {
return { token: 'issued' };
}
@Get('/me')
me() {
return { name: 'Ada' };
}
}
@Module({
imports: [
// Priority 1100: outermost, so every response below carries the headers.
SecurityHeadersModule.forRoot(),
// Priority 1000: answers preflights before rate limiting and routing.
CorsModule.forRoot({
origin: ['https://app.example.com'],
credentials: true,
exposedHeaders: ['RateLimit-Limit', 'RateLimit-Remaining', 'RateLimit-Reset', 'Retry-After'],
}),
// Priority 900: counts every request that is not a preflight.
RateLimitModule.forRoot({ limit: 300, window: '1m' }),
],
controllers: [ApiController],
})
export class AppModule {}And the listener options that matter in production:
const app = await KneadFactory.create(AppModule);
app.enableShutdownHooks();
await app.listen({
port: 3000,
// The public names this server answers to; any other Host gets 421.
allowedHosts: ['api.example.com'],
// Proxies in 10.0.0.0/8 are trusted: ctx.ip and ctx.secure follow their X-Forwarded-* headers.
trustProxy: ['10.0.0.0/8'],
// Per-user API responses must not be stored by shared caches.
defaultCacheControl: 'no-store',
// Issue messages never echo the submitted values back.
validation: { redactIssueMessages: true },
});With this setup:
- every response carries a strict CSP, HSTS (once the proxy reports HTTPS),
nosniff,X-Frame-Options: DENYand the other headers listed in security headers; - only
https://app.example.comcan read responses with credentials, and a WebSocket upgrade from another origin is a 403; - each client IP gets 300 requests a minute, plus 5 login attempts per 15 minutes on their own counter, and the health check is never limited;
- requests for any other host than
api.example.comare refused with 421.
Behind several app instances, give RateLimitModule a RedisStore, or each instance counts
separately. See rate limit.
#Untrusted HTML
When you accept HTML to render back (comments, rich-text fields), clean it with
@knead-js/sanitize before storing or rendering it: sanitize() or the
sanitizedHtml() schema keep only an allowlist of tags, attributes and URL schemes, and refuse
policies that would re-enable script. Combined with a strict Content Security Policy, an injected
script neither survives sanitizing nor runs if something slips through.
#Production checklist
-
NODE_ENVis notdevelopment, anddevelopmentis not forced totrue. -
listen({ allowedHosts })lists your public host names, plus theHostyour load balancer's health checks send (often an internal name or IP), or they get 421. - Behind a proxy or load balancer,
trustProxyis the proxy CIDRs or the exact hop count, and the app port is reachable only through the proxy. - TLS is terminated by the app (
tls, optionallyredirectHttpwith a canonicalhost) or by a trusted proxy that setsX-Forwarded-Proto, soctx.secureis true and HSTS is sent. -
SecurityHeadersModule.forRoot()is installed; any CSP relaxation is per route, and inline scripts use the nonce instead of'unsafe-inline'. - HSTS
maxAgeis at least 180 days;preloadonly once every subdomain serves HTTPS. -
CorsModulelists explicit origins (or anchored RegExps) and never combines'*'with credentials; WebSocket endpoints list cross-origin front ends inallowedOrigins. -
RateLimitModuleuses aRedisStorewhen there is more than one process, connected overrediss://withrequireTls: true, ahashSecretand a least-privilege ACL user. - Authentication routes have their own
@RateLimit()andonStoreError: 'deny'. - Health checks have
@SkipRateLimit(), and CORSexposedHeadersinclude theRateLimit-*andRetry-Afterheaders if browser code reads them. - Request limits (body size,
maxParams, WebSocket and GraphQL limits) are at their defaults or raised deliberately, never disabled. - Every route that changes data has a guard; for GraphQL consider
defaultDeny: true. - HTML from users goes through
sanitize()orsanitizedHtml(). -
defaultCacheControl: 'no-store'(or a per-routeCache-Control) for responses with per-user data. - Logs go through
LOGGER, neverconsole.*, so redaction applies.
Report vulnerabilities in Knead itself privately, as described in the repository's security policy.