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.
270 lines
12 KiB
TypeScript
270 lines
12 KiB
TypeScript
/**
|
|
* A revocation marker for issued sessions, PERSISTED across restarts.
|
|
*
|
|
* The session is stateless: everything lives inside the sealed cookie, so `session.clear()` only
|
|
* deletes the BROWSER's copy. A cookie captured beforehand stayed valid for its full 7-day TTL —
|
|
* "log out" did not log anything out.
|
|
*
|
|
* The counter has to survive a restart or it does not do its job: an in-memory `let epoch = 1`
|
|
* revokes within one process run, then resets to 1 the next time the service starts, and a cookie
|
|
* captured from that first run is accepted again for the rest of its TTL. (The seal key cannot save
|
|
* us — it is derived from the stable mgmt token, so pre-restart cookies still unseal fine.) So it
|
|
* lives in a file next to the host's own config.
|
|
*
|
|
* Best-effort by design: if the file cannot be read or written the console still works, it just
|
|
* falls back to in-memory revocation for this process. Refusing to log anyone out because a state
|
|
* file is unwritable would be the wrong trade for a LAN console.
|
|
*/
|
|
const EPOCH_FILE = (): string =>
|
|
process.env.PUNKTFUNK_UI_EPOCH_FILE ??
|
|
join(
|
|
process.env.PUNKTFUNK_CONFIG_DIR ?? join(homedir(), ".config", "punktfunk"),
|
|
"web-session-epoch",
|
|
);
|
|
|
|
let epochCache: number | null = null;
|
|
|
|
/** The epoch a new session is stamped with, and the one the gate requires. */
|
|
export function sessionEpoch(): number {
|
|
if (epochCache !== null) return epochCache;
|
|
try {
|
|
const raw = readFileSync(EPOCH_FILE(), "utf8").trim();
|
|
const n = Number.parseInt(raw, 10);
|
|
epochCache = Number.isFinite(n) && n > 0 ? n : 1;
|
|
} catch {
|
|
epochCache = 1; // no file yet — first run
|
|
}
|
|
return epochCache;
|
|
}
|
|
|
|
/** Invalidate every session issued so far (what logging out does). */
|
|
export function revokeAllSessions(): void {
|
|
const next = sessionEpoch() + 1;
|
|
epochCache = next;
|
|
try {
|
|
mkdirSync(dirname(EPOCH_FILE()), { recursive: true });
|
|
writeFileSync(EPOCH_FILE(), String(next), { mode: 0o600 });
|
|
} catch {
|
|
// Unwritable state dir: the bump still holds for this process, which is the common case
|
|
// (log out, walk away). It is weaker than persisted, and better than refusing to log out.
|
|
}
|
|
}
|
|
|
|
// Shared auth helpers for the Nitro server (the deployed Bun server). Single-user,
|
|
// shared-password gate: the user logs in with PUNKTFUNK_UI_PASSWORD, which sets a SEALED
|
|
// (h3 useSession — AES-GCM) cookie; every request is gated by server/middleware/auth.ts.
|
|
//
|
|
// The management token never reaches the browser: server/routes/api/[...].ts injects it
|
|
// server-side when proxying to the loopback management API.
|
|
import {
|
|
createHash,
|
|
timingSafeEqual as nodeTimingSafeEqual,
|
|
} from "node:crypto";
|
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
import {
|
|
getRequestHeader,
|
|
getRequestIP,
|
|
type H3Event,
|
|
type SessionConfig,
|
|
} from "h3";
|
|
|
|
export const SESSION_NAME = "pf_session";
|
|
|
|
/** Set by the Bun entry (nitro-entry/bun-https.mjs) to the real socket peer, after deleting any
|
|
* inbound copy. Keep the name in sync with that file. */
|
|
const PEER_IP_HEADER = "x-pf-peer-ip";
|
|
|
|
/**
|
|
* The requesting peer, as the key for every per-peer budget (currently the login throttle).
|
|
*
|
|
* `getRequestIP()` alone does NOT work under the deployed server: Nitro's `localFetch` builds a
|
|
* synthetic request whose socket carries no `remoteAddress`, so h3 finds nothing and every caller
|
|
* collapses onto one shared bucket — which turned the "per-IP" login throttle into a lockout any
|
|
* LAN peer could trigger for everyone. The Bun entry stamps the real peer into PEER_IP_HEADER
|
|
* (unforgeable: it deletes any client-supplied copy first), so prefer that.
|
|
*
|
|
* `getRequestIP` is kept as the fallback for any other ingress (a plain `node`/dev run), and
|
|
* "unknown" as the last resort — a SHARED bucket, deliberately: an unattributable request must
|
|
* still be rate-limited, and failing open would make brute force unbounded.
|
|
*/
|
|
export function peerAddress(event: H3Event): string {
|
|
const stamped = getRequestHeader(event, PEER_IP_HEADER)?.trim();
|
|
if (stamped) return stamped;
|
|
return getRequestIP(event) ?? "unknown";
|
|
}
|
|
|
|
/** The login password. Empty string ⇒ auth is MISCONFIGURED (the gate fails closed). */
|
|
export function uiPassword(): string {
|
|
return process.env.PUNKTFUNK_UI_PASSWORD ?? "";
|
|
}
|
|
|
|
/** The management API the proxy forwards to (loopback by default — never LAN-exposed). It serves
|
|
* HTTPS with the host's self-signed identity cert, so the proxy relaxes verification for that ONE
|
|
* loopback hop via Bun's per-request `tls` option (routes/api/[...].ts, util/forward.ts). There is
|
|
* deliberately no process-wide NODE_TLS_REJECT_UNAUTHORIZED — see .env.example. */
|
|
export function mgmtUrl(): string {
|
|
return process.env.PUNKTFUNK_MGMT_URL ?? "https://127.0.0.1:47990";
|
|
}
|
|
|
|
/** Bearer token for the management API, injected server-side. */
|
|
export function mgmtToken(): string {
|
|
return process.env.PUNKTFUNK_MGMT_TOKEN ?? "";
|
|
}
|
|
|
|
/** Whether `url`'s host is a loopback address — the only place the proxy relaxes TLS verification
|
|
* for the host's self-signed cert. IPv4 127.0.0.0/8, IPv6 ::1, and the `localhost` name. */
|
|
export function isLoopbackUrl(url: string): boolean {
|
|
let host: string;
|
|
try {
|
|
host = new URL(url).hostname;
|
|
} catch {
|
|
return false;
|
|
}
|
|
// URL wraps IPv6 in brackets in .host but strips them in .hostname; normalize anyway.
|
|
const h = host.replace(/^\[|\]$/g, "").toLowerCase();
|
|
if (h === "localhost" || h === "::1") return true;
|
|
return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h);
|
|
}
|
|
|
|
/**
|
|
* The cookie-sealing key for h3 `useSession` (must be ≥ 32 chars). Precedence:
|
|
* 1. PUNKTFUNK_UI_SECRET — explicit operator override.
|
|
* 2. Derived from the MANAGEMENT TOKEN (a 32-byte / 64-hex CSPRNG value) — the packaged deployment
|
|
* always has one, so the seal key is high-entropy without any extra config.
|
|
* 3. Only as a last resort (dev/local with no token) derive from the password.
|
|
*
|
|
* Why not (2)→password by default: the password is low-entropy (a human picks it), so a key DERIVED
|
|
* from it turns any captured session cookie into an OFFLINE dictionary oracle — an attacker unseals
|
|
* candidate cookies locally, no server round-trips, so the login throttle can't help. The mgmt token
|
|
* is unguessable, so a cookie sealed under it leaks nothing about the password. (Deriving from the
|
|
* token instead of the password also means changing the password no longer silently invalidates
|
|
* sessions; rotating the mgmt token does — the correct, security-relevant trigger.)
|
|
*/
|
|
export function sessionConfig(): SessionConfig {
|
|
const explicit = process.env.PUNKTFUNK_UI_SECRET;
|
|
const token = mgmtToken();
|
|
let secret: string;
|
|
if (explicit && explicit.length >= 32) {
|
|
secret = explicit;
|
|
} else if (token) {
|
|
// High-entropy source: the CSPRNG mgmt token. Hash it (never use the raw admin token as the
|
|
// seal key) with a distinct label so the two uses can't be conflated.
|
|
secret = createHash("sha256")
|
|
.update(`punktfunk-session-v1:token:${token}`)
|
|
.digest("hex");
|
|
} else {
|
|
// Last resort (no token configured — dev/local only). No worse than before; a real deployment
|
|
// always has a token and never reaches here.
|
|
secret = createHash("sha256")
|
|
.update(`punktfunk-session-v1:${uiPassword()}`)
|
|
.digest("hex");
|
|
}
|
|
return {
|
|
name: SESSION_NAME,
|
|
// h3's `useSession` calls this seal key `password` (it's the iron/AES-GCM key, not the login
|
|
// password — see the derivation above).
|
|
password: secret,
|
|
// Bounds a stolen/replayed cookie's lifetime (sets the cookie Max-Age AND the iron
|
|
// seal TTL). 7 days for a single-user console.
|
|
maxAge: 60 * 60 * 24 * 7,
|
|
cookie: {
|
|
httpOnly: true,
|
|
sameSite: "lax",
|
|
path: "/",
|
|
// h3 defaults Secure to true, which browsers DROP over plain http:// (so login
|
|
// silently fails on a LAN HTTP server). Only mark Secure when actually behind TLS.
|
|
//
|
|
// Derived from whether TLS is CONFIGURED, not from `PUNKTFUNK_UI_SECURE` alone
|
|
// (2026-08-05 review L-20). The entry point already refuses the inverse mistake —
|
|
// `PUNKTFUNK_UI_SECURE` without TLS exits rather than serving a console whose cookie
|
|
// the browser will never store — but nothing caught this direction: TLS configured and
|
|
// the flag forgotten shipped a session cookie without `Secure`, which a browser will
|
|
// then also send over a plain-http downgrade. The env var still forces it on for a
|
|
// deploy terminating TLS in front of us (a reverse proxy), where this process sees no
|
|
// cert of its own.
|
|
secure:
|
|
(!!process.env.PUNKTFUNK_UI_TLS_CERT &&
|
|
!!process.env.PUNKTFUNK_UI_TLS_KEY) ||
|
|
/^(1|true)$/i.test(process.env.PUNKTFUNK_UI_SECURE ?? ""),
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Constant-time string comparison (avoids leaking the password via timing). */
|
|
export function timingSafeEqual(a: string, b: string): boolean {
|
|
const ab = Buffer.from(a);
|
|
const bb = Buffer.from(b);
|
|
if (ab.length !== bb.length) return false;
|
|
return nodeTimingSafeEqual(ab, bb);
|
|
}
|
|
|
|
/** Paths reachable WITHOUT a session: the login page, the auth endpoints, and the build's
|
|
* static assets (the login page needs its own CSS/JS, all of which live under /assets/).
|
|
* Everything else — crucially ALL of /api — is gated.
|
|
*
|
|
* Note: do NOT allowlist by file extension. The client assets are all under /assets/, and a
|
|
* generic `*.json` allowlist would expose `/api/v1/openapi.json` (and any future
|
|
* `.json`/`.png` management route) through the proxy unauthenticated. */
|
|
export function isPublicPath(pathname: string): boolean {
|
|
if (pathname === "/api" || pathname.startsWith("/api/")) return false; // always gated
|
|
if (pathname === "/login") return true;
|
|
if (pathname.startsWith("/_auth/")) return true;
|
|
if (pathname.startsWith("/assets/")) return true;
|
|
if (pathname === "/favicon.ico" || pathname === "/robots.txt") return true;
|
|
// The web manifest must be fetchable to install the app, and it says nothing a logged-out
|
|
// visitor cannot already see from the login page (name, colours, the brand mark).
|
|
if (pathname === "/manifest.webmanifest") return true;
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Collapse a request path to the shape an upstream router will actually see: percent-decoded,
|
|
* with empty (`//`) and `.` segments dropped and `..` resolved. Used to test denylists against
|
|
* something an attacker cannot re-spell — `/api//v1/x`, `/api/./v1/x` and `/api/v1/%78` all reach
|
|
* the same handler, so matching only the literal path is not a security boundary.
|
|
*
|
|
* Decoding is per segment and failure-tolerant: a malformed escape keeps the raw segment rather
|
|
* than throwing, so a bad path degrades to "does not match the canonical form" instead of a 500.
|
|
*/
|
|
export function normalizePath(pathname: string): string {
|
|
const out: string[] = [];
|
|
for (const raw of pathname.split("/")) {
|
|
let seg = raw;
|
|
try {
|
|
seg = decodeURIComponent(raw);
|
|
} catch {
|
|
// Malformed escape — keep the raw segment.
|
|
}
|
|
if (seg === "" || seg === ".") continue;
|
|
if (seg === "..") {
|
|
out.pop();
|
|
continue;
|
|
}
|
|
out.push(seg);
|
|
}
|
|
return `/${out.join("/")}`;
|
|
}
|
|
|
|
/** Validate a post-login redirect target: a same-origin path only. Resolves `next` against a
|
|
* sentinel origin and keeps it only if it stays same-origin — rejecting absolute (`https://evil.com`),
|
|
* protocol-relative (`//evil.com`) AND backslash/tab variants (`/\evil.com`, which the WHATWG URL
|
|
* parser folds to `//evil.com`) that a plain `startsWith("//")` guard lets through. */
|
|
export function safeNextPath(next: string | undefined): string {
|
|
if (!next) return "/";
|
|
try {
|
|
const base = "http://pf.invalid";
|
|
const u = new URL(next, base);
|
|
return u.origin === base ? u.pathname + u.search + u.hash : "/";
|
|
} catch {
|
|
return "/";
|
|
}
|
|
}
|
|
|
|
export interface SessionData {
|
|
authenticated?: boolean;
|
|
/** The epoch this session was sealed under — see `sessionEpoch`. */
|
|
epoch?: number;
|
|
}
|