Security best practices
Esta página aún no está disponible en tu idioma.
The framework ships the pieces; this page is how to assemble them. The
runnable counterpart is examples/http/secure-service.ts.
The recommended stack
Section titled “The recommended stack”Order matters — outermost first:
import { concat, cors, csrfProtection, fallback, handleErrors, path, requestId, requestTimeout, securityHeaders, withMiddleware, CorsOptions, CsrfOptions,} from 'actor-ts/http';
const corsOptions = CorsOptions.create().withOrigins('https://app.example').withCredentials();const csrf = csrfProtection(CsrfOptions.create().withSecret(process.env.CSRF_SECRET!));
const routes = withMiddleware(requestId(), withMiddleware(securityHeaders(), cors(corsOptions, withMiddleware(requestTimeout(15_000), handleErrors(errorMapper, withMiddleware(csrf, concat( appRoutes, fallback(notFound), )))))));| Layer | Why here |
|---|---|
requestId | outermost, so every log line (including errors) has an id |
securityHeaders | above everything that rejects, so those responses carry the headers too |
cors | outside auth — preflights are anonymous by spec |
requestTimeout | bound latency before doing real work |
handleErrors | outside csrf, so it maps that middleware’s own 403 — and inside the header layers, so what it hands back still flows through them |
csrf | browser apps only; after CORS, before handlers |
fallback | at the root — answers anything unmatched |
What every response already carries
Section titled “What every response already carries”X-Content-Type-Options: nosniff needs no setup. The backend writes it,
not a middleware, so it also reaches the responses no middleware sees: the
backend’s own error mapping, the fallback 404 and the body-too-large 413.
A handler’s own header still wins.
It is the only one on by default, deliberately — it is the only header of
the bundle that cannot change how an existing application is embedded,
framed or referred to. X-Frame-Options and
Cross-Origin-Resource-Policy would break iframes, cross-origin embedding
and OAuth popups, so they stay opt-in.
To put the whole bundle on every response — including those same middleware-invisible paths — configure it on the server rather than the route tree:
import { SecurityHeadersOptions } from 'actor-ts/http';
const securityHeadersOptions = SecurityHeadersOptions.create() .withFrameOptions('SAMEORIGIN') .withReferrerPolicy('strict-origin-when-cross-origin');
await http.newServerAt('0.0.0.0', 8080) .withSecurityHeaders(securityHeadersOptions) .bind(routes);Passing options opts into the full bundle, its own defaults included — so
the two headers above arrive alongside Cross-Origin-Resource-Policy: same-origin and the rest. A plain object works as well
(withSecurityHeaders({ frameOptions: 'SAMEORIGIN' })), and
withSecurityHeaders(false) turns the mechanism off entirely.
Server-wide or as securityHeaders() middleware — both stamp the same set.
The middleware covers its own subtree, and that includes a short-circuit
that throws: an HttpError from a layer below it — a CSRF 403, an auth
401 — carries the headers out with it. What it never sees is a response
raised outside its subtree: the backend’s own 404, the body-too-large 413,
and the generic 500 that a non-HttpError throw maps to. Reach for the
middleware when only a route subtree needs it; reach for the builder when
the whole server does.
Operational environment
Section titled “Operational environment”- TLS terminates at a proxy. Set HSTS regardless — browsers ignore it over plain HTTP. Don’t gate security headers on a scheme the app can’t see.
- Trusting client IPs.
req.remoteAddressis the socket peer, not a spoofableX-Forwarded-For— and “does my proxy overwrite the header” is the wrong question, because NGINX, AWS ALB and Cloudflare all append to it. The client’s own text stays at the left of the chain forever. Name the proxies instead (IpAllowlist’strustedProxies) and the client address is resolved from the socket end inwards, which is the only end infrastructure controls. Never trust every hop: on Express and Fastifytrust proxy: trueresolves to that leftmost, client-chosen entry. - Secrets from the environment, not HOCON files — the CSRF secret, Basic credentials, and bearer tokens all take values you load at startup.
Cookies
Section titled “Cookies”Prefer the __Host- prefix (requires Secure, Path=/, no Domain) and
SameSite=Lax or Strict. The CSRF cookie uses
that prefix by default — it is what stops a sibling subdomain from planting
a token — and is deliberately not HttpOnly (double-submit needs JS to read
it); your session/auth cookie should be HttpOnly.
serializeCookie gets you there by omission. An attribute you don’t
mention resolves to the strict end — Secure, HttpOnly, SameSite=Lax,
Path=/ — so the two-argument call is already shippable, and what it
produces satisfies the __Host- rules without a word about them.
Loosening is the part you write down:
import { serializeCookie } from 'actor-ts/http';
// __Host-session=<id>; Path=/; HttpOnly; Secure; SameSite=Laxconst sessionCookie = serializeCookie('__Host-session', sessionId);
// Plain-HTTP local dev, and a cookie same-origin JS has to read.const readableCookie = serializeCookie('token', value, { secure: false, httpOnly: false });Path is always emitted, because the alternative is worse: without it a
browser derives the scope from the request URI, so the same cookie covers
/account when minted from /account/login and / when minted from the
root.
It also refuses anything that could break out of the cookie it is
building — an illegal name or value, and a Path or Domain that is not
one. Those two reach the header verbatim, so an unchecked Path of
'/;Domain=evil.example' would append an attribute of somebody else’s
choosing, and the last Domain is the one that counts: it would override
yours rather than lose to it.
Error hygiene
Section titled “Error hygiene”Don’t leak internals. Use withErrorHandler (or a top-level handleErrors)
to return a generic body, and log the detail server-side keyed by the
request id. Keep 403/404 responses unspecific — the static-file layer
returns a uniform 404 for every rejected path so it never reveals what
exists.
The server-side copy needs hygiene too, and the usual leak there is a
connection URL: a rejected option value and the WebSocket client’s
oversize-frame warning both name one, and both are written with the
userinfo masked. When you log a URL yourself, run it through
redactUrlCredentials or redactedUrlLabel
— a password that has reached a log aggregator has to be rotated, not
deleted.
Redirects
Section titled “Redirects”redirect(target) takes same-origin targets only — a relative
reference. An absolute URL, a protocol-relative //host, or a control
character throws HttpError(400), so forwarding a ?next= parameter
straight into a redirect fails closed instead of becoming an open redirect.
When leaving the origin is the intent, say so: redirectExternal(...) is
the same helper without the origin rule, and being a separate name makes
every deliberate off-origin hop greppable in one command. Hand it a
constant or a target you allowlisted — never a raw request parameter. See
the Route DSL.
Capping untrusted input
Section titled “Capping untrusted input”- Request bodies are capped at 1 MiB on every backend, and a body over
it is refused with the same
413 Payload Too Largewhichever backend serves it — before the handler runs, and without consultingwithErrorHandler, because a size cap is a transport decision rather than an application error. Raise it where an endpoint genuinely takes more:withMaxBodyBytes(bytes)on the Express and Hono backend options,bodyLimitin the options bag ofFastifyBackend. A body that declares an over-capContent-Lengthis refused before a byte of it is read; a chunked body declares no length, so all three backends count it as it arrives and abandon the read at the cap — the bytes past it are never received. - WebSocket frames are capped by
maxFrameBytes(1 MiB default). The framework always refuses an oversize frame before it reaches your actor; on most runtime/backend pairs the resolved cap is also handed to the runtime’s own socket, so the frame is refused while it arrives rather than after the process has buffered it — otherwise a peer could force 16 MiB (Bun) or 100 MiB (ws) of buffering per frame that the framework would then discard. Lowering the cap — per route or viaactor-ts.http.websocket.maxFrameBytes— narrows that buffering window with it. Three places have no transport cap and buffer the frame before rejecting it: Hono on Deno, whoseupgradeWebSocketexposes no payload limit at all; Bun with the Express or Fastify backend, wherewsresolves to Bun’s built-in shim, which acceptsmaxPayloadand reports it back but does not enforce it; and the outboundWebsocketClientActoron every runtime — no native clientWebSockethonours a payload limit passed to its constructor, measured on Bun, Node and Deno, where the option is accepted, reads backundefined, and a 4 MiB frame is still delivered whole. On the client the cap is therefore post-hoc only, and what it buys is that the breach is terminal: an oversize frame closes the connection with 1009, so the peer cannot repeat the allocation on that socket. Note the threat model differs there — the peer is an endpoint you chose to dial, or a MITM on a plaintextws://link. See WebSocket. - Untrusted HTML — escape with the
htmltemplate; to accept rich markup, sanitise with a dedicated library beforerawHtml. - Responses you fetch are untrusted input too, and the direction this list
is easiest to forget.
HttpClientmaterialises a whole response body in memory before a caller sees it, so without a bound the peer decides how much of this process’s heap it takes — aContent-Lengthis a claim rather than a limit, and a chunked response declares no length at all. Every call carries a 30 s deadline and an 8 MiB response ceiling; the body is counted as it is read and abandoned while it arrives, with the connection torn down so the rest never lands, and the call throwsHttpResponseTooLargeError. Raise either per call (maxResponseBytes,timeoutMs), per integration (newClient(...)), or fleet-wide underactor-ts.http.client. Redirects are bounded with them — 5 hops, andauthorization/cookiedropped on any hop that crosses origins, so one hostileLocationcannot walk a bearer token to a host of its choosing. See the HTTP client.
Static files
Section titled “Static files”Keep the dotfiles: 'deny' and symlinks: 'within-root' defaults. Enable
browse only for internal tools — a public listing exposes your directory
structure. See Static files.
WebSocket
Section titled “WebSocket”Wrap a websocket() route in cors(...) to reject cross-origin upgrades,
and put auth middleware around it — withMiddleware runs at upgrade time,
so it gates the handshake.
A websocket() route needs no special treatment in the hardening stack
above: securityHeaders(), requestId() and the rest may sit over it like
they sit over any other route, and the upgrade still completes. Their
headers land on a rejected handshake, not an accepted one — the backend
writes the 101 itself. See WebSocket.
Checklist
Section titled “Checklist”| Measure | How |
|---|---|
| MIME-sniffing disabled on every response | nothing — on by default |
| Security headers on every response | withSecurityHeaders(...) server-wide — securityHeaders() covers a subtree, rejections included |
| HTTPS enforced by the browser | strictTransportSecurity() / withHsts |
| Cross-origin controlled | cors(...) |
| CSRF (browser forms) | csrfProtection(...) |
| No internal error leaks | withErrorHandler + generic bodies |
| No open redirects | redirect(...) — same-origin by default |
| Request correlation | requestId() |
| Bounded latency | requestTimeout(...) |
| Safe file serving | getFromDirectory defaults (dotfiles deny, symlink confinement) |
| Outbound responses bounded | nothing — 30 s and 8 MiB on every HttpClient call; per call, per newClient(...), or actor-ts.http.client |
| Outbound redirects bounded | nothing — 5 hops, credentials dropped cross-origin; redirect: 'error' to refuse them |
