ci / bun-nix (pull_request) Successful in 20s
ci / docs-site (pull_request) Successful in 1m5s
ci / web (pull_request) Successful in 1m10s
ci / rust-arm64 (pull_request) Successful in 1m49s
ci / rust (pull_request) Successful in 6m23s
nix / flake (pull_request) Failing after 12m37s
Two separate things had to be wrong for this, and both were. The frame's own policy locked it out. Plugin UIs moved to their own origin so a plugin cannot act as the logged-in operator, and the plugin origin names the console as the only page allowed to frame it. It built that name from the incoming request — but Nitro hands the app a synthetic request with no TLS socket, so an HTTPS console reads back as `http:`. The header said `frame-ancestors http://host:47992` while the operator was on `https://host:47992`, and the browser refused the frame outright (ERR_BLOCKED_BY_RESPONSE). Nothing on screen said so; the reason was only in devtools. The scheme now comes from the listener's own TLS state, stamped at bind time, with x-forwarded-proto winning when something in front terminated TLS for us — the one case where the browser's scheme is not ours. And the port was shut. 47993 was added to the firewall definitions, but an already-open firewall does not pick a new port up: ufw expands an app profile into rules when you allow it and keeps those, so editing the profile on upgrade changes nothing, and firewalld needs a reload. Every upgraded Linux host kept a 47992-only rule, silently. The packages now notice on upgrade and print the one command that fixes it, without touching the running firewall. The NixOS module and the container image never learned the port at all; both now open it. Also: the console no longer mounts the frame while it is still checking whether that origin is reachable. A firewalled port drops rather than refuses, so the check hangs for the browser's whole connect timeout, and mounting meanwhile is exactly the empty panel with no explanation. The card that follows now names both causes it can be — an untrusted certificate for that port, or a closed one — because from a browser the two are indistinguishable. The rule is now a pure function with tests, since its failure mode is a well-formed header that only a browser rejects. Verified on glass against home-worker-5 (.21) and its ROM Manager plugin: the frame was refused before, renders the plugin's UI after.
101 lines
4.8 KiB
TypeScript
101 lines
4.8 KiB
TypeScript
// 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;
|
|
}
|
|
|
|
/**
|
|
* The scheme the console is actually served on (`https` when TLS is configured), or `null` when the
|
|
* entry has not stamped one — `vite dev`, or a test importing this directly.
|
|
*
|
|
* ⚠ This cannot be read off the request. Nitro's `localFetch` hands the app a SYNTHETIC request with
|
|
* no TLS socket, so `getRequestURL(event).protocol` is `http:` even when the listener is HTTPS. That
|
|
* is harmless for a relative redirect, and was NOT harmless for `frame-ancestors`: the plugin origin
|
|
* named `http://host:47992` as its only permitted ancestor while the console the operator was
|
|
* looking at was `https://host:47992`, and the browser refused to frame the plugin
|
|
* (`ERR_BLOCKED_BY_RESPONSE`) — an empty panel with the explanation only in the devtools console.
|
|
* The scheme-part upgrade in CSP3 (an `http` source also matching an `https` URL) does NOT rescue
|
|
* this: Chromium enforces `frame-ancestors` against the ancestor's origin strictly. Verified on
|
|
* glass, 2026-08-06.
|
|
*
|
|
* Stamped by the entry from the same `tls` option both listeners are built with, so the two can
|
|
* never disagree, and never read from the environment we inherited.
|
|
*/
|
|
export function consoleOriginScheme(): "http" | "https" | null {
|
|
const raw = process.env.PUNKTFUNK_UI_SCHEME_ACTIVE;
|
|
return raw === "https" || raw === "http" ? raw : null;
|
|
}
|
|
|
|
/**
|
|
* The `frame-ancestors` source naming the console, as a pure rule over the three things that can
|
|
* know the scheme — kept separate from the request so it can be tested, because the bug it exists
|
|
* to prevent is invisible in a header (`http://…` looks perfectly well-formed) and only shows up as
|
|
* a plugin panel that never fills in.
|
|
*
|
|
* Precedence, and why:
|
|
* 1. `x-forwarded-proto` — something in front terminated TLS, so it, not us, knows what the
|
|
* browser's address bar says. The only case where the two legitimately differ.
|
|
* 2. the scheme the listener was built with — the normal path, stamped at bind time.
|
|
* 3. the request's own scheme — last resort (nothing stamped: `vite dev`, or a direct import).
|
|
*/
|
|
export function frameAncestorSource(o: {
|
|
forwardedProto?: string | null;
|
|
listenerScheme?: "http" | "https" | null;
|
|
requestScheme: string;
|
|
hostname: string;
|
|
port: number;
|
|
}): string {
|
|
const forwarded = o.forwardedProto?.split(",")[0]?.trim().toLowerCase();
|
|
const scheme =
|
|
forwarded === "https" || forwarded === "http"
|
|
? forwarded
|
|
: (o.listenerScheme ?? o.requestScheme.replace(/:$/, ""));
|
|
return `${scheme}://${o.hostname}:${o.port}`;
|
|
}
|