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.
60 lines
3.5 KiB
Bash
60 lines
3.5 KiB
Bash
# punktfunk web — management console (Nitro server on bun) configuration.
|
|
# Copy to `.env` (gitignored) or set these in the environment of `bun .output/server/index.mjs`.
|
|
# NOTE: on a packaged install (the punktfunk-web .deb) you edit NOTHING — the systemd --user units
|
|
# auto-wire these from the host's ~/.config/punktfunk/{mgmt-token,web-password,cert.pem,key.pem}.
|
|
# See web.env.example.
|
|
|
|
# REQUIRED in production: the shared login password for the console. The built Nitro
|
|
# server fails CLOSED (503 on every request) if this is unset, so a LAN-exposed server
|
|
# never admits anyone by accident.
|
|
PUNKTFUNK_UI_PASSWORD=change-me
|
|
|
|
# Management API the console proxies to. It serves HTTPS (the host's own identity cert) and
|
|
# requires auth (mTLS or the bearer below). Keep this loopback — the login-gated web server is
|
|
# the only path to it.
|
|
PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990
|
|
|
|
# REQUIRED: bearer token for the management API, injected server-side by the /api proxy (never
|
|
# sent to the browser). Must match the host's `--mgmt-token` / PUNKTFUNK_MGMT_TOKEN — otherwise
|
|
# the proxy gets 401.
|
|
PUNKTFUNK_MGMT_TOKEN=
|
|
|
|
# NOTE: NODE_TLS_REJECT_UNAUTHORIZED is intentionally NOT set. The host's self-signed loopback cert
|
|
# is accepted only for the /api proxy's loopback hop — scoped inside the proxy code (Bun per-request
|
|
# TLS: server/routes/api/[...].ts), so it can never silently unverify some other outbound TLS. A
|
|
# NON-loopback PUNKTFUNK_MGMT_URL is verified normally (present a valid chain).
|
|
|
|
# OPTIONAL: explicit cookie-sealing secret (>= 32 chars). If unset, the key is derived from the
|
|
# high-entropy PUNKTFUNK_MGMT_TOKEN (so a captured cookie is NOT an offline password oracle); only if
|
|
# no token is configured (dev/local) does it fall back to deriving from PUNKTFUNK_UI_PASSWORD.
|
|
# Rotating the mgmt token invalidates existing sessions.
|
|
# PUNKTFUNK_UI_SECRET=
|
|
|
|
# TLS: serve the console over HTTPS (HTTP/1.1 over TLS) using the HOST's own identity cert (the cert
|
|
# native clients already pin). Point these at the host's PEM files; BOTH set ⇒ HTTPS. Unset ⇒ plain
|
|
# HTTP (local dev only). (No HTTP/2 or HTTP/3: Bun.serve has no HTTP/2 server, and a browser won't
|
|
# speak HTTP/3/QUIC against this self-signed, no-SAN host cert.)
|
|
PUNKTFUNK_UI_TLS_CERT=/home/you/.config/punktfunk/cert.pem
|
|
PUNKTFUNK_UI_TLS_KEY=/home/you/.config/punktfunk/key.pem
|
|
|
|
# REQUIRED when serving over TLS: mark the session cookie Secure (browsers drop a Secure cookie over
|
|
# plain http://, so it is OFF by default; turn it ON whenever PUNKTFUNK_UI_TLS_* is set).
|
|
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
|