Middleware and security
Security headers, deny-by-default frame and loader guards, trusted origins, CSRF, and the production hardening model.
@ilha/router hardens SSR and server-island endpoints by default. This page is the full guard and origin model. It sits behind Server rendering and Server islands.
Security headers
Both respond() and the low-level httpResponse() emit sensible security headers by default:
| Header | Value |
|---|---|
Content-Type |
text/html |
X-Content-Type-Options |
nosniff |
Referrer-Policy |
no-referrer |
Cache-Control |
no-store |
A header is added only when the caller has not already set it, so you can override any default by passing headers.
import { httpResponse } from "@ilha/router";
const html = "<h1>Hi</h1>";
const nonce = "abc123";
// ---cut---
// Default: nosniff, no-referrer, no-store (see table above).
const res = httpResponse("<h1>Hi</h1>");
// Conservative default CSP via a nonce — pass the same nonce to <script nonce=…>.
const withCsp = httpResponse(html, { cspNonce: nonce });
// Full CSP string wins over the nonce-derived default.
const locked = httpResponse(html, {
contentSecurityPolicy: "default-src 'self'",
});
| Option | Behavior |
|---|---|
status |
Response status (default 200). |
headers |
Extra headers, merged over defaults. |
cspNonce |
Emit a conservative default CSP with 'nonce-${nonce}' for script-src. |
contentSecurityPolicy |
Full CSP string; overrides the nonce-derived default. |
Prefer respond() when you are rendering a route — it runs the full render + head + headers pipeline. Reach for httpResponse() when you already have the HTML and only need a correctly-headered Response.
Deny-by-default endpoints
In production, two internal endpoints are denied unless you install a guard:
| Endpoint | Serves | Method | Default when unguarded |
|---|---|---|---|
/__ilha/frame |
Re-render of a server island or server page | POST | Denied (403) |
/__ilha/loader |
Loader data for a regular page navigation | GET | Denied (403) |
An unguarded /__ilha/frame would let anyone re-render any server island by id — if an island’s state holds private data you must install a session check. /__ilha/loader is gated the same way through the shared policy. The dev middleware stays permissive unless you register a guard, so local iteration works out of the box.
Installing guards
Register guards once at server startup for production, via the shared registry:
import {
setFrameAuth,
setFrameGuard,
setLoaderGuard,
} from "@ilha/router/server-island-registry";
function isSignedIn(_request: Request) {
return true;
}
// ---cut---
// Gates /__ilha/frame in production; without this the endpoint returns 403.
setFrameGuard((request) => {
if (!isSignedIn(request))
return new Response("Unauthorized", { status: 401 });
});
// Gates /__ilha/loader independently (falls back to the frame guard when absent).
setLoaderGuard((request) =>
isSignedIn(request)
? undefined
: new Response("Unauthorized", { status: 401 }),
);
// Optional: explicit trusted origins and a CSRF verifier for the frame POST.
setFrameAuth({
trustedOrigins: ["https://app.example.com"],
csrf: (request) =>
request.headers.get("x-csrf") === process.env.CSRF_SECRET,
});
In dev, configure the guards through the plugin options instead — see Plugin options.
setFrameAuth
Tuning the frame-auth policy:
| Option | Type | Behavior |
|---|---|---|
defaultAction |
"open" | "deny" |
"deny" is the default. "open" opts out of deny-by-default (not recommended). |
trustedOrigins |
string[] |
Explicit origins to accept for frame/loader origin checks. Otherwise the Origin header is compared against the request’s own Host. |
csrf |
(request) => boolean |
Extra verifier for the frame POST — covers non-browser callers that send no Origin header. |
The same defaultAction policy gates both the loader and the frame.
What is forwarded
The middleware forwards only cookie, authorization, and user-agent headers to loaders and frames. It deliberately does not forward a client-supplied x-forwarded-for header — it is spoofable and must not be trusted for IP checks. If you relied on it for IP-based decisions, move that check to a trusted proxy layer instead.
Validation and status clamping
- Frame bodies are streamed with a hard 16 KiB cap.
- Frame paths are validated and fail closed — an invalid path returns
400instead of falling back to/. redirect()coerces invalid statuses to302;error()coerces invalid statuses to500(silently).- Non-
LoaderErrorloader failures surface as"Internal error"outside dev — error details are never leaked to clients in production.
Related
| Topic | Guide |
|---|---|
| Server islands and frames | Server islands |
Rendering routes to a Response |
Server rendering |
| Guards via the pages plugin | File-system routing — plugin options |
| Production wiring | Deployment |