Organize src/ into concern folders (http, auth, admin, plugin-host, ui); co-locate tests, move plugin-api barrel into plugin-host, sync docs + AGENTS layout

This commit is contained in:
2026-06-24 00:23:55 +02:00
parent 6d316c4888
commit de22f51c12
113 changed files with 282 additions and 265 deletions
+1380
View File
File diff suppressed because it is too large Load Diff
+598
View File
@@ -0,0 +1,598 @@
import { randomBytes, randomUUID } from "node:crypto";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import * as ejs from "ejs";
import { ADMIN_CLIENTS_BASE, ADMIN_GROUPS_BASE, ADMIN_ROLES_BASE, ADMIN_USERS_BASE } from "../admin/admin-nav.ts";
import { type AdminClientsDeps, handleAdminClients } from "../admin/admin-clients.ts";
import { type AdminGroupsDeps, handleAdminGroups } from "../admin/admin-groups.ts";
import { type AdminRolesDeps, handleAdminRoles } from "../admin/admin-roles.ts";
import { type AdminUsersDeps, handleAdminUsers } from "../admin/admin-users.ts";
import { readFormBody } from "./body.ts";
import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
import { buildContext, type User } from "./context.ts";
import { CSRF_FIELD, csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
import type { Denylist } from "../auth/denylist.ts";
import { buildDashboardModel } from "../ui/dashboard.ts";
import { PLUGINS_DIR } from "../plugin-host/discovery.ts";
import { GuardError, loginRedirect } from "../auth/guards.ts";
import { AUTH_FLOWS, buildFlowView } from "../auth/flow-view.ts";
import { runRequestHooks, runResponseHooks } from "../plugin-host/hooks.ts";
import { HydraError, type HydraAdmin } from "../auth/hydra-admin.ts";
import type { JwksProvider } from "../auth/jwks.ts";
import { resolveSession, type VerifyOptions } from "../auth/jwt-middleware.ts";
import type { KetoClient } from "../auth/keto-client.ts";
import type { KratosAdmin } from "../auth/kratos-admin.ts";
import { type Flow, KratosError, type KratosPublic } from "../auth/kratos-public.ts";
import { createLogger, type Log, requestLogger, runWithLog } from "../logger.ts";
import { clearSessionCookie, completeLogin, remintSession, sessionCookie } from "../auth/login.ts";
import { resolveLoginChallenge } from "../auth/oauth-login.ts";
import { acceptConsent, rejectConsent, resolveConsentChallenge } from "../auth/oauth-consent.ts";
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
import type { Plugin, RouteHandler, RouteResult } from "../plugin-host/plugin.ts";
import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts";
import { securityHeaders } from "./security-headers.ts";
import { localPath } from "./safe-url.ts";
import { routePublic, serveStatic } from "./static.ts";
import { renderPluginView } from "../plugin-host/view-resolver.ts";
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
export interface AppOptions {
appUrl?: string; // canonical public URL (config.appUrl); off-host GET/HEAD visitors are 308'd here. Omitted ⇒ no redirect
auth?: VerifyOptions; // expected JWT issuer/audience + clock skew (config); used with jwks
// Cache compiled templates; caller decides (server passes config.cacheTemplates).
// Off by default so edits show live; the app itself never inspects the environment.
cache?: boolean;
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
denylist?: Denylist; // optional instant-revoke; the hot path rejects revoked subjects, admin writes record revokes
hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge
jwks?: JwksProvider; // verify the session JWT → ctx.user/roles; absent ⇒ always anonymous
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes
kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion
log?: Log; // app-level logger; per-request access log + trace span. Default: silent (tests)
menu?: MenuConfig; // central override + branding (config/menu.ts); defaults to DEFAULT_MENU
plugins?: Plugin[]; // discovered manifests to mount (router); empty until discovery runs
pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/
publicDir?: string;
secureCookies?: boolean; // set Secure on our session/CSRF cookies (config.secureCookies; off in dev http)
viewsDir?: string;
}
export function createApp(options: AppOptions = {}): Server {
// The denylist (when enabled) rides in the verify options so resolveSession rejects a revoked
// subject on the hot path; the bound `revoke` is handed to the admin handlers that should
// revoke instantly. Both absent ⇒ the feature is fully off (no cost, no behaviour change).
const denylist = options.denylist;
const authOptions: VerifyOptions = denylist ? { ...(options.auth ?? {}), denylist } : (options.auth ?? {});
const revoke = denylist ? (sub: string): void => denylist.revoke(sub) : undefined;
const cache = options.cache ?? false;
// Canonical public host (APP_URL): when set, an off-host GET/HEAD visitor is redirected here so
// every cookie (esp. Kratos' cross-origin CSRF cookie) shares one host. Omitted ⇒ feature off.
const canonical = options.appUrl ? new URL(options.appUrl) : undefined;
const canonicalHost = canonical?.host; // host[:port], default ports omitted — matches the Host header
const canonicalOrigin = canonical?.origin; // scheme + host[:port], no trailing slash
const csrfSecret = options.csrfSecret ?? randomBytes(32).toString("hex"); // server passes config; tests pass their own
const secureCookies = options.secureCookies ?? false;
const hydra = options.hydra;
const jwks = options.jwks;
const keto = options.keto;
const kratos = options.kratos;
const kratosAdmin = options.kratosAdmin;
// Silent default so unit/integration tests stay quiet; server.ts injects the configured logger.
const log = options.log ?? createLogger({ level: "none" });
const menu = options.menu ?? DEFAULT_MENU;
const plugins = options.plugins ?? [];
const pluginIds = new Set(plugins.map((p) => p.id));
// A plugin may fully replace the public landing "/" (`home`) or the gated dashboard "/dashboard"
// (`dashboard`) — Discovery's findConflicts guarantees at most one of each, so `find` is
// unambiguous; the predicates narrow the slot to defined.
const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "function");
const dashboardPlugin = plugins.find((p): p is Plugin & { dashboard: RouteHandler } => typeof p.dashboard === "function");
// Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free).
const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest);
const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse);
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
const publicDir = options.publicDir ?? join(rootDir, "public");
const viewsDir = options.viewsDir ?? join(rootDir, "views");
// Response security headers, fixed at boot (only HSTS depends on the https deployment signal).
const secHeaderEntries = Object.entries(securityHeaders({ secure: secureCookies }));
// `views: [viewsDir]` lets a view in a subfolder (e.g. admin/users.ejs) include() the shared
// partials/ by the same root-relative name top-level views use (EJS tries relative first).
const render = (view: string, data: Record<string, unknown>): Promise<string> =>
ejs.renderFile(join(viewsDir, `${view}.ejs`), data, { cache, views: [viewsDir] });
// A `view` RouteResult renders plugins/<id>/views/<view>.ejs; such views may include() the core
// building-block partials (resolved from viewsDir) and their own partials/subfolders.
const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir });
// Built-in admin screens — wired only when their Ory clients are present (the writes go
// there). They render core views via `render` and are gated/CSRF-guarded inside the handler.
// Users writes to Kratos; Groups writes to Keto and reads users from Kratos for the pickers.
const adminDeps: AdminUsersDeps | null = kratosAdmin ? { csrfSecret, kratosAdmin, menu, render, ...(revoke ? { revoke } : {}) } : null;
const adminGroupsDeps: AdminGroupsDeps | null = kratosAdmin && keto ? { csrfSecret, keto, kratosAdmin, menu, render } : null;
const adminRolesDeps: AdminRolesDeps | null = kratosAdmin && keto ? { csrfSecret, keto, kratosAdmin, menu, render, ...(revoke ? { revoke } : {}) } : null;
// OAuth2 clients write to Hydra; wired only when the Hydra admin client is present.
const adminClientsDeps: AdminClientsDeps | null = hydra ? { csrfSecret, hydra, menu, render } : null;
const sendHtml = (res: ServerResponse, status: number, html: string): void => {
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
res.end(html);
};
// The request handler. Run inside runWithLog (below) so the per-request logger is ambient: every
// outbound fetch (the Ory clients via tracedFetch) and any deep module joins this request's trace
// and correlation with no logger threaded through their signatures.
const handleRequest = async (req: IncomingMessage, res: ServerResponse, reqLog: Log): Promise<void> => {
try {
const method = req.method ?? "GET";
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
// Set before any branch so every response — static/redirect/error included — inherits them
// (writeHead merges these with its own headers; a plugin's RouteResult.headers can override).
for (const [name, value] of secHeaderEntries) res.setHeader(name, value);
if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) {
// /public/<id>/… serves a plugin's public/; everything else the core public/.
// Before auth: assets don't need a verified user, and the JWT cookie rides every request.
const { dir, subPath } = routePublic(pathname.slice("/public/".length), publicDir, pluginsDir, pluginIds);
await serveStatic(dir, subPath, res, method === "HEAD", (err) => reqLog.error("static stream error", { error: String(err) }));
return;
}
// Canonical host (APP_URL): a visitor who reached us on a different host (localhost vs
// 127.0.0.1, a secondary domain) is sent to the configured origin, path + query preserved, so
// the browser, the themed forms, and the cross-origin Kratos POST all share one cookie host —
// otherwise the host-scoped Kratos CSRF cookie is lost and login dumps onto /error. Static
// assets above are served on any host (health checks). GET/HEAD only — a 308 must not replay a
// cross-host POST; first-party forms are always served from a canonical page anyway.
if (canonicalHost && (method === "GET" || method === "HEAD")) {
const host = req.headers.host;
if (host !== undefined && host !== canonicalHost) {
res.writeHead(308, { location: canonicalOrigin + (req.url ?? "/") }).end();
return;
}
}
// Verify the session JWT once (cached JWKS) → ctx.user/roles; none/invalid ⇒ anonymous.
// If the token has lapsed but a live Kratos session still backs it (and we have the Ory
// clients), silently re-mint it — "stay signed in": re-read roles from Keto, re-tokenize,
// and set the fresh cookie via setHeader so it rides whatever response this request produces
// (a dead session clears the stale cookie). This is the only place the hot path touches Ory.
let user: User | null = null;
if (jwks) {
const auth = await resolveSession(req.headers.cookie, jwks, authOptions);
user = auth.user;
if (!user && auth.expired && keto && kratos && kratosAdmin) {
try {
const reminted = await remintSession({ keto, kratosAdmin, kratosPublic: kratos }, req.headers.cookie, { secure: secureCookies });
user = reminted.user;
res.appendHeader("set-cookie", reminted.setCookie);
} catch (err) {
// Ory unreachable (Kratos/Keto 5xx, refused, timeout) — degrade to anonymous instead of
// 500ing every lapsed request. Leave the cookie alone: it can re-mint once Ory recovers.
reqLog.warn("session re-mint failed (Ory unreachable?)", { error: String(err) });
}
}
}
// CSRF token for this request's first-party forms: reuse a genuine cookie token, else mint
// one (the form page below Set-Cookies it). Verified on our own state-changing routes.
const csrf = ensureCsrfToken(req.headers.cookie, csrfSecret);
// Bound CSRF verifier handed to plugins via ctx.verifyCsrf (the host owns the secret).
const verifyCsrf = (submitted: string | null | undefined): boolean =>
verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted });
// Chrome (brand/global-nav/user/theme/csrf) composes the whole menu, so it's resolved lazily and
// at most once per request: this app-level memo shares it across the contexts below, and each
// ctx.chrome getter only triggers it when a handler actually reads it (a json/redirect handler,
// or the public "/" with a standalone home, never composes the menu).
let chromeMemo: PageChrome | undefined;
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user }));
// base context (no route params yet); reused for onRequest + the built-in admin screens.
const ctx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf });
// Plugin onRequest hooks run before routing and may short-circuit the request.
if (anyRequestHooks) {
const short = await runRequestHooks(plugins, ctx);
if (short) {
// Set the fresh CSRF cookie like every other page-emitting path, so a form the hook
// renders (its token is in ctx.chrome.csrfToken) has the matching double-submit cookie.
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
await sendResult(res, short.result, (view, data) => renderView(short.plugin.id, view, data));
return;
}
}
// Plugin routes (any method): gate on the route's permission, then run the handler. The
// handler gets ctx.chrome (native app shell) + ctx.verifyCsrf (guard its own forms); a fresh
// CSRF cookie is set so those forms have a valid double-submit token.
const match = matchRoute(plugins, method, pathname);
if (match) {
const routeCtx = buildContext(req, res, { chrome, log: reqLog, params: match.params, user, verifyCsrf });
if (!isAuthorized(match.route, routeCtx.roles)) {
// Anonymous → sign in (like the built-in screens' requireSession), remembering the page as
// return_to; a signed-in user who simply lacks the role gets the 403 page.
if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
reqLog.warn("forbidden: missing role", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id });
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
return;
}
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
const result = (await match.route.handler(routeCtx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, routeCtx, result); // observers; a throw → 500
await sendResult(res, result, (view, data) => renderView(match.plugin.id, view, data));
return;
}
// Built-in admin screens. Each handler gates (admin only; throws GuardError the catch
// maps), CSRF-guards mutations, and returns html/redirect. Set the page's CSRF cookie when
// freshly minted (its forms carry the matching token); null ⇒ unknown subpath → 404.
if (adminDeps && pathname.startsWith(ADMIN_USERS_BASE)) {
const result = await handleAdminUsers(ctx, csrf.token, adminDeps);
if (result) {
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
await sendResult(res, result, () => Promise.reject(new Error("admin screens return html, not view")));
return;
}
}
if (adminGroupsDeps && pathname.startsWith(ADMIN_GROUPS_BASE)) {
const result = await handleAdminGroups(ctx, csrf.token, adminGroupsDeps);
if (result) {
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
await sendResult(res, result, () => Promise.reject(new Error("admin screens return html, not view")));
return;
}
}
if (adminRolesDeps && pathname.startsWith(ADMIN_ROLES_BASE)) {
const result = await handleAdminRoles(ctx, csrf.token, adminRolesDeps);
if (result) {
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
await sendResult(res, result, () => Promise.reject(new Error("admin screens return html, not view")));
return;
}
}
if (adminClientsDeps && pathname.startsWith(ADMIN_CLIENTS_BASE)) {
const result = await handleAdminClients(ctx, csrf.token, adminClientsDeps);
if (result) {
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
await sendResult(res, result, () => Promise.reject(new Error("admin screens return html, not view")));
return;
}
}
// Themed Kratos self-service pages (login/registration/recovery/verification/settings).
const flowType = AUTH_FLOWS[pathname];
if (kratos && flowType && (method === "GET" || method === "HEAD")) {
// Already signed in? Re-authenticating / re-registering is pointless — send them to the app
// dashboard. (/settings, /recovery, /verification stay reachable — a signed-in user can use those.)
if (ctx.user && (pathname === "/login" || pathname === "/registration")) {
res.writeHead(303, { location: "/dashboard" }).end();
return;
}
const cookie = req.headers.cookie;
const flowId = ctx.url.searchParams.get("flow");
// Only the Kratos calls are in the try, so a render/buildFlowView bug below falls through to
// the catch-all 500 (with a stack), not the "Ory unreachable" 503.
let flow: Flow;
try {
if (!flowId) {
// No flow yet: init one server-side, relay Kratos' CSRF cookie, bounce to ?flow=<id>.
// A `return_to` is baked into the flow so Kratos lands there after login instead of the
// default completion route. A first-party deep link (host-relative, from the gate's
// return_to) is wrapped through /auth/complete so the session JWT is minted before the
// user reaches the page; an absolute target (the OAuth2 login challenge) is passed
// as-is — Kratos allow-lists it. localPath rejects an off-origin "//evil.com".
const raw = ctx.url.searchParams.get("return_to");
const local = localPath(raw);
let returnTo: string | undefined;
if (local) {
const origin = `${secureCookies ? "https" : "http"}://${req.headers.host ?? "127.0.0.1:3000"}`;
const complete = new URL(`${origin}/auth/complete`);
complete.searchParams.set("return_to", local);
returnTo = complete.toString();
} else if (raw) returnTo = raw;
const { flow: initiated, setCookie } = await kratos.initBrowserFlow(flowType, { ...(cookie ? { cookie } : {}), ...(returnTo ? { returnTo } : {}) });
if (setCookie.length) res.appendHeader("set-cookie", setCookie);
res.writeHead(303, { location: `${pathname}?flow=${initiated.id}` }).end();
return;
}
flow = await kratos.getFlow(flowType, flowId, cookie ? { cookie } : {});
} catch (err) {
// Expired/unknown flow → restart by re-initialising (drop the stale ?flow=).
if (err instanceof KratosError && [403, 404, 410].includes(err.status)) {
res.writeHead(303, { location: pathname }).end();
return;
}
// Already authenticated at Kratos but no app JWT yet (e.g. straight after registration, whose
// `session` hook signs the user in but routes to verification, not /auth/complete — so ctx.user
// is null and the "already signed in" short-circuit above can't fire). Initialising a login/
// registration flow then returns Kratos 400 `session_already_available`. Recover by completing
// login (mint the JWT from the live session), honouring return_to — never a 500.
if (err instanceof KratosError && err.status === 400 && err.body.includes("session_already_available")) {
const local = localPath(ctx.url.searchParams.get("return_to"));
res.writeHead(303, { location: local ? `/auth/complete?return_to=${encodeURIComponent(local)}` : "/auth/complete" }).end();
return;
}
// Ory unreachable (Kratos 5xx / connection refused / timeout): "Ory down ⇒ no logins" is
// documented, so render an honest 503 rather than the catch-all "error on our end" 500.
if (!(err instanceof KratosError) || err.status >= 500) {
reqLog.warn("auth flow failed (Ory unreachable?)", { error: String(err), path: pathname });
sendHtml(res, 503, await render("503", { title: "Sign-in unavailable" }));
return;
}
throw err; // any other Kratos 4xx → the catch-all (genuinely unexpected)
}
// Rendered inside the unified app shell, so set a fresh CSRF cookie when minted — the
// shell's Sign-out form (shown on /settings, where the user is signed in) needs the token.
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
sendHtml(res, 200, await render("auth", { chrome: ctx.chrome, flow: buildFlowView(flow, flowType) }));
return;
}
// OAuth2 login challenge: Hydra hands the browser here when another app logs in
// *through* us. Resolve it via the Kratos session and accept; an unauthenticated user
// bounces to our themed login and returns here once signed in. Provider-only.
if (hydra && kratos && pathname === "/oauth2/login" && (method === "GET" || method === "HEAD")) {
const challenge = ctx.url.searchParams.get("login_challenge");
if (!challenge) {
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("Missing login_challenge");
return;
}
// Absolute return target so Kratos lands back here post-login. Host reflects what the
// browser used (so it matches Kratos' allowed_return_urls); scheme follows SECURE_COOKIES.
// A spoofed Host can't escape — Kratos validates return_to against its allow-list.
const origin = `${secureCookies ? "https" : "http"}://${req.headers.host ?? "127.0.0.1:3000"}`;
const selfUrl = `${origin}/oauth2/login?login_challenge=${encodeURIComponent(challenge)}`;
try {
const { redirect } = await resolveLoginChallenge({ hydra, kratos }, challenge, req.headers.cookie, selfUrl);
res.writeHead(303, { location: redirect }).end();
} catch (err) {
// A stale/invalid/consumed challenge (Hydra 4xx — back button, slow login, re-used URL) is
// user-reachable: tell them to restart rather than 500. A 5xx (Hydra down) rethrows → 500.
if (err instanceof HydraError && err.status < 500) {
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("This sign-in request has expired. Please start again from the application you were signing in to.");
} else throw err;
}
return;
}
// OAuth2 consent challenge: after login Hydra hands the browser here. A first-party
// (or Hydra-skipped) client is auto-granted its scopes; a third-party client gets the themed
// consent screen, whose CSRF-guarded POST accepts (Allow) or rejects (Deny). Provider-only.
if (hydra && kratos && pathname === "/oauth2/consent") {
const consentDeps = { hydra, kratos };
try {
if (method === "GET" || method === "HEAD") {
const challenge = ctx.url.searchParams.get("consent_challenge");
if (!challenge) {
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("Missing consent_challenge");
return;
}
const { redirect, view } = await resolveConsentChallenge(consentDeps, challenge, req.headers.cookie);
if (redirect) {
res.writeHead(303, { location: redirect }).end();
return;
}
// Third-party: show the consent screen, carrying a CSRF token its form echoes back.
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
sendHtml(res, 200, await render("oauth-consent", { brand: menu.branding.name, consent: view, csrfField: CSRF_FIELD, csrfToken: csrf.token }));
return;
}
if (method === "POST") {
const form = await readFormBody(req);
if (!verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted: form.get(CSRF_FIELD) })) {
reqLog.warn("csrf rejected", { path: pathname });
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
return;
}
const challenge = form.get("consent_challenge");
if (!challenge) {
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("Missing consent_challenge");
return;
}
const redirect = form.get("decision") === "allow"
? await acceptConsent(consentDeps, challenge, req.headers.cookie)
: await rejectConsent(consentDeps, challenge);
res.writeHead(303, { location: redirect }).end();
return;
}
} catch (err) {
// Stale/consumed challenge (Hydra 4xx) → recoverable 400; a genuine outage (5xx) → 500 (as /oauth2/login).
if (err instanceof HydraError && err.status < 500) {
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("This authorization request has expired. Please start again from the application you were signing in to.");
return;
}
throw err;
}
}
// OAuth2 RP-initiated logout: Hydra hands the browser here to end the OAuth2 session
// (hydra.yml urls.logout). Accept the challenge and resume to Hydra's post-logout redirect;
// the first-party POST /logout (below) owns the Kratos session + our JWT cookie. Provider-only.
// GET-accept is safe (like the login/consent handlers): the challenge is Hydra-minted +
// single-use, so a forged GET can't fabricate one — we skip only the optional "confirm logout?".
if (hydra && pathname === "/oauth2/logout" && (method === "GET" || method === "HEAD")) {
const challenge = ctx.url.searchParams.get("logout_challenge");
if (!challenge) {
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("Missing logout_challenge");
return;
}
try {
const { redirect } = await hydra.acceptLogoutRequest(challenge);
res.writeHead(303, { location: redirect }).end();
} catch (err) {
// Stale/consumed challenge (Hydra 4xx) → recoverable 400; a genuine outage (5xx) → 500.
if (err instanceof HydraError && err.status < 500) {
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("This logout request has expired. Please start again from the application you were signing out of.");
} else throw err;
}
return;
}
// Login completion: where Kratos lands the browser after authenticating (kratos.yml).
// Mint our session JWT — read roles from Keto, project onto the identity, tokenize —
// and store it as the cookie; no active session bounces back to sign in.
if (pathname === "/auth/complete" && method === "GET" && kratos && kratosAdmin && keto) {
const completed = await completeLogin({ keto, kratosAdmin, kratosPublic: kratos }, req.headers.cookie);
if (!completed) {
res.writeHead(303, { location: "/login" }).end();
return;
}
res.appendHeader("set-cookie", sessionCookie(completed.jwt, { secure: secureCookies }));
// Land on the deep link the user was headed to (return_to, validated host-relative so a
// crafted ?return_to= can't make this an open redirect), else the gated dashboard.
res.writeHead(303, { location: localPath(ctx.url.searchParams.get("return_to")) ?? "/dashboard" }).end();
return;
}
// Logout: a state change, so a CSRF-guarded POST (the shell submits a form, not a GET link).
// Clear our local JWT and revoke the Kratos session — Kratos' own cookie lives on its origin,
// so redirect to its logout URL (it revokes the session, clears plainpages_session, then lands
// on /login per kratos.yml). No active session ⇒ just clear our cookie and go to /login.
if (pathname === "/logout" && method === "POST" && kratos) {
const form = await readFormBody(req);
if (!verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted: form.get(CSRF_FIELD) })) {
reqLog.warn("csrf rejected", { path: pathname });
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
return;
}
const flow = await kratos.createLogoutFlow(req.headers.cookie ? { cookie: req.headers.cookie } : {});
res.appendHeader("set-cookie", clearSessionCookie({ secure: secureCookies }));
reqLog.info("logout", { sub: user?.id ?? "" });
res.writeHead(303, { location: flow?.logoutUrl ?? "/login" }).end();
return;
}
// Kratos' self-service error sink (kratos.yml flows.error.ui_url → /error). A flow that fails a
// security/expiry check redirects the browser here with ?id=<uuid>. Render a themed page with a
// path back into sign-in instead of the catch-all 404 ("Page not found") it used to hit. The
// canonical-host redirect above prevents the common cause (a lost cross-host CSRF cookie); this
// is the honest fallback for any genuine flow error. The id is shown only for support reference.
if (pathname === "/error" && (method === "GET" || method === "HEAD")) {
sendHtml(res, 200, await render("error", { id: ctx.url.searchParams.get("id"), title: "Sign-in problem" }));
return;
}
if (pathname === "/" && (method === "GET" || method === "HEAD")) {
// The public landing: ungated — anyone may see it. A plugin may fully own it via `home`
// (rendered against its own views, native shell via ctx.chrome, with a fresh CSRF cookie for
// any form it ships). Else the built-in intro page with prominent sign-in / register links.
if (homePlugin) {
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
const homeCtx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf });
const result = (await homePlugin.home(homeCtx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, homeCtx, result);
await sendResult(res, result, (view, data) => renderView(homePlugin.id, view, data));
return;
}
// Default landing in the unified app shell: `user` picks "go to dashboard" vs sign-in,
// and the shell's Sign-out form (when signed in) needs a fresh CSRF cookie.
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
sendHtml(res, 200, await render("home", { chrome: ctx.chrome, user }));
return;
}
if (pathname === "/dashboard" && (method === "GET" || method === "HEAD")) {
// The post-login app home, gated to a signed-in user: anonymous bounces to sign in,
// remembering /dashboard as return_to.
if (!user) { res.writeHead(303, { location: loginRedirect(ctx) }).end(); return; }
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
// A plugin may fully own the dashboard: render its handler against its own views, native
// shell via ctx.chrome — same path as a plugin route. Else the built-in mock-data People list.
if (dashboardPlugin) {
const dashCtx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf });
const result = (await dashboardPlugin.dashboard(dashCtx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, dashCtx, result);
await sendResult(res, result, (view, data) => renderView(dashboardPlugin.id, view, data));
return;
}
// The one global menu (ctx.chrome.nav) + branding/override from config/menu.ts.
sendHtml(res, 200, await render("index", { model: buildDashboardModel({ csrfToken: csrf.token, menu, nav: ctx.chrome.nav, user }) }));
return;
}
// Known path, wrong method → 405 with Allow; otherwise nothing here → 404.
const allow = allowedMethods(plugins, pathname);
if (allow.length) {
res.writeHead(405, { allow: allow.join(", "), "content-type": "text/plain; charset=utf-8" }).end("Method Not Allowed");
return;
}
sendHtml(res, 404, await render("404", { title: "Not found" }));
} catch (err) {
// A guard thrown anywhere in handling maps to a response (not a 500): a `location` ⇒ a
// redirect (requireSession → /login), otherwise the status renders the error page.
if (err instanceof GuardError) {
if (res.headersSent) return void res.end();
if (err.location) return void res.writeHead(303, { location: err.location }).end();
return void sendHtml(res, err.status, await render("403", { title: "Forbidden" }));
}
reqLog.error("unhandled request error", { error: err instanceof Error ? (err.stack ?? err.message) : String(err) });
if (res.headersSent) return void res.end(); // a partial body is already on the wire
try {
// Render before writing: if the 500 page itself throws, headers stay unsent
// and we fall back to plain text below instead of a half-written response.
sendHtml(res, 500, await render("500", { title: "Server error" }));
} catch (renderErr) {
reqLog.error("error page render failed", { error: renderErr instanceof Error ? (renderErr.stack ?? renderErr.message) : String(renderErr) });
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }).end("Internal Server Error");
}
}
};
return createServer((req, res) => {
// Per-request log + trace span: a "request" span, continuing an upstream W3C traceparent
// when present (distributed tracing across a proxy). "close" (not "finish") fires on both a
// completed response and a premature disconnect/abort, so an aborted/truncated request is still
// logged and its span flushed.
const startMs = Date.now();
const reqLog = requestLogger(log, {
requestId: randomUUID(),
...(typeof req.headers.traceparent === "string" ? { traceparent: req.headers.traceparent } : {}),
});
// end() must run exactly once, after BOTH the handler has fully unwound (settled) AND the
// response has closed (the access line is then emitted with the final status). Ending earlier
// would throw "already ended" from a still-running handler's ctx.log/tracedFetch on a client
// abort, or drop the access line on the happy path (handler settles before close). Coordinating
// the two signals avoids both. Logging must never crash a served request, so it's all guarded.
let settled = false;
let closed = false;
const finalize = (): void => { if (settled && closed) void reqLog.end().catch(() => {}); };
res.on("close", () => {
closed = true;
try {
// path only (no query — it may carry tokens); method/status are header-safe here.
reqLog.info("request", { method: req.method ?? "GET", ms: Date.now() - startMs, path: (req.url ?? "/").split("?", 1)[0] ?? "/", status: res.statusCode });
} catch { /* never let logging crash a served request */ }
finalize();
});
// Make reqLog ambient for the whole handler (sync body + every await) so all outbound fetch is
// traced. handleRequest owns its own try/catch; the .catch logs a pathological escape via the
// app logger (not reqLog, which may be the thing that broke), never crashing the request.
void runWithLog(reqLog, () => handleRequest(req, res, reqLog))
.catch((err) => log.error("request handler escaped its try/catch", { error: err instanceof Error ? (err.stack ?? err.message) : String(err) }))
.finally(() => { settled = true; finalize(); });
});
}
type ViewRenderer = (view: string, data: Record<string, unknown>) => Promise<string>;
// Turn a handler's RouteResult into the HTTP response. `null` = the handler took over `ctx.res`
// itself (the void escape hatch). Author `headers` override the content-type default.
async function sendResult(res: ServerResponse, result: RouteResult | null, renderView: ViewRenderer): Promise<void> {
if (result == null || res.writableEnded) return;
if ("redirect" in result) {
res.writeHead(result.status ?? 303, { location: result.redirect }).end();
return;
}
if ("json" in result) {
res.writeHead(result.status ?? 200, { "content-type": "application/json; charset=utf-8", ...result.headers });
res.end(JSON.stringify(result.json));
return;
}
const body = "html" in result ? result.html : await renderView(result.view, result.data ?? {});
res.writeHead(result.status ?? 200, { "content-type": "text/html; charset=utf-8", ...result.headers });
res.end(body); // Node suppresses the body for HEAD automatically
}
+17
View File
@@ -0,0 +1,17 @@
import assert from "node:assert/strict";
import type { IncomingMessage } from "node:http";
import { Readable } from "node:stream";
import { test } from "node:test";
import { readFormBody } from "./body.ts";
const reqOf = (body: string): IncomingMessage => Readable.from([Buffer.from(body)]) as unknown as IncomingMessage;
test("readFormBody parses urlencoded fields, handles an empty body, and caps the size", async () => {
const form = await readFormBody(reqOf("_csrf=abc.def&name=Sam+Rivers"));
assert.equal(form.get("_csrf"), "abc.def");
assert.equal(form.get("name"), "Sam Rivers");
assert.equal([...(await readFormBody(reqOf("")))].length, 0); // empty body ⇒ no fields, no throw
await assert.rejects(() => readFormBody(reqOf("x".repeat(50)), { limit: 10 }), /limit/);
});
+19
View File
@@ -0,0 +1,19 @@
// Read an application/x-www-form-urlencoded request body. Our own POST forms are
// tiny, so cap the size and reject anything larger rather than buffer unbounded. Consumes the
// stream once; never throws on an empty body. The CSRF gate + admin forms read fields here.
import type { IncomingMessage } from "node:http";
const DEFAULT_LIMIT = 1024 * 1024; // 1 MiB
export async function readFormBody(req: IncomingMessage, options: { limit?: number } = {}): Promise<URLSearchParams> {
const limit = options.limit ?? DEFAULT_LIMIT;
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of req) {
const buf = chunk as Buffer;
size += buf.length;
if (size > limit) throw new Error("request body exceeds limit");
chunks.push(buf);
}
return new URLSearchParams(Buffer.concat(chunks).toString("utf8"));
}
+54
View File
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import { IncomingMessage, ServerResponse } from "node:http";
import { Socket } from "node:net";
import { test } from "node:test";
import { buildContext, type User } from "./context.ts";
import { createLogger } from "../logger.ts";
// A req/res pair without a live server — enough to build and inspect a context.
function reqRes(url?: string): { req: IncomingMessage; res: ServerResponse } {
const req = new IncomingMessage(new Socket());
if (url !== undefined) req.url = url;
req.method = "GET";
return { req, res: new ServerResponse(req) };
}
test("buildContext parses the URL, exposes query, and defaults to an anonymous user", () => {
const { req, res } = reqRes("/users?q=ann&page=2");
const ctx = buildContext(req, res);
assert.equal(ctx.req, req);
assert.equal(ctx.res, res);
assert.equal(ctx.url.pathname, "/users");
assert.equal(ctx.query, ctx.url.searchParams); // same instance, not a copy
assert.equal(ctx.query.get("q"), "ann");
assert.equal(ctx.query.get("page"), "2");
assert.equal(ctx.user, null);
assert.deepEqual(ctx.roles, []);
assert.deepEqual(ctx.params, {});
});
test("buildContext threads path params supplied by the router", () => {
const { req, res } = reqRes("/users/42");
const ctx = buildContext(req, res, { params: { id: "42" } });
assert.equal(ctx.params.id, "42");
});
test("buildContext threads the user and derives roles from it", () => {
const { req, res } = reqRes("/");
const user: User = { email: "a@b.c", id: "u1", roles: ["admin", "editor"] };
const ctx = buildContext(req, res, { user });
assert.equal(ctx.user, user);
assert.equal(ctx.roles, user.roles); // same reference, never a divergent copy — buildContext is the only writer
});
test("buildContext defaults a missing request URL to /", () => {
const { req, res } = reqRes();
assert.equal(buildContext(req, res).url.pathname, "/");
});
test("buildContext provides a logger: a silent default, or the host's request logger", () => {
const { req, res } = reqRes("/");
assert.equal(typeof buildContext(req, res).log.info, "function"); // always present (silent default)
const log = createLogger({ level: "none" });
assert.equal(buildContext(req, res, { log }).log, log); // host's request logger threads through
});
+75
View File
@@ -0,0 +1,75 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { PageChrome } from "../ui/chrome.ts"; // type-only: no runtime import, so no cycle
import { createLogger, type Log } from "../logger.ts";
// The request context threaded to every route handler (plugin + built-in), built once
// per request by `buildContext`: the router supplies matched path `params`, the JWT
// middleware supplies `user` (null until then). The host's single handler argument.
// The authenticated user, projected from verified session JWT claims:
// `id` = `sub`, plus `email` and the coarse `roles` carried in the token.
export interface User {
email: string;
id: string;
roles: string[];
}
export interface RequestContext {
// Page chrome (brand/global-nav/user/theme/csrf) a plugin view hands to partials/shell so its
// page renders the native app shell; the host builds it per request (anonymous default otherwise).
chrome: PageChrome;
// Request-scoped logger: structured, in the request's trace. `log.info/warn/error(...)` to
// log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by
// requestId. Additive, stable per the contract; defaults to a silent logger off the request path.
log: Log;
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
req: IncomingMessage;
res: ServerResponse;
roles: string[]; // user?.roles ?? [] — coarse gate without a null-check
url: URL;
user: User | null;
// Gate a first-party form submission: true iff `submitted` matches this request's signed CSRF
// cookie (double-submit). The host binds the secret; a plugin calls it after reading its body.
verifyCsrf(submitted: string | null | undefined): boolean;
}
export interface BuildContextOptions {
// Lazy chrome factory: composing the global menu is only paid for if the handler actually reads
// ctx.chrome (a json/redirect handler, or the public "/" with a standalone home, pays nothing).
// The host's factory is memoised, so the menu composes at most once per request across contexts.
chrome?: () => PageChrome;
log?: Log;
params?: Record<string, string>;
user?: User | null;
verifyCsrf?: (submitted: string | null | undefined) => boolean;
}
// Anonymous default chrome — used until the host supplies a real one (built-in routes, tests).
const ANON_CHROME: PageChrome = { brand: { name: "Plainpages" }, csrfToken: "", nav: [], signInHref: "/login", user: { email: "", initials: "G", name: "Guest" } };
// Silent default logger — used off the request path (built-in routes built ad hoc, tests) until the
// host supplies the real request logger. One instance, no output, negligible cost.
const SILENT_LOG = createLogger({ level: "none" });
export function buildContext(
req: IncomingMessage,
res: ServerResponse,
options: BuildContextOptions = {},
): RequestContext {
const url = new URL(req.url ?? "/", "http://localhost");
const user = options.user ?? null;
const buildChrome = options.chrome;
let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context
return {
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
log: options.log ?? SILENT_LOG,
params: options.params ?? {},
query: url.searchParams,
req,
res,
roles: user?.roles ?? [],
url,
user,
verifyCsrf: options.verifyCsrf ?? (() => false), // fail-closed unless the host binds the secret
};
}
+91
View File
@@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { parseCookies, serializeCookie } from "./cookie.ts";
// parseCookies returns a null-prototype map; spread into a plain object to deep-equal.
const flat = (header: string | undefined): Record<string, string> => ({ ...parseCookies(header) });
test("parseCookies returns an empty object for an absent or empty header", () => {
assert.deepEqual(flat(undefined), {});
assert.deepEqual(flat(""), {});
});
test("parseCookies splits pairs, trims, keeps `=` in values, and skips nameless/`=`-less pairs", () => {
assert.deepEqual(flat("a=1; b=2"), { a: "1", b: "2" });
assert.deepEqual(flat(" a = 1 ;b= 2"), { a: "1", b: "2" });
assert.deepEqual(flat("t=ey.Jh=="), { t: "ey.Jh==" }); // `=` inside a base64/JWT value is kept
assert.deepEqual(flat("novalue; =orphan; a=1"), { a: "1" });
});
test("parseCookies decodes percent-encoded values, raw on malformed", () => {
assert.equal(parseCookies("a=one%20two").a, "one two");
assert.equal(parseCookies("a=%E0%A4%A").a, "%E0%A4%A"); // invalid escape → untouched, no throw
});
test("parseCookies strips one layer of surrounding double-quotes", () => {
assert.equal(parseCookies('a="quoted"').a, "quoted");
});
test("parseCookies keeps the first occurrence of a duplicate name", () => {
assert.equal(parseCookies("a=first; a=second").a, "first");
});
test("parseCookies is not vulnerable to prototype pollution", () => {
const parsed = parseCookies("__proto__=polluted; a=1");
assert.equal(Object.getPrototypeOf(parsed), null); // null-prototype map
assert.equal(parsed["__proto__"], "polluted"); // stored as a plain own key, not the prototype
assert.equal(parsed.a, "1");
assert.equal(Object.getPrototypeOf({}), Object.prototype); // global prototype untouched
});
test("serializeCookie emits name=value, encoding specials but leaving JWT chars (-_.) readable", () => {
assert.equal(serializeCookie("session", "abc"), "session=abc");
assert.equal(serializeCookie("session", "a b&c"), "session=a%20b%26c");
assert.equal(serializeCookie("session", "ab-_.cd"), "session=ab-_.cd");
});
test("serializeCookie appends the secure-by-default attribute flags", () => {
const out = serializeCookie("session", "x", { httpOnly: true, path: "/", sameSite: "Lax", secure: true });
assert.equal(out, "session=x; Path=/; HttpOnly; SameSite=Lax; Secure");
});
test("serializeCookie writes Max-Age (incl. non-positive, expire-now) and rejects a non-integer", () => {
assert.match(serializeCookie("a", "1", { maxAge: 600 }), /; Max-Age=600(;|$)/);
assert.match(serializeCookie("a", "1", { maxAge: 0 }), /; Max-Age=0(;|$)/);
assert.match(serializeCookie("a", "1", { maxAge: -1 }), /; Max-Age=-1(;|$)/);
assert.throws(() => serializeCookie("a", "1", { maxAge: 1.5 }), /integer/);
});
test("serializeCookie writes Expires from a Date and rejects invalid or out-of-range ones", () => {
assert.match(serializeCookie("a", "1", { expires: new Date(0) }), /; Expires=Thu, 01 Jan 1970 00:00:00 GMT/);
assert.throws(() => serializeCookie("a", "1", { expires: new Date("nope") }), /Expires/);
// toUTCString() of a year > 9999 yields a 6-digit year browsers may reject — fail loud instead.
assert.throws(() => serializeCookie("a", "1", { expires: new Date(8640000000000000) }), /Expires/);
});
test("serializeCookie writes Domain/Path and rejects empty or injecting values", () => {
const out = serializeCookie("a", "1", { domain: "example.com", path: "/admin" });
assert.match(out, /; Domain=example\.com/);
assert.match(out, /; Path=\/admin/);
assert.throws(() => serializeCookie("a", "1", { domain: "" }), /domain/); // misconfigured deploy
assert.throws(() => serializeCookie("a", "1", { path: "" }), /path/);
assert.throws(() => serializeCookie("a", "1", { path: "/x; Domain=evil.com" }), /path/); // attribute injection
assert.throws(() => serializeCookie("a", "1", { domain: "evil\r\nSet-Cookie: x=y" }), /domain/); // header split
});
test("serializeCookie rejects SameSite=None without Secure (browsers would drop it)", () => {
assert.throws(() => serializeCookie("a", "1", { sameSite: "None" }), /Secure/);
assert.doesNotThrow(() => serializeCookie("a", "1", { sameSite: "None", secure: true }));
});
test("serializeCookie rejects an invalid cookie name", () => {
assert.throws(() => serializeCookie("bad name", "1"), /name/);
assert.throws(() => serializeCookie("a;b", "1"), /name/);
});
test("serializeCookie and parseCookies round-trip an arbitrary value", () => {
const value = "header.payload.sig with spaces & symbols=";
const setCookie = serializeCookie("session", value, { httpOnly: true });
const cookieHeader = setCookie.split("; ")[0]; // browsers send only name=value
assert.equal(parseCookies(cookieHeader).session, value);
});
+92
View File
@@ -0,0 +1,92 @@
// Cookie helpers — parse the request `Cookie` header, build secure-by-default
// `Set-Cookie` headers. Stdlib only (no `cookie` dep); stores/clears the session
// JWT + CSRF token here. Values round-trip via percent-encoding; JWT `-_.` chars are
// URI-unreserved, so JWTs stay readable.
export interface CookieOptions {
domain?: string;
expires?: Date;
httpOnly?: boolean;
maxAge?: number; // seconds; 0 / negative expire the cookie immediately
path?: string;
sameSite?: "Lax" | "None" | "Strict";
secure?: boolean;
}
// RFC 6265 cookie-name token: no control chars, whitespace, or separators.
const cookieName = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
// Cookie Expires must be a 4-digit-year HTTP-date (RFC 1123); a Date outside this
// range makes toUTCString() emit a 6-digit/negative year browsers may reject.
const minExpires = Date.UTC(1601, 0, 1);
const maxExpires = Date.UTC(9999, 11, 31, 23, 59, 59, 999);
function decode(value: string): string {
if (!value.includes("%")) return value; // fast path: nothing to decode
try {
return decodeURIComponent(value);
} catch {
return value; // malformed input is untrusted — keep raw rather than throw
}
}
// Parse a `Cookie` header into a name→value map. First occurrence of a name wins.
// Null-prototype result, so a `__proto__`/`constructor` key can't pollute. Header
// length is bounded upstream by Node's `maxHeaderSize` (~16 KB).
export function parseCookies(header: string | undefined): Record<string, string> {
const out: Record<string, string> = Object.create(null);
if (!header) return out;
for (const pair of header.split(";")) {
const eq = pair.indexOf("=");
if (eq < 0) continue;
const name = pair.slice(0, eq).trim();
if (!name || name in out) continue;
let value = pair.slice(eq + 1).trim();
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
out[name] = decode(value);
}
return out;
}
// Validate a Domain/Path attribute: non-empty (fail loud on a misconfig) and free of
// chars that could inject extra attributes or split the header (CRLF). Cheap insurance
// against Set-Cookie injection, even though these come from config.
function assertAttrSafe(label: string, value: string): void {
if (value === "" || /[;\x00-\x1f\x7f]/.test(value)) throw new Error(`invalid cookie ${label}: ${JSON.stringify(value)}`);
}
// Build a `Set-Cookie` header value. Throws on inputs that would produce a
// malformed or injectable header.
export function serializeCookie(name: string, value: string, options: CookieOptions = {}): string {
if (!cookieName.test(name)) throw new Error(`invalid cookie name: ${JSON.stringify(name)}`);
const parts = [`${name}=${encodeURIComponent(value)}`];
if (options.maxAge !== undefined) {
if (!Number.isInteger(options.maxAge)) throw new Error("cookie maxAge must be an integer number of seconds");
parts.push(`Max-Age=${options.maxAge}`);
}
if (options.domain !== undefined) {
assertAttrSafe("domain", options.domain);
parts.push(`Domain=${options.domain}`);
}
if (options.path !== undefined) {
assertAttrSafe("path", options.path);
parts.push(`Path=${options.path}`);
}
if (options.expires !== undefined) {
const t = options.expires.getTime();
if (Number.isNaN(t)) throw new Error("cookie Expires is an invalid Date");
if (t < minExpires || t > maxExpires) throw new Error("cookie Expires year is out of the 4-digit RFC range");
parts.push(`Expires=${options.expires.toUTCString()}`);
}
if (options.httpOnly) parts.push("HttpOnly");
if (options.sameSite !== undefined) {
if (options.sameSite === "None" && !options.secure) throw new Error("SameSite=None requires Secure");
parts.push(`SameSite=${options.sameSite}`);
}
if (options.secure) parts.push("Secure");
return parts.join("; ");
}
+47
View File
@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { localPath, safeUrl } from "./safe-url.ts";
test("safeUrl: passes relative + http(s) through, neutralises dangerous schemes", () => {
// Relative forms (no scheme) and http(s) are rendered as-is.
assert.equal(safeUrl("/admin/users?q=1#f"), "/admin/users?q=1#f");
assert.equal(safeUrl("?q=1"), "?q=1");
assert.equal(safeUrl("#frag"), "#frag");
assert.equal(safeUrl("shifts/edit"), "shifts/edit");
assert.equal(safeUrl("http://example.com/x"), "http://example.com/x");
assert.equal(safeUrl("https://example.com/x"), "https://example.com/x");
assert.equal(safeUrl("HTTPS://EXAMPLE.com"), "HTTPS://EXAMPLE.com"); // scheme match is case-insensitive
// Any other scheme (the contract is: relative or http(s) only) ⇒ neutralised to "#".
assert.equal(safeUrl("javascript:alert(1)"), "#");
assert.equal(safeUrl("data:text/html,<script>alert(1)</script>"), "#");
assert.equal(safeUrl("vbscript:msgbox(1)"), "#");
assert.equal(safeUrl("mailto:x@y.z"), "#");
// Control-char / leading-whitespace obfuscation can't slip a scheme past the check (browsers
// strip TAB/CR/LF and leading controls before resolving the scheme).
assert.equal(safeUrl("java\tscript:alert(1)"), "#");
assert.equal(safeUrl("java\nscript:alert(1)"), "#");
assert.equal(safeUrl(" javascript:alert(1)"), "#");
// Empty / control-only ⇒ a safe no-op href.
assert.equal(safeUrl(""), "#");
// Leading Unicode whitespace above U+0020 (NBSP/NEL/LS) is left as-is on purpose: a browser only
// strips C0+space when resolving an href, so a NBSP-prefixed "javascript:" is an invalid scheme to
// it too \u2014 it resolves as a relative reference, not script. Documented so the strip set isn't widened later.
assert.equal(safeUrl("\u00a0javascript:alert(1)"), "\u00a0javascript:alert(1)");
});
test("localPath: accepts host-relative paths, rejects absolute / protocol-relative / odd input", () => {
assert.equal(localPath("/admin/users?q=1&page=2"), "/admin/users?q=1&page=2");
assert.equal(localPath("/"), "/");
// Protocol-relative and backslash variants are off-origin → rejected (open-redirect guard).
assert.equal(localPath("//evil.com"), null);
assert.equal(localPath("/\\evil.com"), null);
assert.equal(localPath("https://evil.com"), null);
assert.equal(localPath("javascript:alert(1)"), null);
assert.equal(localPath("relative/no-leading-slash"), null);
// Control chars / whitespace (a return_to is a server-built path) ⇒ rejected.
assert.equal(localPath("/x\nSet-Cookie: y"), null);
assert.equal(localPath("/a b"), null);
assert.equal(localPath(""), null);
assert.equal(localPath(null), null);
assert.equal(localPath(undefined), null);
});
+35
View File
@@ -0,0 +1,35 @@
// URL safety helpers. Two pure, dependency-free guards:
//
// safeUrl(value) — sanitise an untrusted URL before rendering it in an href/src attribute.
// Partials escape *text*, but a URL field is emitted verbatim, so a
// `javascript:`/`data:` URL from upstream/user data would be live XSS. The
// contract (docs/plugin-contract.md) is: a relative or http(s) URL is allowed,
// anything else collapses to "#". Exported to plugins via plugin-api.ts.
//
// localPath(value) — validate a redirect target is a *same-origin* path (the redirect-URI
// allowlist). Used for `return_to`: a host-relative "/a/b?x=1" passes, an
// absolute or protocol-relative ("//evil.com", "https://evil.com") is rejected
// so a crafted ?return_to= can't turn login completion into an open redirect.
// ASCII control chars + space that browsers strip/ignore when resolving a URL — strip them before
// the scheme check so "java\tscript:" / a leading space can't masquerade as relative.
const CONTROL_G = /[\u0000-\u0020\u007f]/g;
const CONTROL = /[\u0000-\u0020\u007f]/;
const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i; // a URL scheme prefix, e.g. "javascript:", "http:"
const HTTP_SCHEME = /^https?:/i;
export function safeUrl(value: string): string {
const cleaned = value.replace(CONTROL_G, "");
if (!cleaned) return "#";
// A scheme present? Allow only http(s). No scheme ⇒ relative ⇒ safe. Return the original once
// deemed safe (EJS still HTML-escapes it into the attribute; the inert control chars don't matter).
if (HAS_SCHEME.test(cleaned) && !HTTP_SCHEME.test(cleaned)) return "#";
return value;
}
export function localPath(value: string | null | undefined): string | null {
if (!value || CONTROL.test(value)) return null;
if (!value.startsWith("/")) return null; // must be host-relative
if (value.startsWith("//") || value.startsWith("/\\")) return null; // protocol-relative ⇒ off-origin
return value;
}
+26
View File
@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { securityHeaders } from "./security-headers.ts";
test("securityHeaders: strict zero-JS defaults; HSTS only over https", () => {
const h = securityHeaders();
// Always-on hardening, independent of scheme.
assert.equal(h["x-content-type-options"], "nosniff");
assert.equal(h["x-frame-options"], "DENY");
assert.equal(h["referrer-policy"], "strict-origin-when-cross-origin");
assert.equal(h["cross-origin-opener-policy"], "same-origin");
const csp = h["content-security-policy"] ?? "";
assert.match(csp, /default-src 'self'/);
assert.match(csp, /script-src 'self'/); // a plugin may ship its own JS; the core ships none
assert.doesNotMatch(csp, /script-src[^;]*'unsafe-inline'/); // an injected <script> can't run
assert.match(csp, /style-src 'self' 'unsafe-inline'/); // a few partials use inline style= attrs
assert.match(csp, /frame-ancestors 'none'/); // clickjacking guard (modern X-Frame-Options)
assert.match(csp, /object-src 'none'/);
assert.doesNotMatch(csp, /form-action/); // omitted: the themed login posts to Kratos' (cross-origin) action
// No HSTS on the dev http origin…
assert.equal(h["strict-transport-security"], undefined);
// …but present once the deployment is https.
assert.match(securityHeaders({ secure: true })["strict-transport-security"] ?? "", /max-age=\d+; includeSubDomains/);
});
+38
View File
@@ -0,0 +1,38 @@
// Response security headers: set once per request in app.ts so every response — page,
// JSON, redirect, static, or error — carries them (writeHead merges with setHeader). A plugin route
// may override any of them per-response via RouteResult.headers (e.g. relax the CSP to ship its own JS).
// Strict default CSP for the zero-JS, server-rendered core:
// - script-src 'self' : the core ships no JS; a plugin may still serve its own /public/<id>/*.js for
// opt-in progressive enhancement. No 'unsafe-inline' ⇒ an injected <script>
// can't run (the main XSS sink).
// - style-src adds 'unsafe-inline' : a few partials carry inline style= attributes.
// - img-src adds data: : favicon + inline data URIs.
// - no form-action : the themed login form posts to Kratos' (often cross-origin) action URL.
// - frame-ancestors 'none' : clickjacking guard (the modern X-Frame-Options).
const CSP = [
"base-uri 'self'",
"default-src 'self'",
"frame-ancestors 'none'",
"img-src 'self' data:",
"object-src 'none'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
].join("; ");
export interface SecurityHeaderOptions {
secure?: boolean; // https deployment (mirrors SECURE_COOKIES) → also emit HSTS
}
export function securityHeaders(options: SecurityHeaderOptions = {}): Record<string, string> {
const headers: Record<string, string> = {
"content-security-policy": CSP,
"cross-origin-opener-policy": "same-origin",
"referrer-policy": "strict-origin-when-cross-origin",
"x-content-type-options": "nosniff",
"x-frame-options": "DENY",
};
// HSTS only over https — ignored (and meaningless) on the dev http origin.
if (options.secure) headers["strict-transport-security"] = "max-age=31536000; includeSubDomains";
return headers;
}
+85
View File
@@ -0,0 +1,85 @@
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import type { ServerResponse } from "node:http";
import { extname, isAbsolute, join, relative } from "node:path";
const contentTypes: Record<string, string> = {
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".ico": "image/x-icon",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
".txt": "text/plain; charset=utf-8",
".webp": "image/webp",
".woff2": "font/woff2",
};
export function contentTypeFor(filePath: string): string {
return contentTypes[extname(filePath).toLowerCase()] ?? "application/octet-stream";
}
// Resolve a request path inside `dir`, or null if it escapes (traversal) or carries a
// control char (NUL etc.) — an explicit guard rather than relying on `stat` to throw.
export function resolveStaticPath(dir: string, requestedPath: string): string | null {
if (/[\x00-\x1f]/.test(requestedPath)) return null;
const filePath = join(dir, requestedPath);
const rel = relative(dir, filePath);
return rel.startsWith("..") || isAbsolute(rel) ? null : filePath;
}
export interface StaticRoute {
dir: string;
subPath: string;
}
// Route a `/public/<rest>` request to a base dir + sub-path: a leading segment naming a discovered
// plugin serves from plugins/<id>/public/, anything else from the core public/. Plugin ids are
// URL-safe (no %-encoding), so the raw segment compares directly to the id set; serveStatic decodes
// and traversal-guards the sub-path as before.
export function routePublic(restPath: string, publicDir: string, pluginsDir: string, pluginIds: Set<string>): StaticRoute {
const slash = restPath.indexOf("/");
const first = slash === -1 ? restPath : restPath.slice(0, slash);
if (pluginIds.has(first)) {
return { dir: join(pluginsDir, first, "public"), subPath: slash === -1 ? "" : restPath.slice(slash + 1) };
}
return { dir: publicDir, subPath: restPath };
}
function plain(res: ServerResponse, status: number, body: string): void {
res.writeHead(status, { "content-type": "text/plain; charset=utf-8" }).end(body);
}
// onError handles a mid-stream read failure (headers already sent); defaults to console.error so
// static.ts stays standalone, while app.ts passes the request logger for structured output.
export async function serveStatic(dir: string, requestedPath: string, res: ServerResponse, head = false, onError: (err: Error) => void = (err) => console.error(err)): Promise<void> {
let decoded: string;
try {
decoded = decodeURIComponent(requestedPath);
} catch {
return plain(res, 400, "Bad Request");
}
const filePath = resolveStaticPath(dir, decoded);
if (filePath === null) return plain(res, 403, "Forbidden");
try {
const info = await stat(filePath);
if (!info.isFile()) return plain(res, 404, "Not Found");
res.writeHead(200, { "content-length": info.size, "content-type": contentTypeFor(filePath) });
if (head) return void res.end(); // headers only — skip opening the file
// Headers are already sent, so a mid-stream read error can't become an HTTP status —
// log and destroy the response to signal a truncated body, not a hung socket.
createReadStream(filePath)
.on("error", (err) => {
onError(err);
res.destroy();
})
.pipe(res);
} catch {
plain(res, 404, "Not Found");
}
}