fix(web/plugins): a plugin's interface loads again, instead of an empty panel #80

Merged
enricobuehler merged 1 commits from worktree-plugin-ui-frame-ancestors into main 2026-08-06 21:16:02 +00:00
Owner

Reported as "the embedded plugin UI does not load in the web console" (testing on .21). Fallout from the H-3 origin split in the 2026-08-05 security review. Two independent defects, both had to be fixed — either one alone leaves an empty panel with no explanation on screen.

1. The frame's own policy locked it out

The plugin origin sends frame-ancestors naming the console as the only page allowed to frame it, and built that name from the incoming request. But Nitro's localFetch hands the app a synthetic request with no TLS socket, so h3 reports http: on an HTTPS listener. On .21:

Content-Security-Policy: frame-ancestors http://192.168.1.21:47992

…while the operator is on https://192.168.1.21:47992. Chromium refuses outright:

Refused to frame 'https://192.168.1.21:47993/' because an ancestor violates the
Content-Security-Policy directive: "frame-ancestors http://192.168.1.21:47992".

⚠ The CSP3 scheme-part upgrade (an http source also matching an https URL) does not rescue frame-ancestors — that was reasoned from the spec first and disproved in a real browser. The scheme now comes from the listener's own tls state, stamped at bind time, with x-forwarded-proto winning only when something in front terminated TLS for us.

2. TCP 47993 was never actually open

ufw expands an app profile into concrete rules when you run ufw allow and stores those. The shipped profile said 47992,47993/tcp; the live rule on .21 still said 47992. A package upgrade cannot change that. Measured from another machine: 47992 answered in 33 ms, 47993 timed out. firewalld is the same class (the daemon serves the definition it loaded ⇒ needs --reload). Windows was already correct — its installer deletes and re-adds both rules on every upgrade.

The port was also missing where nobody would look: the NixOS module (allowedTCPPorts = [ 47992 ]) and web/Dockerfile (EXPOSE 47992) were broken out of the box, and 47993 appeared in zero docs against ~50 files mentioning 47992.

Also

A closed port DROPs rather than refuses, so the console's no-cors reachability probe hangs for the browser's whole connect timeout — and the render gate mounted the iframe while it was still pending. That is the "just doesn't load" with no card at all. Now gated on the probe having settled, and the card names both causes it can be (untrusted certificate for that port, or a closed one), because from a browser the two are indistinguishable.

Changes

  • web/nitro-entry/bun-https.mjs — stamp the listener's scheme alongside the existing port stamps
  • web/server/util/pluginOrigin.tsconsoleOriginScheme() + frameAncestorSource() as a pure, testable rule (its failure mode is a well-formed header only a browser rejects)
  • web/server/middleware/auth.ts — build frame-ancestors from that rule
  • web/src/sections/Plugins/index.tsx + messages/{en,de}.json — don't mount the frame mid-probe; card names both causes
  • packaging/arch|debian|rpm — on upgrade, detect a stale ufw/firewalld rule and print the one command that fixes it. Print-only: the running firewall stays the operator's call, matching existing policy
  • packaging/nix/nixos-module.nix, web/Dockerfile — open/expose 47993
  • docs-site — "Two ports, not one" in web-console, and a troubleshooting entry
  • web/server/util/pluginOrigin.test.ts + bun test server/ in CI — the first tests in web/

Verification

  • On glass, headless Chromium against home-worker-5 (.21) and its ROM Manager plugin, through tunnels to the real mgmt API and the real plugin port: frame refused before, renders the plugin's UI after.
  • Origin-split invariants re-checked: console 404s /plugin-ui/**, plugin origin 404s /api/** and /, console CSP still frame-ancestors 'self'.
  • bun run build / bun run lint clean; 9 tests pass — and proven non-vacuous: reverting the rule to the old behaviour fails 3 of them with exactly the http:// vs https:// diff.
  • Firewall warning branches exercised against stubbed ufw / firewall-cmd for stale, refreshed, never-allowed, and not-installed states — no false positives. The firewalld branch's output format follows --info-service; no firewalld box was reachable to confirm on glass.
  • bun.lock / bun.nix untouched (tests are excluded from tsc rather than adding @types/bun, which would churn both).

.21's firewall was fixed for real during triage (ufw app update punktfunk-web && ufw reload) — that persists. It still runs the packaged canary containing the CSP bug, so plugin UIs stay broken there until the canary rebuilds from main.

Reported as "the embedded plugin UI does not load in the web console" (testing on `.21`). Fallout from the H-3 origin split in the 2026-08-05 security review. **Two independent defects, both had to be fixed** — either one alone leaves an empty panel with no explanation on screen. ## 1. The frame's own policy locked it out The plugin origin sends `frame-ancestors` naming the console as the only page allowed to frame it, and built that name from the incoming request. But Nitro's `localFetch` hands the app a **synthetic request with no TLS socket**, so h3 reports `http:` on an HTTPS listener. On `.21`: ``` Content-Security-Policy: frame-ancestors http://192.168.1.21:47992 ``` …while the operator is on `https://192.168.1.21:47992`. Chromium refuses outright: ``` Refused to frame 'https://192.168.1.21:47993/' because an ancestor violates the Content-Security-Policy directive: "frame-ancestors http://192.168.1.21:47992". ``` ⚠ The CSP3 scheme-part upgrade (an `http` source also matching an `https` URL) does **not** rescue `frame-ancestors` — that was reasoned from the spec first and disproved in a real browser. The scheme now comes from the listener's own `tls` state, stamped at bind time, with `x-forwarded-proto` winning only when something in front terminated TLS for us. ## 2. TCP 47993 was never actually open `ufw` expands an app profile into concrete rules **when you run `ufw allow`** and stores those. The shipped profile said `47992,47993/tcp`; the live rule on `.21` still said `47992`. A package upgrade cannot change that. Measured from another machine: 47992 answered in 33 ms, 47993 timed out. firewalld is the same class (the daemon serves the definition it loaded ⇒ needs `--reload`). Windows was already correct — its installer deletes and re-adds both rules on every upgrade. The port was also missing where nobody would look: the **NixOS module** (`allowedTCPPorts = [ 47992 ]`) and **`web/Dockerfile`** (`EXPOSE 47992`) were broken *out of the box*, and `47993` appeared in **zero docs** against ~50 files mentioning 47992. ## Also A closed port DROPs rather than refuses, so the console's `no-cors` reachability probe hangs for the browser's whole connect timeout — and the render gate mounted the iframe while it was still pending. That is the "just doesn't load" with no card at all. Now gated on the probe having settled, and the card names both causes it can be (untrusted certificate for that port, or a closed one), because from a browser the two are indistinguishable. ## Changes - `web/nitro-entry/bun-https.mjs` — stamp the listener's scheme alongside the existing port stamps - `web/server/util/pluginOrigin.ts` — `consoleOriginScheme()` + `frameAncestorSource()` as a **pure, testable rule** (its failure mode is a well-formed header only a browser rejects) - `web/server/middleware/auth.ts` — build `frame-ancestors` from that rule - `web/src/sections/Plugins/index.tsx` + `messages/{en,de}.json` — don't mount the frame mid-probe; card names both causes - `packaging/arch|debian|rpm` — on upgrade, detect a stale ufw/firewalld rule and print the one command that fixes it. Print-only: the running firewall stays the operator's call, matching existing policy - `packaging/nix/nixos-module.nix`, `web/Dockerfile` — open/expose 47993 - `docs-site` — "Two ports, not one" in web-console, and a troubleshooting entry - `web/server/util/pluginOrigin.test.ts` + `bun test server/` in CI — the first tests in `web/` ## Verification - **On glass**, headless Chromium against `home-worker-5 (.21)` and its ROM Manager plugin, through tunnels to the real mgmt API and the real plugin port: frame **refused before**, **renders the plugin's UI after**. - Origin-split invariants re-checked: console 404s `/plugin-ui/**`, plugin origin 404s `/api/**` and `/`, console CSP still `frame-ancestors 'self'`. - `bun run build` / `bun run lint` clean; 9 tests pass — and **proven non-vacuous**: reverting the rule to the old behaviour fails 3 of them with exactly the `http://` vs `https://` diff. - Firewall warning branches exercised against stubbed `ufw` / `firewall-cmd` for stale, refreshed, never-allowed, and not-installed states — no false positives. The firewalld branch's output format follows `--info-service`; no firewalld box was reachable to confirm on glass. - `bun.lock` / `bun.nix` untouched (tests are excluded from `tsc` rather than adding `@types/bun`, which would churn both). ⚠ `.21`'s firewall was fixed for real during triage (`ufw app update punktfunk-web && ufw reload`) — that persists. It still runs the packaged canary containing the CSP bug, so plugin UIs stay broken there until the canary rebuilds from main.
enricobuehler added 1 commit 2026-08-06 21:00:28 +00:00
fix(web/plugins): a plugin's interface loads again, instead of an empty panel
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
185a69c806
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.
enricobuehler merged commit 9c7409ea40 into main 2026-08-06 21:16:02 +00:00
enricobuehler deleted branch worktree-plugin-ui-frame-ancestors 2026-08-06 21:16:12 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#80