fix(security): plugin UIs get their own origin
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

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.
This commit is contained in:
2026-08-05 17:50:04 +02:00
parent 8103958169
commit defdfbdb58
14 changed files with 542 additions and 82 deletions
+43 -31
View File
@@ -655,39 +655,51 @@ fn web_setup(args: &[String]) -> Result<()> {
server.display()
);
}
// 4. firewall: inbound TCP 47992. The console serves HTTPS (HTTP/1.1 over TLS) with the host's
// identity cert. (No UDP/HTTP-3: browsers won't use QUIC against a self-signed/no-SAN cert.)
// Scoped to the same profiles as the streaming ports — Domain + Private by default, Public
// only with `--allow-public-network`. Delete any prior rule first so an upgrade re-scopes it
// instead of stacking a second (possibly all-profiles) rule behind the new one.
// 4. firewall: inbound TCP 47992 (console) and 47993 (plugin UIs). The console serves HTTPS
// (HTTP/1.1 over TLS) with the host's identity cert. (No UDP/HTTP-3: browsers won't use QUIC
// against a self-signed/no-SAN cert.) Scoped to the same profiles as the streaming ports —
// Domain + Private by default, Public only with `--allow-public-network`. Delete any prior
// rule first so an upgrade re-scopes it instead of stacking a second (possibly all-profiles)
// rule behind the new one.
//
// 47993 is a SEPARATE ORIGIN, not a second copy of the console: plugin UIs are served there
// precisely so a plugin cannot act as the logged-in operator on the console's origin
// (security-review 2026-08-05 H-3). Same host, same certificate, different port — which is
// what makes it a different origin to the browser while staying same-site for the session
// cookie. Without this rule, plugin interfaces simply do not load from another device.
let fw_profile =
crate::service::firewall_profile_arg(crate::service::allow_public_network(args)?);
run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"delete",
"rule",
"name=Punktfunk web console (TCP 47992)",
],
);
if !run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"add",
"rule",
"name=Punktfunk web console (TCP 47992)",
"dir=in",
"action=allow",
"protocol=TCP",
"localport=47992",
fw_profile,
],
) {
eprintln!("warning: could not add the firewall rule for TCP 47992");
for (name, port) in [
("Punktfunk web console (TCP 47992)", "47992"),
("Punktfunk plugin UIs (TCP 47993)", "47993"),
] {
run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"delete",
"rule",
&format!("name={name}"),
],
);
if !run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"add",
"rule",
&format!("name={name}"),
"dir=in",
"action=allow",
"protocol=TCP",
&format!("localport={port}"),
fw_profile,
],
) {
eprintln!("warning: could not add the firewall rule for TCP {port}");
}
}
// No start step: the PunktfunkHost service supervises the console and starts it the moment the
// host has written the files it needs (mgmt token + identity cert/key) — there is nothing an
+10 -1
View File
@@ -6,7 +6,8 @@
Installed to /usr/lib/firewalld/services/ by the punktfunk-host package. NOT enabled automatically
(packages never touch the admin's firewall). Only useful if you installed the console (punktfunk-web)
AND want to reach it from another device on the LAN — the console binds all interfaces on TCP 47992
(HTTPS, login-gated). The streaming host itself does not need this open; enable it deliberately with
(HTTPS, login-gated), and serves plugin UIs from a SEPARATE ORIGIN on TCP 47993 (see below).
The streaming host itself does not need this open; enable it deliberately with
firewall-cmd (add-service=punktfunk-web, then reload). CachyOS/Ubuntu: use the ufw punktfunk-web
profile instead.
@@ -18,4 +19,12 @@
<short>Punktfunk web console</short>
<description>The optional punktfunk management web console (device pairing, status, GPU selection, performance graphs) over HTTPS. Open only if you run the punktfunk-web package and want the console reachable from other devices on the LAN.</description>
<port protocol="tcp" port="47992"/> <!-- HTTPS web console (login-gated) -->
<!--
Plugin UIs, on their OWN ORIGIN. Not a second console: a plugin's interface is third-party code,
and serving it on the console's origin let it act as the logged-in operator (security-review
2026-08-05 H-3). Same host, same certificate, different port — a different origin to the browser,
but still same-site, so the session cookie reaches it. Login-gated exactly like the console.
Only needed if you use plugins that ship a UI and want to reach them from another device.
-->
<port protocol="tcp" port="47993"/> <!-- HTTPS plugin UIs (login-gated, separate origin) -->
</service>
+10 -3
View File
@@ -36,8 +36,15 @@ ports=47984,47989,48010/tcp|47998:48010/udp|5353/udp
# Run the host with `--mgmt-bind 127.0.0.1:47990` to keep 47990 loopback-only (then don't open it).
#
# The optional web console (the separate punktfunk-web package). Open only if you installed it and
# want to reach it from another device — it binds all interfaces on TCP 47992 (HTTPS, login-gated).
# want to reach it from another device — it binds all interfaces on TCP 47992 (HTTPS, login-gated),
# and serves plugin UIs from a SEPARATE ORIGIN on TCP 47993.
#
# 47993 is not a second console. A plugin's interface is third-party code, and serving it on the
# console's own origin let it act as the logged-in operator (security-review 2026-08-05 H-3). Same
# host, same certificate, different port: a different ORIGIN to the browser, so the same-origin
# policy is the boundary — but still the same SITE, so the login session still reaches it. It is
# login-gated exactly like the console, and only needed for plugins that ship a UI.
[punktfunk-web]
title=punktfunk web console
description=The optional punktfunk management web console (HTTPS, login-gated) reachable from the LAN
ports=47992/tcp
description=The optional punktfunk management web console (HTTPS, login-gated) reachable from the LAN, plus the separate-origin port its plugin UIs are served on
ports=47992,47993/tcp
+13 -2
View File
@@ -21,14 +21,25 @@ export const resolvePluginBase = (): string => {
export const useIsEmbedded = (): boolean =>
typeof window !== "undefined" && window.parent !== window;
/** Mirror a route into the console's address bar (best-effort, embedded only). */
/**
* Mirror a route into the console's address bar (best-effort, embedded only).
*
* The `"*"` target origin is load-bearing and must stay: the console frames plugin UIs from a
* DIFFERENT ORIGIN than its own (they get their own port, so a plugin cannot act as the logged-in
* operator — security-review 2026-08-05 H-3). Narrowing this to `window.location.origin` would
* target the PLUGIN's origin, not the console's, and every message would be silently dropped.
*
* `"*"` is safe here because the payload is a route path the plugin itself just navigated to —
* nothing secret — and the console verifies `event.origin` against the plugin origin before acting
* on it, so the trust decision is made on the receiving side where it belongs.
*/
export const postNavigate = (path: string): void => {
try {
if (window.parent !== window) {
window.parent.postMessage({ type: "pf-ui:navigate", path }, "*");
}
} catch {
// cross-origin parent or detached — deep-link sync is best-effort
// detached parent — deep-link sync is best-effort
}
};
+13
View File
@@ -44,3 +44,16 @@ PUNKTFUNK_UI_SECURE=1
# The Bun server binds these (standard Nitro env):
# PORT=47992
# HOST=0.0.0.0
# The port plugin UIs are served on — their OWN ORIGIN, not the console's. Defaults to PORT + 1.
#
# This is a security boundary, not a layout choice. A plugin's interface is third-party code; served
# on the console's origin it ran as first-party script with the operator's session and could drive
# the whole admin API (security-review 2026-08-05 H-3). Same host, same certificate, different port
# means a different ORIGIN to the browser (so the same-origin policy separates them) while staying
# the same SITE (so the SameSite=Lax session cookie still reaches it and plugin pages keep working).
#
# The console refuses to serve plugin UIs on its own origin, so if this port cannot be bound, plugin
# UIs are DISABLED rather than silently moved back — the console says so on the plugin page.
# Open it in the firewall alongside PORT if you reach the console from other devices.
# PUNKTFUNK_UI_PLUGIN_PORT=47993
+5
View File
@@ -10,6 +10,11 @@
"nav_library": "Bibliothek",
"nav_plugins": "Plugins",
"plugin_offline_title": "Dieses Plugin läuft nicht",
"plugin_origin_untrusted_title": "Port dieses Plugins einmal best\u00e4tigen",
"plugin_origin_untrusted_hint": "Plugin-Oberfl\u00e4chen laufen auf einem eigenen Port, damit ein Plugin nicht in deinem Namen auf der Konsole handeln kann. Dein Browser vertraut dem Zertifikat dieses Hosts f\u00fcr den Konsolen-Port, aber noch nicht f\u00fcr diesen — und in einem Frame kann er nicht nachfragen. \u00d6ffne ihn einmal in einem Tab, best\u00e4tige das Zertifikat und komm zur\u00fcck.",
"plugin_origin_untrusted_open": "In neuem Tab \u00f6ffnen",
"plugin_origin_unavailable_title": "Plugin-Oberfl\u00e4chen sind nicht verf\u00fcgbar",
"plugin_origin_unavailable_hint": "Plugin-Oberfl\u00e4chen laufen auf einem eigenen Port, damit ein Plugin nicht in deinem Namen auf der Konsole handeln kann. Dieser Port konnte nicht ge\u00f6ffnet werden, deshalb bleiben sie deaktiviert. Sieh ins Konsolen-Log, setze dann PUNKTFUNK_UI_PLUGIN_PORT auf einen freien Port und starte neu.",
"plugin_offline_hint": "Starte den Scripting-Runner und versuche es erneut.",
"plugin_retry": "Erneut versuchen",
"plugin_open_new_tab": "In neuem Tab öffnen",
+5
View File
@@ -54,6 +54,11 @@
"nav_more": "More",
"nav_plugins": "Plugins",
"plugin_offline_title": "This plugin isn't running",
"plugin_origin_untrusted_title": "Trust this plugin's port once",
"plugin_origin_untrusted_hint": "Plugin interfaces run on their own port so a plugin can't act as you on the console. Your browser trusts this host's certificate for the console's port but not yet for theirs, and it can't ask you inside a frame. Open it once in a tab, accept the certificate, then come back.",
"plugin_origin_untrusted_open": "Open in a new tab",
"plugin_origin_unavailable_title": "Plugin interfaces are unavailable",
"plugin_origin_unavailable_hint": "Plugin interfaces are served on their own port so a plugin can't act as you on the console. That port could not be opened, so they stay switched off. Check the console log, then set PUNKTFUNK_UI_PLUGIN_PORT to a free port and restart.",
"plugin_offline_hint": "Start the scripting runner, then retry.",
"plugin_retry": "Retry",
"plugin_open_new_tab": "Open in new tab",
+70 -2
View File
@@ -14,10 +14,13 @@
// (a local CA installed per device) fronted by a server that speaks them (e.g. Caddy) — deliberately
// out of scope for a LAN console; TLS (no cleartext login/session) is the win.
//
// TWO LISTENERS, on purpose — see `PLUGIN ORIGIN` below.
//
// Env (set by the launchers / the systemd unit — see web.env.example):
// PUNKTFUNK_UI_TLS_CERT / _KEY PEM file paths (the host's cert.pem / key.pem). BOTH set ⇒ HTTPS.
// Unset ⇒ plain HTTP (local dev only).
// PORT / HOST standard Nitro bind (3000 / 0.0.0.0).
// PUNKTFUNK_UI_PLUGIN_PORT the plugin-UI origin's port (default: console port + 1).
import "#nitro-internal-pollyfills";
import wsAdapter from "crossws/adapters/bun";
import { useNitroApp } from "nitropack/runtime";
@@ -40,6 +43,37 @@ const ws = import.meta._websocket
// Read back by `peerAddress()` in server/util/auth.ts — keep the two names in sync.
const PEER_IP_HEADER = "x-pf-peer-ip";
// PLUGIN ORIGIN — which listener a request arrived on, stamped the same unforgeable way.
//
// A plugin's UI used to be reverse-proxied onto the CONSOLE's own origin and framed with
// `allow-same-origin`, which means plugin JS ran as first-party code on the console origin: it
// could `fetch('/api/**', {credentials:'same-origin'})` and the BFF would attach the operator's
// ADMIN mgmt bearer. That reached everything `plugin_may_access` withholds — arm pairing, read the
// host PIN, approve a device, read `/hooks` — i.e. any plugin was one line of JS away from full
// operator admin (2026-08-05 review H-3). The "open in new tab" link was the same escalation with
// no iframe involved at all, so no sandbox attribute could have fixed it.
//
// The fix is to make the browser's own same-origin policy the boundary, by serving plugin UIs from
// a DIFFERENT ORIGIN: a second listener on its own port.
//
// different ORIGIN — scheme+host+PORT — so SOP applies: plugin JS cannot read the console's DOM,
// and its cross-origin `fetch` of `/api/**` is unreadable (we emit no CORS) and
// unable to mutate (the Sec-Fetch-Site guard sees `same-site`, not
// `same-origin`).
// same SITE — because a cookie's scope ignores the port, and SameSite is computed on the
// site, not the origin. So the `SameSite=Lax` session cookie still flows to the
// plugin origin, and plugin pages keep loading their assets while logged in.
//
// That combination is why this works and why the obvious alternative does not: dropping
// `allow-same-origin` gives the frame an OPAQUE origin, which makes its subresource requests
// cross-site, which stops the Lax cookie, which 302s every plugin asset to /login — a blank frame.
//
// The console listener refuses `/plugin-ui/**` and the plugin listener refuses everything else
// (server/middleware/auth.ts). Both halves matter: without the first the old path still works;
// without the second, plugin JS could call `/api/**` on its OWN origin and get the admin bearer
// attached right back.
const LISTENER_HEADER = "x-pf-listener";
// TLS from the host's identity cert (file PATHS → Bun.file, not PEM-in-env). Absent ⇒ plain HTTP.
const certPath = process.env.PUNKTFUNK_UI_TLS_CERT;
const keyPath = process.env.PUNKTFUNK_UI_TLS_KEY;
@@ -76,8 +110,8 @@ if (!tls && secureFlag) {
process.exit(1);
}
const server = Bun.serve({
port: process.env.NITRO_PORT || process.env.PORT || 3000,
/** The shared `Bun.serve` options both listeners use — only the port and the stamped lane differ. */
const listenerOptions = (lane) => ({
host: process.env.NITRO_HOST || process.env.HOST,
// Bun defaults this to 10 s, which is SHORTER than the host's 15 s SSE keep-alive comment — so a
// proxied `/api/v1/events` stream (or any other quiet long-lived response) gets cut by us and
@@ -108,8 +142,10 @@ const server = Bun.serve({
// Strip any client-supplied value BEFORE stamping the real one (see PEER_IP_HEADER).
const headers = new Headers(req.headers);
headers.delete(PEER_IP_HEADER);
headers.delete(LISTENER_HEADER);
const peer = server.requestIP(req)?.address;
if (peer) headers.set(PEER_IP_HEADER, peer);
headers.set(LISTENER_HEADER, lane);
return nitroApp.localFetch(url.pathname + url.search, {
host: url.hostname,
protocol: url.protocol,
@@ -120,7 +156,39 @@ const server = Bun.serve({
});
},
});
const consolePort = Number(process.env.NITRO_PORT || process.env.PORT || 3000);
const server = Bun.serve({ ...listenerOptions("console"), port: consolePort });
console.log(`punktfunk web console listening on ${server.url} (tls=${!!tls})`);
// The plugin-UI origin. Its own port, everything else identical.
//
// A bind failure does NOT fall back to serving plugin UIs on the console origin — that is the hole
// this exists to close, and a security boundary that disappears when a port is busy is not one. It
// degrades to "plugin UIs unavailable": the console reads the state below and renders an
// explanation instead of a frame, and everything else about the console keeps working.
const pluginPort = Number(process.env.PUNKTFUNK_UI_PLUGIN_PORT || consolePort + 1);
let pluginServer;
try {
pluginServer = Bun.serve({ ...listenerOptions("plugin"), port: pluginPort });
// Read back by the app (server/util/pluginOrigin.ts) — same process, so process.env is the
// simplest channel, and it is only ever SET here, never trusted from the environment we started
// with (a stale inherited value would otherwise advertise a port nothing is listening on).
process.env.PUNKTFUNK_UI_PLUGIN_PORT_ACTIVE = String(pluginPort);
process.env.PUNKTFUNK_UI_CONSOLE_PORT_ACTIVE = String(consolePort);
console.log(
`punktfunk plugin-UI origin listening on ${pluginServer.url} (tls=${!!tls})`,
);
} catch (e) {
delete process.env.PUNKTFUNK_UI_PLUGIN_PORT_ACTIVE;
console.error(
`punktfunk web console: could not bind the plugin-UI origin on port ${pluginPort} ` +
`(${e?.message ?? e}). Plugin UIs are DISABLED until this is resolved — they are ` +
"deliberately not served on the console's own origin, because a plugin sharing that " +
"origin can act as the logged-in operator. Set PUNKTFUNK_UI_PLUGIN_PORT to a free port.",
);
}
if (import.meta._tasks) {
startScheduleRunner();
}
+64 -5
View File
@@ -6,6 +6,7 @@ import {
defineEventHandler,
getRequestHeader,
getRequestURL,
type H3Event,
sendRedirect,
setResponseHeader,
setResponseStatus,
@@ -18,25 +19,60 @@ import {
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 proxied onto THIS origin (/plugin-ui/**), so a script-src policy tight enough to be
// worth having would break third-party plugin pages we don't control. What is safe to assert
// unconditionally still closes the cheap holes:
// 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— only our own pages may frame the console (the plugin iframes are same-origin)
// 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 'self'; object-src 'none'; base-uri 'self'",
`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,
@@ -74,6 +110,13 @@ export default defineEventHandler(async (event) => {
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,
@@ -81,3 +124,19 @@ export default defineEventHandler(async (event) => {
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}`;
}
+32
View File
@@ -0,0 +1,32 @@
// GET /_auth/ui-config — the handful of deployment facts the console UI cannot work out for itself.
//
// Today that is exactly one: where plugin UIs live. They are served from a different ORIGIN than
// the console (2026-08-05 review H-3), so the browser needs the port to build the iframe URL — and
// it must come from the server, because only the server knows whether that listener actually bound.
//
// Public (the `/_auth/` prefix is), which is fine: a port number is discoverable by connecting to
// it, and nothing here is a secret. Deliberately NOT an inference the client makes for itself
// (`location.port + 1` would silently point at whatever else is on that port).
import { defineEventHandler } from "h3";
import { pluginOriginPort } from "../../util/pluginOrigin";
export interface UiConfig {
/**
* How plugin UIs are reachable:
* - `origin` from their own origin on `pluginPort` (the deployed, secure arrangement)
* - `same-origin` `vite dev` only: one listener, and its own middleware serves `/plugin-ui`
* - `unavailable` the plugin listener could not bind. Plugin UIs are OFF; the console must
* not fall back to its own origin, which is the hole this all exists to close.
*/
pluginUi: "origin" | "same-origin" | "unavailable";
pluginPort: number | null;
}
export default defineEventHandler((): UiConfig => {
const port = pluginOriginPort();
if (port) return { pluginUi: "origin", pluginPort: port };
// `import.meta.dev` is Nitro's build-time dev flag — false in every shipped build, so a
// production bind failure can never resolve to the same-origin arrangement.
if (import.meta.dev) return { pluginUi: "same-origin", pluginPort: null };
return { pluginUi: "unavailable", pluginPort: null };
});
@@ -0,0 +1,41 @@
// GET /_plugin-health/<id> — is this plugin's UI actually up?
//
// The console needs this to decide between mounting the iframe and showing the offline card. It
// used to be a browser `fetch('/plugin-ui/<id>/__health')`, which worked only because plugin UIs
// were same-origin with the console — the very arrangement 2026-08-05 review H-3 removed. From a
// separate origin the browser could not read the answer without us serving CORS, so the probe moved
// here, to the console's own origin, and is done server-side.
//
// Session-gated like every other console route (it is not under a public prefix), so an
// unauthenticated LAN peer cannot enumerate which plugins are running.
import { defineEventHandler, getRouterParam, setResponseStatus } from "h3";
import { fetchUiCredential, PLUGIN_ID_RE } from "../../util/pluginProxy";
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id") ?? "";
if (!PLUGIN_ID_RE.test(id)) {
setResponseStatus(event, 400);
return { ok: false, error: "not a valid plugin id" };
}
const cred = await fetchUiCredential(id);
if (!cred) {
setResponseStatus(event, 502);
return { ok: false, error: `plugin "${id}" is not running` };
}
try {
// The plugin's UI server is loopback-only and plain HTTP, exactly as the proxy dials it.
const resp = await fetch(`http://127.0.0.1:${cred.port}/__health`, {
headers: { authorization: `Bearer ${cred.secret}` },
redirect: "manual",
});
if (!resp.ok) {
setResponseStatus(event, 502);
return { ok: false, error: `health ${resp.status}` };
}
return { ok: true };
} catch {
// Port died between the credential lookup and the probe (plugin restarting).
setResponseStatus(event, 502);
return { ok: false, error: `plugin "${id}" is not reachable` };
}
});
+51
View File
@@ -0,0 +1,51 @@
// Which listener a request arrived on, and where plugin UIs live.
//
// Plugin UIs are served from a DIFFERENT ORIGIN than the console (a second listener on its own
// port — see nitro-entry/bun-https.mjs for why). Two things need to know about that split: the
// gate, which enforces that neither origin serves the other's paths, and the console UI, which has
// to build the iframe URL against the right origin.
import type { H3Event } from "h3";
import { getRequestHeader } from "h3";
/** Set by the server entry on every request; any inbound copy is stripped first. */
const LISTENER_HEADER = "x-pf-listener";
export type Listener = "console" | "plugin";
/**
* Which listener served this request. Absent `console`, which is the safe default: it is what
* `vite dev` looks like (one listener, and its own middleware intercepts `/plugin-ui` before Nitro
* ever sees it), and treating an unknown lane as the console means the plugin-path refusal below
* applies rather than the console-path one deny the escalation, not the ordinary console.
*/
export function listenerOf(event: H3Event): Listener {
return getRequestHeader(event, LISTENER_HEADER) === "plugin"
? "plugin"
: "console";
}
/** Paths the plugin origin serves. Everything else on that origin is refused. */
export function isPluginUiPath(pathname: string): boolean {
return pathname === "/plugin-ui" || pathname.startsWith("/plugin-ui/");
}
/**
* The port the plugin-UI origin is listening on, or `null` when there is none either the bind
* failed (production: plugin UIs are disabled, deliberately, rather than falling back to the
* console origin) or this is `vite dev`, which serves everything from one port.
*
* Read from the value the entry SETS after a successful bind, never from the configured-but-unbound
* one, so this can never advertise a port nothing is listening on.
*/
export function pluginOriginPort(): number | null {
const raw = process.env.PUNKTFUNK_UI_PLUGIN_PORT_ACTIVE;
const port = raw ? Number(raw) : Number.NaN;
return Number.isInteger(port) && port > 0 ? port : null;
}
/** The console's own port, for the plugin origin's `frame-ancestors`. */
export function consoleOriginPort(): number | null {
const raw = process.env.PUNKTFUNK_UI_CONSOLE_PORT_ACTIVE;
const port = raw ? Number(raw) : Number.NaN;
return Number.isInteger(port) && port > 0 ? port : null;
}
+49
View File
@@ -0,0 +1,49 @@
// Where plugin UIs live, from the server that knows.
//
// Plugin UIs are served from a DIFFERENT ORIGIN than the console (2026-08-05 review H-3): same
// scheme and host, its own port. The console has to build iframe and new-tab URLs against that
// origin, and the port has to come from the server — only it knows whether the listener bound.
import { useQuery } from "@tanstack/react-query";
export interface UiConfig {
pluginUi: "origin" | "same-origin" | "unavailable";
pluginPort: number | null;
}
/**
* Deployment facts the console cannot infer. Cached for the session the ports cannot change
* without the server restarting, which reloads the page anyway.
*/
export const useUiConfig = () =>
useQuery({
queryKey: ["ui-config"],
queryFn: async (): Promise<UiConfig> => {
const r = await fetch("/_auth/ui-config", {
credentials: "same-origin",
});
if (!r.ok) throw new Error(`ui-config ${r.status}`);
return (await r.json()) as UiConfig;
},
staleTime: Number.POSITIVE_INFINITY,
retry: 2,
});
/**
* The origin serving plugin UIs, or `null` when there is none and the console must say so rather
* than render a frame.
*
* Built from the CURRENT location's scheme and hostname, so it follows whatever address the
* operator actually browsed to an IP, an mDNS name, a hostname and only the port differs. That
* matters for more than cosmetics: it keeps the origin same-SITE with the console, which is what
* lets the `SameSite=Lax` session cookie reach the plugin listener at all.
*/
export function pluginOriginFrom(
config: UiConfig | undefined,
): string | null | undefined {
if (!config) return undefined; // still loading — render neither frame nor error
if (config.pluginUi === "same-origin") return ""; // vite dev: relative URLs, one origin
if (config.pluginUi === "origin" && config.pluginPort) {
return `${window.location.protocol}//${window.location.hostname}:${config.pluginPort}`;
}
return null; // unavailable — the listener did not bind
}
+136 -38
View File
@@ -1,14 +1,20 @@
// A plugin's UI, embedded in the console (plugin-ui-surface §5). We probe the plugin's liveness
// first and only mount the iframe when it answers — otherwise the iframe would show the proxy's raw
// 502. The iframe is same-origin (proxied through /plugin-ui), so the plugin can talk to its own
// loopback REST with the operator's session and, optionally, keep the address bar in sync by posting
// `{ type: "pf-ui:navigate", path }` to the parent.
// 502.
//
// The iframe is CROSS-ORIGIN: plugin UIs are served from their own origin (same scheme and host,
// its own port — see nitro-entry/bun-https.mjs and 2026-08-05 review H-3). The plugin can still talk
// to its own loopback REST with the operator's session, because that origin is same-SITE and the
// `SameSite=Lax` cookie reaches it; what it can no longer do is read or drive the console. It may
// still keep the address bar in sync by posting `{ type: "pf-ui:navigate", path }` to the parent —
// now verified against the plugin origin before it is honoured.
import { useQuery } from "@tanstack/react-query";
import { getRouteApi, useNavigate } from "@tanstack/react-router";
import { ExternalLink, RefreshCw } from "lucide-react";
import { type FC, useEffect, useMemo, useRef } from "react";
import { pluginIcon, usePlugins } from "@/api/plugins";
import { useInstalledPlugins } from "@/api/store";
import { pluginOriginFrom, useUiConfig } from "@/api/uiConfig";
import { Button } from "@/components/ui/button";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
@@ -34,14 +40,21 @@ export const SectionPlugin: FC = () => {
const { data: installed } = useInstalledPlugins();
const provenance = installed?.find((p) => p.plugin_id === pluginId);
// Where plugin UIs are served from. `undefined` = still resolving, `null` = the plugin listener
// did not bind, so there is nowhere safe to render this and we say so instead of falling back to
// the console's own origin — that fallback IS the vulnerability.
const { data: uiConfig } = useUiConfig();
const pluginOrigin = pluginOriginFrom(uiConfig);
// Liveness: a 200 from /__health means the plugin is up.
//
// Two subtleties, both learned the hard way:
//
// - A 200 is not enough. `fetch` follows redirects, so an expired session — where the gate
// answers 302 → /login → 200 HTML — looked exactly like a healthy plugin, and the console
// rendered its own login page inside the plugin's iframe. `redirect: "manual"` makes that
// an opaque response we can reject instead.
// rendered its own login page inside the plugin's iframe. This now asks the CONSOLE origin,
// which probes the plugin server-side (the plugin origin is cross-origin to us and would need
// CORS to be readable from here) — and a bounced session is a plain 401, not HTML.
// - One failure must not be terminal. The runner is restarted at the end of every successful
// install, so a single missed probe is routine; giving up on the first one threw away
// whatever the operator had open in another plugin. Retry a few times, and keep probing on a
@@ -49,11 +62,11 @@ export const SectionPlugin: FC = () => {
const health = useQuery({
queryKey: ["plugin-health", pluginId],
queryFn: async () => {
const r = await fetch(`/plugin-ui/${pluginId}/__health`, {
const r = await fetch(`/_plugin-health/${pluginId}`, {
credentials: "same-origin",
redirect: "manual",
});
// `type === "opaqueredirect"` is the gate bouncing us to /login, not the plugin answering.
// `type === "opaqueredirect"` is the gate bouncing us to /login, not an answer.
if (r.type === "opaqueredirect") throw new Error("session expired");
if (!r.ok) throw new Error(`health ${r.status}`);
return true;
@@ -62,18 +75,49 @@ export const SectionPlugin: FC = () => {
refetchInterval: (q) => (q.state.status === "error" ? 5_000 : 20_000),
});
// Is the plugin ORIGIN reachable from this browser? Distinct from "is the plugin running".
//
// The console is served with the host's own self-signed certificate, and a browser stores a
// certificate exception PER ORIGIN — including the port. So the operator having trusted
// https://host:47992 says nothing about https://host:47993, and a certificate interstitial
// cannot be shown (let alone accepted) inside an iframe: the frame would just sit blank, with no
// way to fix it and nothing on screen explaining why.
//
// A `no-cors` probe distinguishes the two cases without needing CORS: the response is opaque and
// unreadable either way, but a TLS failure REJECTS while an ordinary answer — even a 401 —
// resolves. Rejection therefore means "this browser will not talk to that origin yet", which is
// a one-time, fixable thing, so we say so and link to it.
const reachable = useQuery({
queryKey: ["plugin-origin-reachable", pluginOrigin],
enabled: !!pluginOrigin,
queryFn: async () => {
await fetch(`${pluginOrigin}/plugin-ui/${pluginId}/__health`, {
mode: "no-cors",
cache: "no-store",
});
return true;
},
retry: 1,
staleTime: 60_000,
});
// The iframe src is fixed at the initial deep-link path; the plugin's own in-app navigation drives
// the console URL via postMessage (below), never the src — so there's no reload loop.
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally pinned to the initial path
const initialSrc = useMemo(
() => `/plugin-ui/${pluginId}/${_splat ?? ""}`,
[pluginId],
() => `${pluginOrigin ?? ""}/plugin-ui/${pluginId}/${_splat ?? ""}`,
[pluginId, pluginOrigin],
);
// Keep the console address bar in sync with the plugin's internal routing.
useEffect(() => {
const onMessage = (e: MessageEvent) => {
if (e.source !== iframeRef.current?.contentWindow) return;
// Now that the frame is cross-origin, `e.origin` is a real check rather than a tautology:
// only the plugin origin may drive the console's address bar. (Empty `pluginOrigin` is
// the vite-dev same-origin arrangement, where `e.origin` is our own.)
const expected = pluginOrigin || window.location.origin;
if (e.origin !== expected) return;
const data = e.data as { type?: string; path?: string };
if (data?.type === "pf-ui:navigate" && typeof data.path === "string") {
navigate({
@@ -85,7 +129,7 @@ export const SectionPlugin: FC = () => {
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [pluginId, navigate]);
}, [pluginId, navigate, pluginOrigin]);
return (
<div className="flex h-[calc(100dvh-7rem)] min-h-[480px] flex-col gap-3 sm:h-[calc(100dvh-5rem)]">
@@ -99,42 +143,45 @@ export const SectionPlugin: FC = () => {
</span>
)}
{provenance && <TierBadge tier={provenance.tier} />}
<a
href={`/plugin-ui/${pluginId}/`}
target="_blank"
rel="noreferrer"
className="ml-auto inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLink className="size-4" />
{m.plugin_open_new_tab()}
</a>
{/* Full-window, on the PLUGIN origin. This link used to be the same escalation as the
iframe with no sandbox involved at all a top-level document on the console origin,
holding the operator's session. It only stops being that because the origin moved,
which is why the fix could never have been a sandbox attribute. */}
{pluginOrigin !== null && pluginOrigin !== undefined && (
<a
href={`${pluginOrigin}/plugin-ui/${pluginId}/`}
target="_blank"
rel="noreferrer"
className="ml-auto inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLink className="size-4" />
{m.plugin_open_new_tab()}
</a>
)}
</div>
{health.isError ? (
{pluginOrigin === null ? (
<UnavailableCard />
) : reachable.isError ? (
<UntrustedOriginCard
href={`${pluginOrigin}/plugin-ui/${pluginId}/`}
onRetry={() => reachable.refetch()}
/>
) : health.isError ? (
<OfflineCard title={title} onRetry={() => health.refetch()} />
) : health.isSuccess ? (
) : health.isSuccess && pluginOrigin !== undefined ? (
<iframe
ref={iframeRef}
src={initialSrc}
title={title}
className="w-full flex-1 rounded-lg border bg-card"
// The plugin is operator-installed code on our own origin (no new trust boundary —
// plugin-ui-surface §7.4); allow it to run scripts, forms, popups, and full-window.
//
// ⚠ KNOWN GAP — security-review-2026-08-05 H-3. `allow-same-origin` means plugin JS
// runs as first-party on the console origin, so it can `fetch('/api/**')` with the
// operator's session and the BFF attaches the ADMIN bearer — reaching everything
// `plugin_may_access` withholds (arm pairing, read the PIN, approve a device, read
// `/hooks`). The "open in new tab" link above is the same escalation without any
// iframe at all, so the sandbox attribute alone is not where this gets fixed.
//
// Simply dropping `allow-same-origin` does NOT work: a sandboxed document has an
// opaque origin, its subresource requests are then treated as cross-site, the
// `SameSite=Lax` `pf_session` cookie is not sent, and every plugin asset 302s to
// /login — a blank frame. The real fix is to serve `/plugin-ui/**` from a distinct
// ORIGIN (a second listener on another port: different origin so the same-origin
// policy is the boundary, but still the same *site*, so the cookie keeps flowing),
// which changes the console's listener/deploy model and needs on-glass validation.
// `allow-same-origin` is correct HERE and was the vulnerability BEFORE, because what
// counts as "same origin" changed underneath it: the frame now loads from the plugin
// origin, so this grants the plugin its OWN origin (storage, its own fetches) rather
// than the console's. Removing it would give the frame an opaque origin instead,
// which stops the SameSite=Lax session cookie and 302s every plugin asset to /login
// — the dead end recorded in 2026-08-05 review H-3. Origin isolation is enforced by
// the two listeners (nitro-entry/bun-https.mjs), not by this attribute.
sandbox="allow-scripts allow-forms allow-popups allow-same-origin allow-modals"
allow="fullscreen"
/>
@@ -146,6 +193,57 @@ export const SectionPlugin: FC = () => {
);
};
/**
* The plugin-UI listener did not bind, so there is no origin to render a plugin on.
*
* Deliberately a dead end rather than a fallback: serving the plugin on the console's own origin is
* exactly the escalation the separate origin exists to prevent, so "the port is busy" must degrade
* to "no plugin UIs", never to "plugin UIs, unsafely".
*/
const UnavailableCard: FC = () => (
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed">
<div className="flex max-w-md flex-col items-center gap-3 p-8 text-center">
<h2 className="text-base font-semibold">
{m.plugin_origin_unavailable_title()}
</h2>
<p className="text-sm text-muted-foreground">
{m.plugin_origin_unavailable_hint()}
</p>
</div>
</div>
);
/**
* The plugin origin exists but this browser will not talk to it yet almost always the host's
* self-signed certificate not having been accepted for that PORT (exceptions are per origin), which
* an iframe can never prompt for. One visit in a real tab fixes it for good.
*/
const UntrustedOriginCard: FC<{ href: string; onRetry: () => void }> = ({
href,
onRetry,
}) => (
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed">
<div className="flex max-w-md flex-col items-center gap-3 p-8 text-center">
<h2 className="text-base font-semibold">
{m.plugin_origin_untrusted_title()}
</h2>
<p className="text-sm text-muted-foreground">
{m.plugin_origin_untrusted_hint()}
</p>
<Button asChild variant="outline" size="sm">
<a href={href} target="_blank" rel="noreferrer">
<ExternalLink className="size-4" />
{m.plugin_origin_untrusted_open()}
</a>
</Button>
<Button variant="ghost" size="sm" onClick={onRetry}>
<RefreshCw className="size-4" />
{m.plugin_retry()}
</Button>
</div>
</div>
);
const OfflineCard: FC<{ title: string; onRetry: () => void }> = ({
title,
onRetry,