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
+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,