Ir al contenido
Español

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.

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),
)))))));
LayerWhy here
requestIdoutermost, so every log line (including errors) has an id
securityHeadersabove everything that rejects, so those responses carry the headers too
corsoutside auth — preflights are anonymous by spec
requestTimeoutbound latency before doing real work
handleErrorsoutside csrf, so it maps that middleware’s own 403 — and inside the header layers, so what it hands back still flows through them
csrfbrowser apps only; after CORS, before handlers
fallbackat the root — answers anything unmatched

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.

  • 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.remoteAddress is the socket peer, not a spoofable X-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’s trustedProxies) 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 Fastify trust proxy: true resolves 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.

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=Lax
const 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.

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.

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.

  • Request bodies are capped at 1 MiB on every backend, and a body over it is refused with the same 413 Payload Too Large whichever backend serves it — before the handler runs, and without consulting withErrorHandler, 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, bodyLimit in the options bag of FastifyBackend. A body that declares an over-cap Content-Length is 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 via actor-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, whose upgradeWebSocket exposes no payload limit at all; Bun with the Express or Fastify backend, where ws resolves to Bun’s built-in shim, which accepts maxPayload and reports it back but does not enforce it; and the outbound WebsocketClientActor on every runtime — no native client WebSocket honours a payload limit passed to its constructor, measured on Bun, Node and Deno, where the option is accepted, reads back undefined, 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 plaintext ws:// link. See WebSocket.
  • Untrusted HTML — escape with the html template; to accept rich markup, sanitise with a dedicated library before rawHtml.
  • Responses you fetch are untrusted input too, and the direction this list is easiest to forget. HttpClient materialises 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 — a Content-Length is 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 throws HttpResponseTooLargeError. Raise either per call (maxResponseBytes, timeoutMs), per integration (newClient(...)), or fleet-wide under actor-ts.http.client. Redirects are bounded with them — 5 hops, and authorization / cookie dropped on any hop that crosses origins, so one hostile Location cannot walk a bearer token to a host of its choosing. See the HTTP client.

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.

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.

MeasureHow
MIME-sniffing disabled on every responsenothing — on by default
Security headers on every responsewithSecurityHeaders(...) server-wide — securityHeaders() covers a subtree, rejections included
HTTPS enforced by the browserstrictTransportSecurity() / withHsts
Cross-origin controlledcors(...)
CSRF (browser forms)csrfProtection(...)
No internal error leakswithErrorHandler + generic bodies
No open redirectsredirect(...) — same-origin by default
Request correlationrequestId()
Bounded latencyrequestTimeout(...)
Safe file servinggetFromDirectory defaults (dotfiles deny, symlink confinement)
Outbound responses boundednothing — 30 s and 8 MiB on every HttpClient call; per call, per newClient(...), or actor-ts.http.client
Outbound redirects boundednothing — 5 hops, credentials dropped cross-origin; redirect: 'error' to refuse them