Acts on the 2026-08-05 host security review. 36 of its 38 findings; the two exceptions are recorded below and in the review doc. The review's headline is that `plugin_may_access` was the one authorization gate in the system that was allow-by-default — a hand-maintained denylist of route prefixes, where every sibling gate is deny-by-default. Its own doc comment names the two capabilities it exists to withhold, and both were reachable one route over, because ~1450 commits of new routes were added and the list was never one of the things anyone remembered to update. So the gate is now an allowlist, and a test walks the live route table and fails the build for any route that has not been deliberately classified for both non-admin lanes. That test is the actual fix: it is what stops the next route from arriving pre-authorized. Route reachability and field authority turned out to be different questions. A provider plugin has to be able to reconcile its own library entries — that is what a scanner plugin IS — but `prep` and a `command` launch inside that payload are handed to `/bin/sh -c` as the host user, and every execution site documents them as operator-typed. Requests now carry the lane that authorized them, and those two fields are refused to everyone but the operator's own token. The art proxy read any absolute path off disk in the host process, which on Windows is LocalSystem, from a path the plugin lane could write and then read back — so it yielded `mgmt-token`, which is full admin. It now serves only real images (extension AND magic bytes, so a renamed secret fails), only from inside an allowed root, only after canonicalization, and never over UNC; and a path it would refuse to serve can no longer be persisted in the first place. On Windows, the config-dir hardening was skipped exactly when it was needed — it ran only in the branch that CREATES host.env, so the case it was written for (a local user pre-created the directory and planted one) was the one case it never ran in. It is now unconditional and first, an existing host.env is re-owned, and the inheritable OWNER RIGHTS ACE that kept an attacker's files theirs after the directory was re-owned is gone. The identity and token readers were hardening the directory only on the path that GENERATED a new secret, so a planted cert/key or token was adopted verbatim and permanently; they harden before the first read now. `ensure_admin_only_source` is implemented. The 2026-07-05 audit recorded it as FIXED and it was in no commit in this repository's history — the local EoP it described was live, and it is the payload half of the config-dir chain above. Also: the three input planes are bounded and lossy like the mic plane on the same loop already was; Android's library client no longer accepts any publicly-trusted certificate for the pinned host; the usbip vhci nodes get their own group instead of riding on `input`, which every packaging scriptlet tells users to join; a registry URL can no longer inject a TOML table into bunfig.toml; the pairing cooldown is charged before the arming state is read, so armed/disarmed is no longer a free oracle; and the whole Low tier, of which the two worth naming are a clipboard MIME NUL that panicked the host on one control message, and an unauthenticated global logout that let any LAN peer sign the operator out on a loop. NOT fixed, deliberately: H-3 (plugin UIs framed allow-same-origin). Dropping allow-same-origin does not work: the document's origin goes opaque, its subresource requests are then cross-site, the SameSite=Lax session cookie is not sent, and every plugin asset 302s to /login. The "open in new tab" link is the same escalation with no iframe at all, so the sandbox attribute is not where this gets fixed either. It needs a second listener — a distinct origin that is still the same site — which changes the console's deploy model and wants on-glass validation. The mechanism and the dead end are written down at the iframe. H-6 registry authentication, whose other half lives in unom/infra. The in-repo halves are done: workflow_dispatch inputs no longer interpolate into run: blocks (one of them in the step holding UPDATE_MANIFEST_KEY), and the syft installer is pinned to its tag instead of main. Digest pinning is left until the registry is authenticated, because a tag — content-keyed or not — can simply be overwritten while anonymous pushes are accepted. M-5 is half done: the oracle is closed, but binding the arming window needs the console to learn the fingerprint first, which is a knock-then-bind flow rather than an edit. Verified: cargo fmt --all --check clean; cargo check --all-targets green on Linux and on Windows (confirmed non-vacuous — a planted type error in windows/install.rs fails the build); scripts/xcheck.sh windows check green; cargo test -p punktfunk-host --bins 416 passed, the single failure being gamestream::stream::tests::sender_delivers_batches, the known qemu-environmental UDP-loopback flake that fails identically on clean main in the same container; cargo test -p pf-clipboard 13 passed; web console typechecks.
127 lines
6.5 KiB
JavaScript
127 lines
6.5 KiB
JavaScript
// Custom Nitro server entry for the punktfunk web console.
|
|
//
|
|
// It is the stock Nitro `bun` preset entry
|
|
// (node_modules/nitropack/dist/presets/bun/runtime/bun.mjs) plus **TLS**, so the console is served
|
|
// over **HTTPS (HTTP/1.1 over TLS)** using the HOST's own identity cert (the cert native clients
|
|
// already pin). One trust anchor across the data plane, the management API, and this console. Wired
|
|
// in via `entry:` in vite.config.ts on top of Nitro's `bun` preset (which bundles the handler in).
|
|
//
|
|
// NOTE on HTTP/2 + HTTP/3: NOT offered here, on purpose. `Bun.serve` has no HTTP/2 server, and
|
|
// HTTP/3 (which Bun *can* do) is useless to a browser against this cert: QUIC refuses any cert error,
|
|
// and the host identity cert is a CN-only, no-SAN, self-signed cert (correct for native fingerprint
|
|
// PINNING, rejected by browsers). So browsers stay on HTTP/1.1 regardless — advertising h3 would just
|
|
// dangle an `Alt-Svc` no browser can use. Real h2/h3 would need a browser-TRUSTED, SAN-matching cert
|
|
// (a local CA installed per device) fronted by a server that speaks them (e.g. Caddy) — deliberately
|
|
// out of scope for a LAN console; TLS (no cleartext login/session) is the win.
|
|
//
|
|
// Env (set by the launchers / the systemd unit — see web.env.example):
|
|
// PUNKTFUNK_UI_TLS_CERT / _KEY PEM file paths (the host's cert.pem / key.pem). BOTH set ⇒ HTTPS.
|
|
// Unset ⇒ plain HTTP (local dev only).
|
|
// PORT / HOST standard Nitro bind (3000 / 0.0.0.0).
|
|
import "#nitro-internal-pollyfills";
|
|
import wsAdapter from "crossws/adapters/bun";
|
|
import { useNitroApp } from "nitropack/runtime";
|
|
import { startScheduleRunner } from "nitropack/runtime/internal";
|
|
|
|
const nitroApp = useNitroApp();
|
|
const ws = import.meta._websocket
|
|
? wsAdapter(nitroApp.h3App.websocket)
|
|
: undefined;
|
|
|
|
// The socket peer, handed to the app as a trusted header.
|
|
//
|
|
// Nitro's `localFetch` (below) hands the app a SYNTHETIC request whose socket has no
|
|
// `remoteAddress`, so h3's `getRequestIP()` returns undefined *inside* the app and every
|
|
// per-peer decision collapses onto one shared bucket. That silently defeated the login
|
|
// throttle: five wrong passwords from anywhere locked out everyone, including the operator
|
|
// (and, since the update-apply route shares that budget, locked out host updates too).
|
|
// `server.requestIP(req)` is the only place the real peer is knowable, so we stamp it here.
|
|
// Any inbound copy is deleted first, so a client cannot forge it.
|
|
// Read back by `peerAddress()` in server/util/auth.ts — keep the two names in sync.
|
|
const PEER_IP_HEADER = "x-pf-peer-ip";
|
|
|
|
// TLS from the host's identity cert (file PATHS → Bun.file, not PEM-in-env). Absent ⇒ plain HTTP.
|
|
const certPath = process.env.PUNKTFUNK_UI_TLS_CERT;
|
|
const keyPath = process.env.PUNKTFUNK_UI_TLS_KEY;
|
|
const tls =
|
|
certPath && keyPath
|
|
? { cert: Bun.file(certPath), key: Bun.file(keyPath) }
|
|
: undefined;
|
|
|
|
// Half-configured TLS is not a warning, it is a refusal.
|
|
//
|
|
// Two silent failures hide here, and both end with the operator staring at a console that looks
|
|
// fine. One path set and the other missing drops to plain HTTP — the login password then crosses
|
|
// the LAN in the clear on a server the operator believes is TLS. And PUNKTFUNK_UI_SECURE without
|
|
// TLS marks the session cookie Secure, which a browser refuses to store over http://, so login
|
|
// "succeeds" and every request after it is unauthenticated, forever.
|
|
//
|
|
// Neither state can serve a working console, so exiting is strictly better than serving a broken
|
|
// one: a supervisor logs the reason and the operator sees a stopped service instead of a subtly
|
|
// wrong one.
|
|
const secureFlag = /^(1|true)$/i.test(process.env.PUNKTFUNK_UI_SECURE ?? "");
|
|
if (Boolean(certPath) !== Boolean(keyPath)) {
|
|
console.error(
|
|
`punktfunk web console: only ${certPath ? "PUNKTFUNK_UI_TLS_CERT" : "PUNKTFUNK_UI_TLS_KEY"} is set — ` +
|
|
"TLS needs BOTH. Refusing to start rather than serve the login password in the clear.",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
if (!tls && secureFlag) {
|
|
console.error(
|
|
"punktfunk web console: PUNKTFUNK_UI_SECURE is set but TLS is not configured. The session " +
|
|
"cookie would be marked Secure and dropped by the browser over http://, so login could " +
|
|
"never stick. Refusing to start — set PUNKTFUNK_UI_TLS_CERT/_KEY, or unset PUNKTFUNK_UI_SECURE.",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const server = Bun.serve({
|
|
port: process.env.NITRO_PORT || process.env.PORT || 3000,
|
|
host: process.env.NITRO_HOST || process.env.HOST,
|
|
// Bun defaults this to 10 s, which is SHORTER than the host's 15 s SSE keep-alive comment — so a
|
|
// proxied `/api/v1/events` stream (or any other quiet long-lived response) gets cut by us and
|
|
// reconnects on a loop. 120 s is comfortably above any keep-alive we forward; still overridable.
|
|
idleTimeout: Number.parseInt(process.env.NITRO_BUN_IDLE_TIMEOUT, 10) || 120,
|
|
// Cap the request body an UNAUTHENTICATED peer can make us hold in memory.
|
|
//
|
|
// `fetch` below buffers the whole body with `await req.arrayBuffer()` before Nitro — and
|
|
// therefore before the auth gate — has seen the request, so Bun's 128 MB default was the only
|
|
// bound on what a LAN peer could push into console RSS by POSTing to /login (2026-08-05 review
|
|
// L-10). Nothing the console legitimately accepts is remotely this large: the biggest real body
|
|
// is a hooks/library JSON edit, kilobytes. 4 MiB leaves several orders of headroom and still
|
|
// makes the memory cost of an unauthenticated request negligible.
|
|
maxRequestBodySize:
|
|
Number.parseInt(process.env.NITRO_BUN_MAX_BODY_BYTES, 10) || 4 * 1024 * 1024,
|
|
// `tls: undefined` ⇒ plain HTTP (dev); otherwise HTTPS over HTTP/1.1.
|
|
tls,
|
|
websocket: import.meta._websocket ? ws.websocket : undefined,
|
|
async fetch(req, server) {
|
|
if (import.meta._websocket && req.headers.get("upgrade") === "websocket") {
|
|
return ws.handleUpgrade(req, server);
|
|
}
|
|
const url = new URL(req.url);
|
|
let body;
|
|
if (req.body) {
|
|
body = await req.arrayBuffer();
|
|
}
|
|
// Strip any client-supplied value BEFORE stamping the real one (see PEER_IP_HEADER).
|
|
const headers = new Headers(req.headers);
|
|
headers.delete(PEER_IP_HEADER);
|
|
const peer = server.requestIP(req)?.address;
|
|
if (peer) headers.set(PEER_IP_HEADER, peer);
|
|
return nitroApp.localFetch(url.pathname + url.search, {
|
|
host: url.hostname,
|
|
protocol: url.protocol,
|
|
headers,
|
|
method: req.method,
|
|
redirect: req.redirect,
|
|
body,
|
|
});
|
|
},
|
|
});
|
|
console.log(`punktfunk web console listening on ${server.url} (tls=${!!tls})`);
|
|
if (import.meta._tasks) {
|
|
startScheduleRunner();
|
|
}
|