The stats charts drew every sample as an evenly-spaced slot, because recharts defaults to a category axis. A capture that idled for two minutes rendered that gap as a single step — so the one view you open specifically to find where the time went was the view least able to show it. All three charts use a numeric time axis now, so the spacing is the elapsed time. They also joined samples across a session boundary into one continuous line, implying a continuity that never existed: the stream stopped and somebody else started a new one. A capture is split at each `session_id` change now. And the live card plotted the whole capture-so-far every 2 s, re-serialising and re-plotting an unbounded series for a capture left running all evening; it plots a bounded tail and says so, with the full series still in the saved recording. Logging out only deleted the browser's copy of the cookie. The session is stateless, so a value captured beforehand — a shared machine, a shell history, a TLS-inspecting proxy — stayed valid for its full 7-day TTL and there was nothing the operator could do about it. Sessions carry an epoch now and logging out bumps it, which invalidates every cookie issued so far. Verified end to end: a captured cookie works, survives nothing across a logout, and a fresh login still works. The rest: - The update card could not show its own timeout warning. It was suppressed by a `job` field read from the last snapshot — which, when the host has gone away mid-job, is exactly the case the warning exists for. Nothing ever cleared the applying state either, so the card waited forever with no way out; there is a button now. "Check now" also surfaces the host's 429 instead of looking dead. - A running install survives a reload: the job id lived only in component state, so refreshing lost sight of an install that was still running while the Install buttons stayed armed against a host that answers 409. The host keeps the list — ask it. - An all-sources-failed catalog said "no plugins available". That is a successful request carrying nothing, not an empty store; it names the sources that failed. - The Installed tab rendered "vundefined" for a plugin with no recorded version (nullable in the contract, typed required here). - The Displays "In effect" badges were computed from the local draft, so they restated the operator's unsaved edits back to them as though the host had adopted them. They read the API's `effective` now. A failed background poll no longer replaces a form someone is editing, and leaving the page with unsaved edits prompts — the old `beforeunload` guard never fired for in-app navigation, which is how you actually leave. - Enter or Space on a preset's rename/update/delete icon applied the preset instead of running the action: keydown bubbled to the card. - Dates follow the console's locale, not the browser's. The dashboard's PIN-pending tile says "Waiting"/"None" instead of "●"/"—". - The Bun entry warns when TLS is half-configured, or when PUNKTFUNK_UI_SECURE is set without it — both of which silently break login. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
225 lines
9.5 KiB
TypeScript
225 lines
9.5 KiB
TypeScript
// 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 {
|
|
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
|
|
// (set PUNKTFUNK_UI_SECURE=1 / =true then).
|
|
secure: /^(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;
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* A revocation counter for issued sessions.
|
|
*
|
|
* The session is stateless: everything lives inside the sealed cookie, so `session.clear()` only
|
|
* deletes the BROWSER's copy. A cookie captured beforehand (a shared machine, a shell history, a
|
|
* TLS-inspecting proxy) stayed valid for its full 7-day TTL with nothing the operator could do
|
|
* about it — "log out" did not log anything out.
|
|
*
|
|
* Bumping this invalidates every previously issued cookie, because the gate compares the stamped
|
|
* epoch against the current one. It lives in memory, so a host restart also revokes — acceptable
|
|
* for a single-user console, and the safe direction to fail.
|
|
*/
|
|
let epoch = 1;
|
|
|
|
/** The epoch a new session is stamped with, and the one the gate requires. */
|
|
export function sessionEpoch(): number {
|
|
return epoch;
|
|
}
|
|
|
|
/** Invalidate every session issued so far (the "sign out everywhere" lever). */
|
|
export function revokeAllSessions(): void {
|
|
epoch += 1;
|
|
}
|