Files
punktfunk/web/server/util/auth.ts
T
enricobuehler 8103958169 fix(security): the plugin lane stops being a way in
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.
2026-08-05 17:12:12 +02:00

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;
}