// The single server-side gate. Runs for EVERY request to the deployed Bun/Nitro server
// (pages, the /api proxy, everything) before routing. Unauthenticated requests are
// redirected to /login (page navigations) or rejected 401 (/api). Fails CLOSED if
// PUNKTFUNK_UI_PASSWORD is unset, so a misconfigured LAN-exposed server admits no one.
import {
defineEventHandler,
getRequestHeader,
getRequestURL,
type H3Event,
sendRedirect,
setResponseHeader,
setResponseStatus,
useSession,
} from "h3";
import {
isPublicPath,
type SessionData,
sessionConfig,
sessionEpoch,
uiPassword,
} from "../util/auth";
import {
consoleOriginPort,
isPluginUiPath,
listenerOf,
} from "../util/pluginOrigin";
export default defineEventHandler(async (event) => {
const { pathname } = getRequestURL(event);
const listener = listenerOf(event);
const isPluginPath = isPluginUiPath(pathname);
// ── the origin split (2026-08-05 review H-3) ────────────────────────────────────────────────
//
// Plugin UIs live on their own origin (see nitro-entry/bun-https.mjs). Enforcing that is two
// refusals, and BOTH are load-bearing:
//
// - the console origin must not serve `/plugin-ui/**`, or the old same-origin path still works
// and nothing has changed;
// - the plugin origin must not serve anything ELSE — above all not `/api/**`. Plugin JS is
// same-origin with the plugin listener, so if that listener proxied `/api/**` the BFF would
// attach the operator's admin bearer to the plugin's own fetch and hand back exactly the
// escalation we just moved.
//
// Unconditional, not conditional on the plugin listener having bound: if it did not, plugin UIs
// are disabled and refusing here is the correct answer, not a reason to fall back. (`vite dev`
// serves one origin, but its own middleware answers `/plugin-ui` before Nitro is reached, so
// this never fires there.)
if (listener === "console" && isPluginPath) {
setResponseStatus(event, 404);
return { error: "plugin UIs are served from their own origin" };
}
if (listener === "plugin" && !isPluginPath) {
setResponseStatus(event, 404);
return { error: "this origin serves plugin UIs only" };
}
// Baseline response headers for everything this server emits. Deliberately modest: a plugin's
// own UI is third-party code we don't control, so a script-src policy tight enough to be worth
// having would break the pages it serves. What is safe to assert unconditionally still closes
// the cheap holes:
// nosniff — a plugin serving text/plain that "looks like" HTML can't be sniffed into it
// frame-ancestors— who may frame this; see below, it differs per origin
// object-src — no Flash/applet embedding anywhere
// base-uri — a stray can't repoint every relative URL on the page
// Referrer-Policy— never leak a console path (which can carry ids) to an external homepage link
setResponseHeader(event, "X-Content-Type-Options", "nosniff");
setResponseHeader(event, "Referrer-Policy", "no-referrer");
// `frame-ancestors 'self'` is right for the console and WRONG for the plugin origin: 'self'
// there means the plugin origin, and the console — now a different origin — is precisely who
// needs to frame it. So the plugin origin names the console explicitly, and nobody else.
setResponseHeader(
event,
"Content-Security-Policy",
`frame-ancestors ${listener === "plugin" ? consoleFrameAncestor(event) : "'self'"}; object-src 'none'; base-uri 'self'`,
);
// Same-origin check for every MUTATING request (defense in depth beyond SameSite=Lax,
// added with the update-apply route where CSRF ≈ code execution — design
// host-update-from-web-console.md §4.3). `Sec-Fetch-Site` is browser-set and unforgeable
// from a page; absent (curl, very old browsers) ⇒ allowed — the console's threat here is
// a BROWSER being ridden cross-site, and every riding browser sends the header.
// `same-site` is rejected too: with an IP-address origin, another port on the same box
// counts as same-site, and nothing on another port has business mutating the console.
// Applies to public paths as well (login CSRF), before any session logic.
const method = event.method?.toUpperCase?.() ?? "GET";
if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {
const site = getRequestHeader(event, "sec-fetch-site")?.toLowerCase();
if (site && site !== "same-origin" && site !== "none") {
setResponseStatus(event, 403);
return { error: "cross-site request refused" };
}
}
if (isPublicPath(pathname)) return;
// Misconfigured: refuse everything rather than serve open on the LAN.
if (!uiPassword()) {
setResponseStatus(event, 503);
return { error: "auth not configured: set PUNKTFUNK_UI_PASSWORD" };
}
const session = await useSession(event, sessionConfig());
// The epoch check is what makes logout mean something: a cookie sealed before the last
// revocation unseals fine but no longer matches, so it is refused like any other bad session.
if (session.data.authenticated && session.data.epoch === sessionEpoch())
return; // authenticated — let it through
if (pathname.startsWith("/api")) {
setResponseStatus(event, 401);
return { error: "unauthorized" };
}
// The plugin origin has no /login to bounce to — it serves plugin UIs and nothing else, so a
// redirect there would land on this middleware's own 404. Answer plainly instead; the console
// probes plugin liveness server-side and renders the session-expired state itself.
if (listener === "plugin") {
setResponseStatus(event, 401);
return { error: "unauthorized" };
}
// Page navigation → bounce to the login screen, remembering where they were headed.
return sendRedirect(
event,
`/login?next=${encodeURIComponent(pathname)}`,
302,
);
});
/**
* The console origin, as a `frame-ancestors` source, derived from the request the PLUGIN origin is
* answering: same scheme and hostname (whatever name the operator actually browsed to — an IP, an
* mDNS name, a hostname — so the policy matches their address bar), the console's port.
*
* Falls back to `'none'` rather than `'self'` or `*` when the console port is unknown: an unframable
* plugin page is a visible, harmless failure, and the alternatives are a policy that either does
* nothing or lets any page on the LAN frame a logged-in plugin UI.
*/
function consoleFrameAncestor(event: H3Event): string {
const port = consoleOriginPort();
if (!port) return "'none'";
const url = getRequestURL(event);
return `${url.protocol}//${url.hostname}:${port}`;
}