From 185a69c80682393762931e5c780cb9c673f64f12 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 6 Aug 2026 22:33:57 +0200 Subject: [PATCH] fix(web/plugins): a plugin's interface loads again, instead of an empty panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitea/workflows/ci.yml | 5 + docs-site/content/docs/troubleshooting.md | 36 +++++++ docs-site/content/docs/web-console.md | 21 ++++ packaging/arch/punktfunk-host.install | 44 +++++++++ packaging/debian/build-deb.sh | 23 +++++ packaging/nix/nixos-module.nix | 13 ++- packaging/rpm/punktfunk.spec | 12 +++ web/Dockerfile | 8 +- web/messages/de.json | 2 +- web/messages/en.json | 2 +- web/nitro-entry/bun-https.mjs | 6 ++ web/package.json | 1 + web/server/middleware/auth.ts | 22 ++++- web/server/util/pluginOrigin.test.ts | 115 ++++++++++++++++++++++ web/server/util/pluginOrigin.ts | 49 +++++++++ web/src/sections/Plugins/index.tsx | 16 ++- web/tsconfig.json | 7 +- 17 files changed, 372 insertions(+), 10 deletions(-) create mode 100644 web/server/util/pluginOrigin.test.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 159f18e9..4ccad414 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -252,6 +252,11 @@ jobs: run: bun run build - name: Typecheck run: bun run lint + # Scoped to server/: the console's browser code has no test runner, but the gate that keeps a + # plugin's origin apart from the console's does — and its failure mode is a well-formed header + # that only a browser rejects, which nothing else here would catch. + - name: Test + run: bun run test docs-site: runs-on: ubuntu-24.04 diff --git a/docs-site/content/docs/troubleshooting.md b/docs-site/content/docs/troubleshooting.md index d4e8350f..a59de68c 100644 --- a/docs-site/content/docs/troubleshooting.md +++ b/docs-site/content/docs/troubleshooting.md @@ -259,6 +259,42 @@ Work through [Why the toggle does nothing](/docs/clipboard#why-the-toggle-does-nothing-or-is-greyed-out) — it also names the clients and host sessions where nothing crosses no matter what you set. +## A plugin's interface doesn't load + +The plugin's page in the console opens — title, version, **Open in new tab** — but the panel below +it stays empty. + +Plugin interfaces are served on **TCP 47993**, a separate port from the console's 47992, so that a +plugin can't act as you with your logged-in session (see +[Two ports, not one](/docs/web-console#two-ports-not-one)). An empty panel means the browser can't +load anything from that second port. Two reasons, in order of likelihood: + +- **The port isn't open.** Only the console's port is reachable, so the frame has nothing to show. + On a host you *upgraded*, this is the usual answer: an already-open firewall does not pick up a + port that a later version added, because the rule it saved lists the ports it knew at the time. + + ```sh + # ufw (CachyOS, Ubuntu): re-expand the profile, then reload + sudo ufw app update punktfunk-web && sudo ufw reload + + # firewalld (Fedora, Bazzite, Nobara): re-read the shipped service definition + sudo firewall-cmd --reload + ``` + + ```powershell + # Windows: re-run the service installer, which re-adds both console rules + punktfunk-host service install + ``` + + Check what's actually open with `sudo ufw status verbose` or + `sudo firewall-cmd --info-service=punktfunk-web` — you want **47993** listed next to 47992. +- **The certificate isn't trusted for that port yet.** Browsers keep a self-signed certificate + exception *per port*, and a warning page can't be shown inside a panel. The console detects this + and offers a link to open the plugin in its own tab: accept the warning there once and come back. + +If the panel is empty and the console shows *no* explanation at all, the plugin's own port is +probably being dropped rather than refused — open 47993 as above. + ## Pairing is rejected / the client can't connect - The host **requires pairing** by default. Arm pairing from the web console, then enter the PIN on diff --git a/docs-site/content/docs/web-console.md b/docs-site/content/docs/web-console.md index c52bd739..f398face 100644 --- a/docs-site/content/docs/web-console.md +++ b/docs-site/content/docs/web-console.md @@ -14,6 +14,27 @@ game-library browsing to paired clients. > New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of > the machine, so keep it on a trusted LAN or VPN and require pairing. +## Two ports, not one + +The console also listens on **TCP 47993**, and plugin interfaces are served from there — same host, +same certificate, **different port**. + +That is a deliberate boundary rather than a second console. A plugin's interface is third-party +code, and on the console's own port the browser would treat it as part of the console: it could act +as you, with your logged-in session, against every admin action the console can reach. A different +port is a different *origin*, so the browser itself keeps the two apart — while staying the same +*site*, which is what lets your login still carry over so you don't sign in twice. + +What this means in practice: + +- **Open 47993 alongside 47992** on the host's firewall if you browse the console from another + device. The packaged firewall profiles already list both. +- **Trust the certificate twice.** Browsers store a self-signed certificate exception *per port*. + The first time you open a plugin, the console will notice it can't reach 47993 yet and offer a + link to open it in a tab — accept the warning there once, come back, and it works from then on. +- If a plugin's page is an empty panel, see + [A plugin's interface doesn't load](/docs/troubleshooting#a-plugins-interface-doesnt-load). + ## Enable the console - **Linux packages (apt / RPM / Bazzite):** on Ubuntu the host package is `punktfunk-host` diff --git a/packaging/arch/punktfunk-host.install b/packaging/arch/punktfunk-host.install index cba9c207..07af7c24 100644 --- a/packaging/arch/punktfunk-host.install +++ b/packaging/arch/punktfunk-host.install @@ -69,4 +69,48 @@ post_upgrade() { _ensure_update_group udevadm control --reload-rules 2>/dev/null || true sysctl -p /usr/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || true + _warn_stale_firewall_ports +} + +# An already-open firewall does NOT pick up a port we added to a profile. +# +# ufw expands an app profile into concrete rules when you run `ufw allow`, and stores THOSE. Editing +# /etc/ufw/applications.d later — which is all a package upgrade does — changes nothing about the +# rules already installed. firewalld is friendlier (its permanent config names the service, so a +# reload re-reads the XML) but still needs that reload. Either way the operator has an old rule and +# no reason to suspect it. +# +# That is not hypothetical: 47993 (plugin UIs, a separate origin from the console) arrived exactly +# this way, and on an upgraded ufw box every plugin interface silently became an empty panel in the +# console. So on upgrade, look at what is actually open and say so — still without touching the +# running firewall, which stays the operator's call. +_warn_stale_firewall_ports() { + # `ufw status verbose` prints each rule with its EXPANDED ports — "47992/tcp (punktfunk-web)" + # before the refresh, "47992,47993/tcp (punktfunk-web)" after — so one listing answers both "is + # the profile allowed at all" and "does that rule know the new port". (Plain `ufw status` prints + # the profile NAME instead, which cannot tell the two apart.) + if command -v ufw >/dev/null 2>&1 && + ufw status verbose 2>/dev/null | grep -q 'punktfunk-web' && + ! ufw status verbose 2>/dev/null | grep -q '47993'; then + cat <<'MSG' + +punktfunk: your ufw rule for 'punktfunk-web' predates TCP 47993, the separate origin plugin UIs +are served from. Until it is refreshed, plugin interfaces will not load in the web console: + sudo ufw app update punktfunk-web && sudo ufw reload +MSG + fi + # `--info-service` asks the DAEMON, which answers from the definition it loaded at its last + # (re)start — precisely the stale copy we are warning about. The file on disk already says 47993. + if command -v firewall-cmd >/dev/null 2>&1 && + firewall-cmd --state >/dev/null 2>&1 && + firewall-cmd --query-service=punktfunk-web >/dev/null 2>&1 && + ! firewall-cmd --info-service=punktfunk-web 2>/dev/null | grep -q '47993'; then + cat <<'MSG' + +punktfunk: the punktfunk-web firewalld service now also covers TCP 47993, the separate origin +plugin UIs are served from. Reload so the running firewall picks it up, or plugin interfaces will +not load in the web console: + sudo firewall-cmd --reload +MSG + fi } diff --git a/packaging/debian/build-deb.sh b/packaging/debian/build-deb.sh index c5f106a8..8ebdaac5 100755 --- a/packaging/debian/build-deb.sh +++ b/packaging/debian/build-deb.sh @@ -314,6 +314,29 @@ if [ "$1" = "configure" ]; then echo " sudo firewall-cmd --permanent --add-service=punktfunk-native && sudo firewall-cmd --reload" echo " (use punktfunk-gamestream for the Moonlight-compat host)" fi + # An ALREADY-OPEN firewall does not pick up a port we later added to a profile. ufw expands an + # app profile into concrete rules at `ufw allow` time and keeps those, so editing + # /etc/ufw/applications.d on upgrade changes nothing; firewalld re-reads its XML, but only on a + # reload. 47993 (the separate origin plugin UIs are served from) arrived exactly this way, and + # an unrefreshed rule turns every plugin interface in the console into an empty panel. + # `ufw status verbose` prints expanded ports, so it can tell "allowed" from "allowed, stale". + if command -v ufw >/dev/null 2>&1 && + ufw status verbose 2>/dev/null | grep -q 'punktfunk-web' && + ! ufw status verbose 2>/dev/null | grep -q '47993'; then + echo "" + echo "punktfunk: your ufw rule for 'punktfunk-web' predates TCP 47993 (plugin UIs, served" + echo " from their own origin). Plugin interfaces will not load in the console until:" + echo " sudo ufw app update punktfunk-web && sudo ufw reload" + fi + # --info-service answers from the definition the daemon loaded, i.e. the stale one. + if command -v firewall-cmd >/dev/null 2>&1 && + firewall-cmd --state >/dev/null 2>&1 && + firewall-cmd --query-service=punktfunk-web >/dev/null 2>&1 && + ! firewall-cmd --info-service=punktfunk-web 2>/dev/null | grep -q '47993'; then + echo "" + echo "punktfunk: the punktfunk-web firewalld service now also covers TCP 47993 (plugin UIs)." + echo " Plugin interfaces will not load in the console until: sudo firewall-cmd --reload" + fi # Conflicting Moonlight-compatible host (Sunshine/Apollo/...): reuse the host's own detector so # the warning lives in one place. Exit 1 = found; never fail the install on it. if command -v punktfunk-host >/dev/null 2>&1; then diff --git a/packaging/nix/nixos-module.nix b/packaging/nix/nixos-module.nix index 11c12b24..fe684878 100644 --- a/packaging/nix/nixos-module.nix +++ b/packaging/nix/nixos-module.nix @@ -234,7 +234,11 @@ in type = types.bool; default = cfg.host.openFirewall; defaultText = literalExpression "config.services.punktfunk.host.openFirewall"; - description = "Open TCP 47992 so the console is reachable from other devices on the LAN."; + description = '' + Open TCP 47992 so the console is reachable from other devices on the LAN, and TCP 47993, + the separate origin its plugin UIs are served from (without it, plugin interfaces do not + load in the console). + ''; }; autoStart = mkOption { @@ -388,7 +392,12 @@ in environment.systemPackages = [ cfg.web.package ]; networking.firewall = mkIf cfg.web.openFirewall { - allowedTCPPorts = [ 47992 ]; # console HTTPS (packaging/linux/punktfunk-web.xml) + # 47992 = the console itself. 47993 = the SEPARATE ORIGIN its plugin UIs are served from + # (console port + 1): same host and certificate, different port, so the browser's same-origin + # policy keeps a plugin from acting as the logged-in operator. Leaving it closed does not + # degrade gracefully — every plugin interface is simply an empty panel from any other device. + # Keep in step with packaging/linux/punktfunk-web.xml and punktfunk.ufw. + allowedTCPPorts = [ 47992 47993 ]; }; # First-run setup: generate the console login password once, in the user's config dir, and diff --git a/packaging/rpm/punktfunk.spec b/packaging/rpm/punktfunk.spec index 11a8a9f9..7e77dde6 100644 --- a/packaging/rpm/punktfunk.spec +++ b/packaging/rpm/punktfunk.spec @@ -586,6 +586,18 @@ if command -v firewall-cmd >/dev/null 2>&1; then echo " sudo firewall-cmd --permanent --add-service=punktfunk-gamestream && sudo firewall-cmd --reload" echo " (use punktfunk-native for the native-only host)" fi +# A RUNNING firewalld keeps serving the service definition it loaded at its last (re)start, so a +# port added to the XML by this upgrade — 47993, the separate origin plugin UIs are served from — +# is not open until a reload, and the console shows every plugin interface as an empty panel with +# nothing to explain it. `--info-service` asks the daemon, i.e. reads that stale copy. +if command -v firewall-cmd >/dev/null 2>&1 && + firewall-cmd --state >/dev/null 2>&1 && + firewall-cmd --query-service=punktfunk-web >/dev/null 2>&1 && + ! firewall-cmd --info-service=punktfunk-web 2>/dev/null | grep -q '47993'; then + echo "" + echo "punktfunk: the punktfunk-web firewalld service now also covers TCP 47993 (plugin UIs)." + echo " Plugin interfaces will not load in the console until: sudo firewall-cmd --reload" +fi # Conflicting Moonlight-compatible host (Sunshine/Apollo/...): reuse the host's own detector so the # warning stays in one place. Exit 1 = something found; never fail the install on it. if command -v punktfunk-host >/dev/null 2>&1; then diff --git a/web/Dockerfile b/web/Dockerfile index eb58e2eb..772b4bb0 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -6,6 +6,12 @@ # # Runtime: PORT (default 47992) and PUNKTFUNK_MGMT_URL (upstream management API the Nitro # server proxies /api to; see web/server/routes). +# +# TWO ports, not one. The console also listens on PUNKTFUNK_UI_PLUGIN_PORT (default PORT + 1 = +# 47993) and serves plugin UIs from there — a different origin, so a plugin's own code cannot act +# as the logged-in operator on the console's origin. Publish BOTH (`-p 47992:47992 -p +# 47993:47993`): the browser loads the frame from the second port directly, so a container that +# only publishes 47992 serves a console whose every plugin interface is an empty panel. FROM oven/bun:1 AS build WORKDIR /repo/web @@ -25,5 +31,5 @@ WORKDIR /app COPY --from=build /repo/web/.output ./.output USER bun ENV PORT=47992 -EXPOSE 47992 +EXPOSE 47992 47993 CMD ["bun", "run", ".output/server/index.mjs"] diff --git a/web/messages/de.json b/web/messages/de.json index 65f2c054..6e29a9e8 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -11,7 +11,7 @@ "nav_plugins": "Plugins", "plugin_offline_title": "Dieses Plugin läuft nicht", "plugin_origin_untrusted_title": "Port dieses Plugins einmal bestätigen", - "plugin_origin_untrusted_hint": "Plugin-Oberflächen 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ür den Konsolen-Port, aber noch nicht für diesen — und in einem Frame kann er nicht nachfragen. Öffne ihn einmal in einem Tab, bestätige das Zertifikat und komm zurück.", + "plugin_origin_untrusted_hint": "Plugin-Oberflächen laufen auf einem eigenen Port, damit ein Plugin nicht in deinem Namen auf der Konsole handeln kann. Dein Browser spricht mit diesem Port noch nicht: entweder hat er das Zertifikat dieses Hosts dafür nicht bestätigt — Zertifikate gelten pro Port, und in einem Frame kann er nicht nachfragen — oder der Port ist in der Firewall des Hosts zu. Öffne ihn einmal in einem Tab: bei einer Zertifikatswarnung bestätigen und zurückkommen; kommt gar keine Verbindung zustande, öffne TCP 47993 auf dem Host.", "plugin_origin_untrusted_open": "In neuem Tab öffnen", "plugin_origin_unavailable_title": "Plugin-Oberflächen sind nicht verfügbar", "plugin_origin_unavailable_hint": "Plugin-Oberflächen laufen auf einem eigenen Port, damit ein Plugin nicht in deinem Namen auf der Konsole handeln kann. Dieser Port konnte nicht geöffnet werden, deshalb bleiben sie deaktiviert. Sieh ins Konsolen-Log, setze dann PUNKTFUNK_UI_PLUGIN_PORT auf einen freien Port und starte neu.", diff --git a/web/messages/en.json b/web/messages/en.json index 7b4ac473..3e5f70f2 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -55,7 +55,7 @@ "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_hint": "Plugin interfaces run on their own port so a plugin can't act as you on the console. Your browser won't talk to that port yet: either it hasn't accepted this host's certificate for it — certificates are trusted per port, and a frame can't ask you — or the port is closed on the host's firewall. Open it once in a tab: if you get a certificate warning, accept it and come back; if it never connects, open TCP 47993 on the host.", "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.", diff --git a/web/nitro-entry/bun-https.mjs b/web/nitro-entry/bun-https.mjs index 5994a6b1..b4384e3a 100644 --- a/web/nitro-entry/bun-https.mjs +++ b/web/nitro-entry/bun-https.mjs @@ -176,6 +176,12 @@ try { // 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); + // …and the SCHEME, which the app cannot recover from a request: `localFetch` synthesises one with + // no TLS socket, so h3 reports `http:` on an HTTPS listener. `frame-ancestors` needs the scheme + // the operator's address bar actually shows, or the browser refuses to frame the plugin at all + // (see consoleOriginScheme() in server/util/pluginOrigin.ts). Both listeners share this `tls` + // object, so one stamp is correct for both. + process.env.PUNKTFUNK_UI_SCHEME_ACTIVE = tls ? "https" : "http"; console.log( `punktfunk plugin-UI origin listening on ${pluginServer.url} (tls=${!!tls})`, ); diff --git a/web/package.json b/web/package.json index 9100dd37..9d18da1b 100644 --- a/web/package.json +++ b/web/package.json @@ -15,6 +15,7 @@ "start": "bun run .output/server/index.mjs", "api:gen": "orval --config orval.config.ts", "lint": "tsc --noEmit", + "test": "bun test server/", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "screenshots": "node tools/screenshots.mjs", diff --git a/web/server/middleware/auth.ts b/web/server/middleware/auth.ts index 385f38c0..1b790010 100644 --- a/web/server/middleware/auth.ts +++ b/web/server/middleware/auth.ts @@ -21,6 +21,8 @@ import { } from "../util/auth"; import { consoleOriginPort, + consoleOriginScheme, + frameAncestorSource, isPluginUiPath, listenerOf, } from "../util/pluginOrigin"; @@ -127,8 +129,16 @@ export default defineEventHandler(async (event) => { /** * 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. + * answering: the hostname is whatever name the operator actually browsed to (an IP, an mDNS name, a + * hostname — so the policy matches their address bar), plus the console's port and scheme. + * + * ⚠ The scheme must NOT come from the request. `getRequestURL` reports `http:` on an HTTPS listener + * here — Nitro hands the app a synthetic request with no TLS socket — so this named + * `http://host:47992` as the only permitted ancestor of a console the operator was reading over + * HTTPS, and every plugin UI came up as an empty panel with `ERR_BLOCKED_BY_RESPONSE`. It is taken + * from the listener's own TLS state instead (`consoleOriginScheme`), with `x-forwarded-proto` + * winning when something in front terminated TLS for us — that is the one case where the browser's + * scheme differs from this process's. * * 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 @@ -138,5 +148,11 @@ function consoleFrameAncestor(event: H3Event): string { const port = consoleOriginPort(); if (!port) return "'none'"; const url = getRequestURL(event); - return `${url.protocol}//${url.hostname}:${port}`; + return frameAncestorSource({ + forwardedProto: getRequestHeader(event, "x-forwarded-proto"), + listenerScheme: consoleOriginScheme(), + requestScheme: url.protocol, + hostname: url.hostname, + port, + }); } diff --git a/web/server/util/pluginOrigin.test.ts b/web/server/util/pluginOrigin.test.ts new file mode 100644 index 00000000..51e51452 --- /dev/null +++ b/web/server/util/pluginOrigin.test.ts @@ -0,0 +1,115 @@ +// The `frame-ancestors` source the plugin origin names the console with. +// +// This exists because getting it wrong is SILENT on the server: the header is well-formed, every +// curl of the plugin origin returns 200, and the only symptom is that a browser quietly refuses to +// paint the frame (`ERR_BLOCKED_BY_RESPONSE`) — so the console shows an empty panel and the reason +// is only in devtools. That is exactly how `http://host:47992` shipped as the permitted ancestor of +// an `https://host:47992` console. +import { describe, expect, test } from "bun:test"; +import { frameAncestorSource, isPluginUiPath } from "./pluginOrigin"; + +describe("frameAncestorSource", () => { + test("uses the listener's scheme, NOT the request's", () => { + // The regression. Nitro's localFetch synthesises a request with no TLS socket, so the request + // says `http:` on an HTTPS listener. The listener's own scheme has to win, or the browser + // refuses to frame the plugin. + expect( + frameAncestorSource({ + requestScheme: "http:", + listenerScheme: "https", + hostname: "192.168.1.21", + port: 47992, + }), + ).toBe("https://192.168.1.21:47992"); + }); + + test("a plain-HTTP console (dev) still gets http", () => { + expect( + frameAncestorSource({ + requestScheme: "http:", + listenerScheme: "http", + hostname: "localhost", + port: 3000, + }), + ).toBe("http://localhost:3000"); + }); + + test("x-forwarded-proto wins — only a proxy knows the browser's scheme", () => { + expect( + frameAncestorSource({ + forwardedProto: "https", + requestScheme: "http:", + listenerScheme: "http", + hostname: "console.lan", + port: 47992, + }), + ).toBe("https://console.lan:47992"); + }); + + test("a multi-hop x-forwarded-proto uses the first hop", () => { + expect( + frameAncestorSource({ + forwardedProto: "https, http", + requestScheme: "http:", + listenerScheme: "http", + hostname: "console.lan", + port: 47992, + }), + ).toBe("https://console.lan:47992"); + }); + + test("a junk x-forwarded-proto is ignored rather than echoed", () => { + expect( + frameAncestorSource({ + forwardedProto: "javascript:alert(1)", + requestScheme: "http:", + listenerScheme: "https", + hostname: "192.168.1.21", + port: 47992, + }), + ).toBe("https://192.168.1.21:47992"); + }); + + test("falls back to the request scheme when nothing is stamped", () => { + expect( + frameAncestorSource({ + listenerScheme: null, + requestScheme: "https:", + hostname: "host", + port: 47992, + }), + ).toBe("https://host:47992"); + }); + + test("keeps whatever hostname the operator browsed to", () => { + // The policy has to match their address bar, not a name we prefer. + for (const hostname of ["192.168.1.21", "punktfunk.local", "deck"]) { + expect( + frameAncestorSource({ + requestScheme: "http:", + listenerScheme: "https", + hostname, + port: 47992, + }), + ).toBe(`https://${hostname}:47992`); + } + }); +}); + +describe("isPluginUiPath", () => { + // The two refusals that keep a plugin off the console's origin depend on this split. + test("claims the plugin-UI prefix", () => { + expect(isPluginUiPath("/plugin-ui")).toBe(true); + expect(isPluginUiPath("/plugin-ui/")).toBe(true); + expect(isPluginUiPath("/plugin-ui/rom-manager/index.html")).toBe(true); + }); + + test("claims nothing else — above all not /api", () => { + expect(isPluginUiPath("/api/v1/status")).toBe(false); + expect(isPluginUiPath("/")).toBe(false); + expect(isPluginUiPath("/login")).toBe(false); + // A near-miss must not be swept in by a loose startsWith. + expect(isPluginUiPath("/plugin-uix")).toBe(false); + expect(isPluginUiPath("/plugin-ui-admin")).toBe(false); + }); +}); diff --git a/web/server/util/pluginOrigin.ts b/web/server/util/pluginOrigin.ts index 5f0e4b02..786f5d0a 100644 --- a/web/server/util/pluginOrigin.ts +++ b/web/server/util/pluginOrigin.ts @@ -49,3 +49,52 @@ export function consoleOriginPort(): number | null { 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}`; +} diff --git a/web/src/sections/Plugins/index.tsx b/web/src/sections/Plugins/index.tsx index 569e052c..27e4ab0a 100644 --- a/web/src/sections/Plugins/index.tsx +++ b/web/src/sections/Plugins/index.tsx @@ -101,6 +101,18 @@ export const SectionPlugin: FC = () => { staleTime: 60_000, }); + // The frame must not be mounted until that probe has SETTLED. + // + // A firewalled port DROPs rather than refuses, so the probe does not fail fast — it hangs for the + // browser's whole connect timeout. Mounting the iframe in the meantime puts an empty panel on + // screen with nothing to explain it, and that is precisely what a closed 47993 looked like in the + // field: a plugin page that "just doesn't load", with the real answer only in devtools. Waiting + // costs a placeholder for the milliseconds a reachable origin takes, and buys the explanation. + // + // Only gate when the probe actually runs: under `vite dev` `pluginOrigin` is "" and the query is + // disabled, which leaves it pending forever — gating on that would never render a frame at all. + const originProbePending = !!pluginOrigin && reachable.isPending; + // 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 @@ -169,7 +181,9 @@ export const SectionPlugin: FC = () => { /> ) : health.isError ? ( health.refetch()} /> - ) : health.isSuccess && pluginOrigin !== undefined ? ( + ) : health.isSuccess && + !originProbePending && + pluginOrigin !== undefined ? (