Files
punktfunk/web/server/middleware/auth.ts
T
enricobuehler defdfbdb58
ci / web (pull_request) Successful in 1m2s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m4s
ci / docs-site (pull_request) Successful in 2m13s
android / android (pull_request) Successful in 3m16s
ci / rust (pull_request) Failing after 3m36s
fix(security): plugin UIs get their own origin
Closes H-3 of the 2026-08-05 review, the last of its six highs. A plugin's
interface was reverse-proxied onto the console's own origin and framed with
`allow-same-origin`, so plugin JS ran as first-party code on that origin: one
`fetch('/api/**', {credentials:'same-origin'})` and the BFF attached the
operator's ADMIN bearer. That reached everything `plugin_may_access` withholds
— arm pairing, read the host PIN, approve a device, read `/hooks`. The "open
in new tab" link was the same escalation with no iframe involved at all.

The fix is not a sandbox attribute, and it is worth writing down why, because
the obvious change is the one that does not work. Dropping `allow-same-origin`
gives the frame an OPAQUE origin; its subresource requests are then cross-site;
the `SameSite=Lax` session cookie stops being sent; every plugin asset 302s to
/login and the frame is blank. Nothing about the new-tab link is helped either.

So the origin moves instead. A second listener on its own port (default
PORT + 1) serves plugin UIs and nothing else:

  different ORIGIN — scheme+host+PORT — so the same-origin policy separates the
                     plugin from the console: it cannot read the console's DOM,
                     its cross-origin fetch of /api/** is unreadable (no CORS)
                     and cannot mutate (Sec-Fetch-Site sees same-site).
  same SITE        — cookie scope ignores the port and SameSite is computed on
                     the site, so the session cookie still reaches the plugin
                     listener and plugin pages keep working.

Enforcement is two refusals and both are load-bearing: the console origin
refuses /plugin-ui/**, and the plugin origin refuses everything ELSE — above
all /api/**, which would otherwise hand the admin bearer right back to plugin
JS that is now same-origin with that listener. Both are unconditional: if the
plugin port cannot be bound, plugin UIs are DISABLED and the console says so,
rather than falling back to the arrangement this exists to remove.

Two consequences that would otherwise bite in the field:

  The port has to be open. Done for the Windows netsh rule, the firewalld
  service and the ufw profile.

  A browser stores a self-signed-certificate exception per ORIGIN, including
  the port — and a certificate interstitial can never be shown inside an
  iframe, so the frame would just sit blank with nothing on screen explaining
  why. A `no-cors` probe distinguishes it (a TLS failure rejects; any HTTP
  answer, even 401, resolves) and the console renders a card linking the
  operator to open the port once in a real tab.

Also here: the health probe moved server-side to the console origin (it used
to rely on being same-origin with the plugin), the postMessage listener now
verifies `event.origin` — a real check rather than a tautology — and
plugin-kit's `postMessage(..., "*")` is documented as load-bearing, since
narrowing it to `location.origin` would now target the plugin's own origin and
silently drop every message.

Verified against a running console with a fake mgmt API and a fake plugin:
console /plugin-ui/** → 404; plugin-origin /api/v1/hooks, /, /login,
/_auth/logout → 404; plugin page loads 200 through its own origin;
unauthenticated plugin origin → 401 (not a redirect to a /login it does not
serve); a forged x-pf-listener header changes nothing on either listener; the
plugin's own Clear-Site-Data / Access-Control-Allow-Origin / Set-Cookie are
dropped by the proxy allowlist; the plugin origin's CSP names the console as
its only frame-ancestors source; and with the port squatted, ui-config reports
`unavailable`, the console still refuses /plugin-ui/**, and the console itself
keeps working.

Still wants on-glass confirmation in a real browser — the cookie and framing
behaviour is reasoned from spec, not observed.

cargo fmt --all --check clean; cargo check -p punktfunk-host --all-targets
green on Windows; web console builds and typechecks.
2026-08-05 17:50:04 +02:00

143 lines
6.6 KiB
TypeScript

// 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 <base> 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<SessionData>(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}`;
}