diff --git a/web/messages/de.json b/web/messages/de.json
index 6e2c37c9..0e49c338 100644
--- a/web/messages/de.json
+++ b/web/messages/de.json
@@ -369,6 +369,7 @@
"store_source_trust_title": "Dieser Quelle vertrauen?",
"store_source_trust_body": "Alles, was du aus „{name}“ installierst, ist Code, den unom nicht geprüft hat. Er läuft auf diesem Host mit den Rechten des Plugin-Runners. Füge nur einen Katalog hinzu, dessen Betreiber du vertraust.",
"store_source_trust_unsigned": "Ohne öffentlichen Schlüssel kann der Host nicht erkennen, ob dieser Index unterwegs manipuliert wurde.",
+ "store_source_password": "Konsolen-Passwort",
"store_source_trust_confirm": "Verstanden — Quelle hinzufügen",
"store_install_title": "{title} installieren?",
"store_install_verified_body": "Version {version} aus dem eingebauten unom-Katalog. unom hat genau dieses Paket geprüft.",
@@ -387,6 +388,8 @@
"store_spec_confirm_field": "Gib die Paketangabe zur Bestätigung erneut ein",
"store_spec_checkbox": "Mir ist klar, dass hier ungeprüfter Code mit Betreiberrechten ausgeführt wird.",
"store_spec_confirm": "Ungeprüft installieren",
+ "store_spec_password": "Konsolen-Passwort",
+ "store_spec_password_help": "Für ungeprüften Code wird das Passwort erneut gebraucht — eine Browser-Sitzung allein reicht dafür nicht.",
"store_job_install": "{target} wird installiert",
"store_job_uninstall": "{target} wird entfernt",
"store_job_done_install": "Installiert.",
diff --git a/web/messages/en.json b/web/messages/en.json
index 63a1a87f..b7268007 100644
--- a/web/messages/en.json
+++ b/web/messages/en.json
@@ -369,6 +369,7 @@
"store_source_trust_title": "Trust this source?",
"store_source_trust_body": "Everything you install from “{name}” is code unom has not reviewed. It runs on this host with the plugin runner's privileges. Only add a catalog whose operator you trust.",
"store_source_trust_unsigned": "Without a public key the host can't tell whether this index was tampered with in transit.",
+ "store_source_password": "Console password",
"store_source_trust_confirm": "I understand — add the source",
"store_install_title": "Install {title}?",
"store_install_verified_body": "Version {version} from the built-in unom catalog. unom reviewed this exact package.",
@@ -387,6 +388,8 @@
"store_spec_confirm_field": "Type the package spec again to confirm",
"store_spec_checkbox": "I understand that this runs unreviewed code with operator privileges.",
"store_spec_confirm": "Install unverified",
+ "store_spec_password": "Console password",
+ "store_spec_password_help": "Running unreviewed code needs the password again — a browser session on its own can't do this.",
"store_job_install": "Installing {target}",
"store_job_uninstall": "Removing {target}",
"store_job_done_install": "Installed.",
diff --git a/web/nitro-entry/bun-https.mjs b/web/nitro-entry/bun-https.mjs
index 5df44215..736c6039 100644
--- a/web/nitro-entry/bun-https.mjs
+++ b/web/nitro-entry/bun-https.mjs
@@ -28,6 +28,18 @@ 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;
@@ -53,10 +65,15 @@ const server = Bun.serve({
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: req.headers,
+ headers,
method: req.method,
redirect: req.redirect,
body,
diff --git a/web/server/middleware/auth.ts b/web/server/middleware/auth.ts
index 51262adf..570aea01 100644
--- a/web/server/middleware/auth.ts
+++ b/web/server/middleware/auth.ts
@@ -7,6 +7,7 @@ import {
getRequestHeader,
getRequestURL,
sendRedirect,
+ setResponseHeader,
setResponseStatus,
useSession,
} from "h3";
@@ -20,6 +21,23 @@ import {
export default defineEventHandler(async (event) => {
const { pathname } = getRequestURL(event);
+ // Baseline response headers for everything this server emits. Deliberately modest: a plugin's
+ // own UI is proxied onto THIS origin (/plugin-ui/**), so a script-src policy tight enough to be
+ // worth having would break third-party plugin pages we don't control. What is safe to assert
+ // unconditionally still closes the cheap holes:
+ // nosniff — a plugin serving text/plain that "looks like" HTML can't be sniffed into it
+ // frame-ancestors— only our own pages may frame the console (the plugin iframes are same-origin)
+ // object-src — no Flash/applet embedding anywhere
+ // base-uri — a stray can't repoint every relative URL on the page
+ // Referrer-Policy— never leak a console path (which can carry ids) to an external homepage link
+ setResponseHeader(event, "X-Content-Type-Options", "nosniff");
+ setResponseHeader(event, "Referrer-Policy", "no-referrer");
+ setResponseHeader(
+ event,
+ "Content-Security-Policy",
+ "frame-ancestors 'self'; object-src 'none'; base-uri 'self'",
+ );
+
// Same-origin check for every MUTATING request (defense in depth beyond SameSite=Lax,
// added with the update-apply route where CSRF ≈ code execution — design
// host-update-from-web-console.md §4.3). `Sec-Fetch-Site` is browser-set and unforgeable
diff --git a/web/server/routes/_auth/login.post.ts b/web/server/routes/_auth/login.post.ts
index 42ddd659..26335b52 100644
--- a/web/server/routes/_auth/login.post.ts
+++ b/web/server/routes/_auth/login.post.ts
@@ -5,12 +5,12 @@
import {
createError,
defineEventHandler,
- getRequestIP,
readBody,
setResponseHeader,
useSession,
} from "h3";
import {
+ peerAddress,
type SessionData,
sessionConfig,
timingSafeEqual,
@@ -31,9 +31,9 @@ export default defineEventHandler(async (event) => {
});
}
// The socket peer address — deliberately NOT trusting X-Forwarded-For (spoofable unless we sit
- // behind a known proxy, which the packaged console does not). Falls back to a single shared bucket
- // if the address is somehow unavailable, so the throttle still applies.
- const ip = getRequestIP(event) ?? "unknown";
+ // behind a known proxy, which the packaged console does not). See `peerAddress`: under the Bun
+ // entry this is the real peer; the shared "unknown" bucket is only a last-resort fallback.
+ const ip = peerAddress(event);
// Throttle BEFORE touching the password so a locked-out client can't keep the guess loop spinning.
const wait = throttleRetryAfterMs(ip);
diff --git a/web/server/routes/api/[...].ts b/web/server/routes/api/[...].ts
index 70f67fd0..392ba59c 100644
--- a/web/server/routes/api/[...].ts
+++ b/web/server/routes/api/[...].ts
@@ -10,7 +10,12 @@ import {
proxyRequest,
setResponseStatus,
} from "h3";
-import { isLoopbackUrl, mgmtToken, mgmtUrl } from "../../util/auth";
+import {
+ isLoopbackUrl,
+ mgmtToken,
+ mgmtUrl,
+ normalizePath,
+} from "../../util/auth";
export default defineEventHandler((event) => {
const { pathname, search } = getRequestURL(event);
@@ -18,7 +23,12 @@ export default defineEventHandler((event) => {
// /plugin-ui proxy and must NEVER reach a browser — deny it on the generic passthrough so a
// session-authed page can't read it (plugin-ui-surface §5, D6). The secret-free list at
// /api/v1/plugins is fine; only the {id}/ui-credential leaf is blocked.
- if (/^\/api\/v1\/plugins\/[^/]+\/ui-credential\/?$/.test(pathname)) {
+ //
+ // Matched against the NORMALIZED path as well as the raw one: `/api//v1/...`, `/api/./v1/...`
+ // and percent-encoded variants all reach the same upstream route, and a denylist that only
+ // knows the canonical spelling is one router-quirk away from leaking the secret.
+ const denied = /^\/api\/v1\/plugins\/[^/]+\/ui-credential\/?$/i;
+ if (denied.test(pathname) || denied.test(normalizePath(pathname))) {
setResponseStatus(event, 403);
return {
error: "plugin UI credentials are not accessible from the browser",
diff --git a/web/server/routes/api/v1/store/install.post.ts b/web/server/routes/api/v1/store/install.post.ts
new file mode 100644
index 00000000..9606ae92
--- /dev/null
+++ b/web/server/routes/api/v1/store/install.post.ts
@@ -0,0 +1,32 @@
+// POST /api/v1/store/install — wins over the `/api/**` catch-all (h3 route specificity), so the
+// raw-spec branch can never reach the host without a password.
+//
+// Two shapes arrive here:
+// { source, id } — a curated catalog entry. Forwarded as-is: the operator
+// already made the trust decision when they added the source.
+// { spec, accept_unverified: true } — an unreviewed package, no catalog, no pinning. This is
+// arbitrary code execution on the host, so it is gated on the
+// console password exactly like update/apply (util/confirm.ts).
+import { defineEventHandler, readBody } from "h3";
+import { confirmPassword } from "../../../../util/confirm";
+import { forwardJson } from "../../../../util/forward";
+
+interface InstallBody {
+ source?: string;
+ id?: string;
+ spec?: string;
+ accept_unverified?: boolean;
+ password?: string;
+}
+
+export default defineEventHandler(async (event) => {
+ const body = await readBody(event);
+ const rawSpec = body?.accept_unverified === true;
+ if (rawSpec) confirmPassword(event, body?.password);
+ // The password stops here — rebuild the upstream body from known fields so it cannot leak
+ // through, and so an unexpected extra field can't ride along to the host.
+ const upstream = rawSpec
+ ? { spec: String(body?.spec ?? ""), accept_unverified: true }
+ : { source: String(body?.source ?? ""), id: String(body?.id ?? "") };
+ return forwardJson(event, "/api/v1/store/install", "POST", upstream);
+});
diff --git a/web/server/routes/api/v1/store/sources/[name].put.ts b/web/server/routes/api/v1/store/sources/[name].put.ts
new file mode 100644
index 00000000..e176a07d
--- /dev/null
+++ b/web/server/routes/api/v1/store/sources/[name].put.ts
@@ -0,0 +1,33 @@
+// PUT /api/v1/store/sources/{name} — adding or repointing a catalog source is a TRUST-ROOT change:
+// every future install from that source is admitted on its say-so, and `public_key` is optional, so
+// a source may be unsigned. That is the boundary worth a password (util/confirm.ts), not each
+// individual install past it. Wins over the `/api/**` catch-all by h3 route specificity.
+//
+// DELETE is deliberately NOT gated — removing a source only ever narrows what the host will trust.
+import { defineEventHandler, getRouterParam, readBody } from "h3";
+import { confirmPassword } from "../../../../../util/confirm";
+import { forwardJson } from "../../../../../util/forward";
+
+interface SourceBody {
+ url?: string;
+ public_key?: string;
+ password?: string;
+}
+
+export default defineEventHandler(async (event) => {
+ const body = await readBody(event);
+ confirmPassword(event, body?.password);
+ const name = getRouterParam(event, "name") ?? "";
+ // Rebuild the body from known fields so the password cannot leak upstream.
+ const upstream: { url: string; public_key?: string } = {
+ url: String(body?.url ?? ""),
+ };
+ const key = body?.public_key?.trim();
+ if (key) upstream.public_key = key;
+ return forwardJson(
+ event,
+ `/api/v1/store/sources/${encodeURIComponent(name)}`,
+ "PUT",
+ upstream,
+ );
+});
diff --git a/web/server/routes/api/v1/update/apply.post.ts b/web/server/routes/api/v1/update/apply.post.ts
index 7216ff51..8c2c565f 100644
--- a/web/server/routes/api/v1/update/apply.post.ts
+++ b/web/server/routes/api/v1/update/apply.post.ts
@@ -1,89 +1,21 @@
-// POST /api/v1/update/apply — the ONE proxied route with an extra gate: the console password
-// must be re-entered per apply (design host-update-from-web-console.md §4.3). A 7-day session
-// cookie alone must not be able to update-and-restart the host; the password is verified HERE
-// (only the BFF knows it), stripped, and never forwarded. Wrong attempts share the login
-// throttle's per-IP budget, so apply can't be used as a password oracle.
+// POST /api/v1/update/apply — a proxied route with an extra gate: the console password must be
+// re-entered per apply (design host-update-from-web-console.md §4.3). A 7-day session cookie alone
+// must not be able to update-and-restart the host; the password is verified in `confirmPassword`
+// (only the BFF knows it), stripped, and never forwarded. Wrong attempts share the login throttle's
+// per-peer budget, so apply can't be used as a password oracle.
//
// This specific file wins over the `[...]` catch-all (h3 route specificity) — verified in the
// U1 gate; everything else about proxying (bearer injection, loopback TLS scoping, 401→502)
-// mirrors ../../[...].ts.
-import {
- createError,
- defineEventHandler,
- getRequestIP,
- readBody,
- setResponseHeader,
- setResponseStatus,
-} from "h3";
-import {
- isLoopbackUrl,
- mgmtToken,
- mgmtUrl,
- timingSafeEqual,
- uiPassword,
-} from "../../../../util/auth";
-import {
- recordLoginFailure,
- recordLoginSuccess,
- throttleRetryAfterMs,
-} from "../../../../util/loginThrottle";
+// lives in util/forward.ts and mirrors ../../[...].ts.
+import { defineEventHandler, readBody } from "h3";
+import { confirmPassword } from "../../../../util/confirm";
+import { forwardJson } from "../../../../util/forward";
export default defineEventHandler(async (event) => {
- const expected = uiPassword();
- if (!expected) {
- throw createError({ statusCode: 503, statusMessage: "auth not configured" });
- }
- const ip = getRequestIP(event) ?? "unknown";
- const wait = throttleRetryAfterMs(ip);
- if (wait > 0) {
- setResponseHeader(event, "Retry-After", Math.ceil(wait / 1000));
- throw createError({
- statusCode: 429,
- statusMessage: "too many attempts — try again shortly",
- });
- }
-
const body = await readBody<{ password?: string; force?: boolean }>(event);
- const password = String(body?.password ?? "");
- if (!timingSafeEqual(password, expected)) {
- recordLoginFailure(ip);
- throw createError({
- statusCode: 401,
- statusMessage: "password confirmation failed",
- });
- }
- recordLoginSuccess(ip);
-
- const token = mgmtToken();
- if (!token) {
- setResponseStatus(event, 503);
- return { error: "management token not configured" };
- }
- const base = mgmtUrl();
- const init: RequestInit = {
- method: "POST",
- headers: {
- authorization: `Bearer ${token}`,
- "content-type": "application/json",
- },
- // The password stops here — the host only ever sees the force flag.
- body: JSON.stringify({ force: body?.force === true }),
- };
- if (isLoopbackUrl(base)) {
- // Bun.fetch extension (see ../../[...].ts for why this is scoped per-request).
- (init as unknown as { tls: { rejectUnauthorized: boolean } }).tls = {
- rejectUnauthorized: false,
- };
- }
- const upstream = await fetch(`${base}/api/v1/update/apply`, init);
- if (upstream.status === 401) {
- throw createError({
- statusCode: 502,
- statusMessage:
- "management API rejected the host token (check PUNKTFUNK_MGMT_TOKEN)",
- });
- }
- setResponseStatus(event, upstream.status);
- setResponseHeader(event, "content-type", "application/json");
- return upstream.text();
+ confirmPassword(event, body?.password);
+ // The password stops here — the host only ever sees the force flag.
+ return forwardJson(event, "/api/v1/update/apply", "POST", {
+ force: body?.force === true,
+ });
});
diff --git a/web/server/routes/plugin-ui/[...].ts b/web/server/routes/plugin-ui/[...].ts
index 88fdc2ed..088f40f4 100644
--- a/web/server/routes/plugin-ui/[...].ts
+++ b/web/server/routes/plugin-ui/[...].ts
@@ -39,10 +39,12 @@ export default defineEventHandler(async (event) => {
delete headers.authorization;
headers["x-forwarded-prefix"] = prefix;
const method = event.method;
- const body =
- method === "GET" || method === "HEAD"
- ? undefined
- : ((await readRawBody(event, false)) as Uint8Array | undefined);
+ // Only read a body for the methods that can carry one. `readRawBody` asserts a payload method,
+ // so calling it for OPTIONS (a plugin UI's CORS preflight, or any client probing Allow) threw
+ // 405 out of the CONSOLE before the plugin was ever dialed.
+ const body = BODY_METHODS.has(method)
+ ? ((await readRawBody(event, false)) as Uint8Array | undefined)
+ : undefined;
// One proxied attempt; `null` means the plugin is unreachable (unregistered, or its port died).
const attempt = async (bustCache: boolean): Promise => {
@@ -74,5 +76,35 @@ export default defineEventHandler(async (event) => {
setResponseStatus(event, 502);
return { error: `plugin "${id}" is not running` };
}
- return sendWebResponse(event, resp);
+ return sendWebResponse(event, sanitize(resp));
});
+
+/** Methods that may carry a request body. Anything else (GET, HEAD, OPTIONS, TRACE) must not be
+ * handed to `readRawBody`. */
+const BODY_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
+
+/**
+ * Fix up a plugin's response before it goes out on the console's origin.
+ *
+ * - `content-encoding` / `content-length` / `transfer-encoding`: `fetch` already decoded the body,
+ * but the plugin's original headers survive on the Response. Re-emitting `content-encoding: gzip`
+ * over plaintext makes the browser fail to decode the page, and a stale `content-length` truncates
+ * it. The framing belongs to OUR response, so drop the plugin's and let it be recomputed.
+ * - `set-cookie`: a plugin runs on the console's own origin, so any cookie it sets is scoped to the
+ * console — it could collide with (or shadow) `pf_session`. A plugin UI has no business setting
+ * cookies on this origin; it authenticates with the injected per-boot bearer.
+ */
+function sanitize(resp: Response): Response {
+ const headers = new Headers(resp.headers);
+ headers.delete("content-encoding");
+ headers.delete("content-length");
+ headers.delete("transfer-encoding");
+ headers.delete("set-cookie");
+ // 204/304 must not carry a body — passing one through throws in the Response constructor.
+ const bodyless = resp.status === 204 || resp.status === 304;
+ return new Response(bodyless ? null : resp.body, {
+ status: resp.status,
+ statusText: resp.statusText,
+ headers,
+ });
+}
diff --git a/web/server/util/auth.ts b/web/server/util/auth.ts
index 3293cb02..d121c66f 100644
--- a/web/server/util/auth.ts
+++ b/web/server/util/auth.ts
@@ -8,18 +8,47 @@ import {
createHash,
timingSafeEqual as nodeTimingSafeEqual,
} from "node:crypto";
-import type { SessionConfig } from "h3";
+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 deployment also sets
- * NODE_TLS_REJECT_UNAUTHORIZED=0 for the (loopback-only) proxy fetch — see .env.example. */
+ * 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";
}
@@ -121,6 +150,34 @@ export function isPublicPath(pathname: string): boolean {
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
diff --git a/web/server/util/confirm.ts b/web/server/util/confirm.ts
new file mode 100644
index 00000000..fb8b2521
--- /dev/null
+++ b/web/server/util/confirm.ts
@@ -0,0 +1,55 @@
+// Password re-confirmation for the routes where an authenticated session is NOT enough.
+//
+// The console's session cookie lives for 7 days, so on its own it must not be able to run new code
+// on the host. Three routes clear that bar and each re-verifies the console password HERE (only the
+// BFF knows it), strips it, and never forwards it:
+//
+// - POST /api/v1/update/apply — update-and-restart the host
+// - POST /api/v1/store/install — but only for a RAW SPEC (`accept_unverified`), which
+// runs an unreviewed package
+// - PUT /api/v1/store/sources/{name} — adds a catalog SOURCE, i.e. a new trust root
+//
+// A catalog install from an already-trusted source is deliberately NOT gated: the operator made
+// that trust decision when they added the source, and re-prompting on every install would train
+// them to type the password without reading. The gate belongs at the trust boundary, not past it.
+//
+// Wrong attempts share the login throttle's per-peer budget, so none of these can be used as a
+// password oracle, and a lockout covers all of them at once.
+import { createError, type H3Event, setResponseHeader } from "h3";
+import { peerAddress, timingSafeEqual, uiPassword } from "./auth";
+import {
+ recordLoginFailure,
+ recordLoginSuccess,
+ throttleRetryAfterMs,
+} from "./loginThrottle";
+
+/**
+ * Verify the re-entered console password, or throw the right HTTP error (503 unconfigured,
+ * 429 throttled, 401 wrong). Returns nothing on success — the caller proceeds.
+ */
+export function confirmPassword(event: H3Event, password: unknown): void {
+ const expected = uiPassword();
+ if (!expected) {
+ throw createError({
+ statusCode: 503,
+ statusMessage: "auth not configured",
+ });
+ }
+ const ip = peerAddress(event);
+ const wait = throttleRetryAfterMs(ip);
+ if (wait > 0) {
+ setResponseHeader(event, "Retry-After", Math.ceil(wait / 1000));
+ throw createError({
+ statusCode: 429,
+ statusMessage: "too many attempts — try again shortly",
+ });
+ }
+ if (!timingSafeEqual(String(password ?? ""), expected)) {
+ recordLoginFailure(ip);
+ throw createError({
+ statusCode: 401,
+ statusMessage: "password confirmation failed",
+ });
+ }
+ recordLoginSuccess(ip);
+}
diff --git a/web/server/util/forward.ts b/web/server/util/forward.ts
new file mode 100644
index 00000000..36803adb
--- /dev/null
+++ b/web/server/util/forward.ts
@@ -0,0 +1,65 @@
+// One-shot forward to the management API, for the handful of routes that need their own handler
+// (a password gate, a rewritten body) instead of the generic `/api/**` passthrough in
+// routes/api/[...].ts. Everything about how we talk upstream is identical to the passthrough:
+// server-side bearer injection, loopback-scoped TLS relaxation, and 401 → 502 so a host-token
+// misconfiguration can't bounce a logged-in user into a redirect loop.
+import {
+ createError,
+ type H3Event,
+ setResponseHeader,
+ setResponseStatus,
+} from "h3";
+import { isLoopbackUrl, mgmtToken, mgmtUrl } from "./auth";
+
+/** Forward a JSON body to `path` on the management API and relay the upstream response verbatim. */
+export async function forwardJson(
+ event: H3Event,
+ path: string,
+ method: string,
+ body: unknown,
+): Promise {
+ const token = mgmtToken();
+ if (!token) {
+ setResponseStatus(event, 503);
+ setResponseHeader(event, "content-type", "application/json");
+ return JSON.stringify({ error: "management token not configured" });
+ }
+ const base = mgmtUrl();
+ const init: RequestInit = {
+ method,
+ headers: {
+ authorization: `Bearer ${token}`,
+ "content-type": "application/json",
+ },
+ body: JSON.stringify(body),
+ };
+ if (isLoopbackUrl(base)) {
+ // Bun.fetch extension — scoped per request, never process-wide (see routes/api/[...].ts).
+ (init as unknown as { tls: { rejectUnauthorized: boolean } }).tls = {
+ rejectUnauthorized: false,
+ };
+ }
+ // A dead/unstarted host makes `fetch` reject. The generic passthrough answers 502 for that, so
+ // these routes must too — an unreachable upstream is not a console bug, and letting the
+ // rejection escape would surface it as a bare 500 "Server Error".
+ let upstream: Response;
+ try {
+ upstream = await fetch(`${base}${path}`, init);
+ } catch (cause) {
+ throw createError({
+ statusCode: 502,
+ statusMessage: "management API unreachable",
+ cause,
+ });
+ }
+ if (upstream.status === 401) {
+ throw createError({
+ statusCode: 502,
+ statusMessage:
+ "management API rejected the host token (check PUNKTFUNK_MGMT_TOKEN)",
+ });
+ }
+ setResponseStatus(event, upstream.status);
+ setResponseHeader(event, "content-type", "application/json");
+ return upstream.text();
+}
diff --git a/web/src/api/store.ts b/web/src/api/store.ts
index 8079d8b1..f3745877 100644
--- a/web/src/api/store.ts
+++ b/web/src/api/store.ts
@@ -123,14 +123,24 @@ export interface JobAccepted {
job: string;
}
-/** Install a curated catalog entry, or — deliberately awkward — a raw package spec. */
+/**
+ * Install a curated catalog entry, or — deliberately awkward — a raw package spec.
+ *
+ * The raw-spec branch carries the console `password`: it runs unreviewed code, so the BFF
+ * re-confirms it (server/routes/api/v1/store/install.post.ts) and strips it before the host ever
+ * sees the request. A catalog install needs no password — that trust decision was made when the
+ * source was added.
+ */
export type InstallBody =
| { source: string; id: string }
- | { spec: string; accept_unverified: true };
+ | { spec: string; accept_unverified: true; password: string };
+/** Adding or repointing a source is a trust-root change, so it carries the console password too
+ * (stripped at the BFF — server/routes/api/v1/store/sources/[name].put.ts). */
export interface SourceBody {
url: string;
public_key?: string;
+ password: string;
}
const BASE = "/api/v1/store";
diff --git a/web/src/sections/Store/InstallDialogs.tsx b/web/src/sections/Store/InstallDialogs.tsx
index 7665f736..a826d457 100644
--- a/web/src/sections/Store/InstallDialogs.tsx
+++ b/web/src/sections/Store/InstallDialogs.tsx
@@ -1,6 +1,6 @@
import { Checkbox } from "@unom/ui/form/checkbox";
import { BadgeCheck, ShieldAlert, ShieldQuestion } from "lucide-react";
-import { type FC, useState } from "react";
+import { type FC, useEffect, useState } from "react";
import type { StoreEntry } from "@/api/store";
import { Button } from "@/components/ui/button";
import {
@@ -95,31 +95,42 @@ export const InstallDialog: FC<{
export const SpecInstallDialog: FC<{
open: boolean;
onCancel: () => void;
- onConfirm: (spec: string) => void;
+ onConfirm: (spec: string, password: string) => void;
isPending: boolean;
-}> = ({ open, onCancel, onConfirm, isPending }) => {
+ /** Set when the BFF rejected the password (401), so the dialog can say so and stay open. */
+ wrongPassword?: boolean;
+}> = ({ open, onCancel, onConfirm, isPending, wrongPassword }) => {
const [spec, setSpec] = useState("");
const [echo, setEcho] = useState("");
const [accepted, setAccepted] = useState(false);
+ const [password, setPassword] = useState("");
- // Both confirmations are cleared on every exit, cancel AND confirm alike: reopening this dialog
- // must never find it pre-armed with the last spec and a ticked box.
- const reset = () => {
+ // Every confirmation is cleared on exit, cancel AND confirm alike: reopening this dialog must
+ // never find it pre-armed with the last spec, a ticked box, or a typed password.
+ //
+ // Clearing hangs off `open` rather than off the two exit paths, because only one of them runs in
+ // this component: cancel goes through `onCancel`, but SUCCESS is the parent flipping `open`, and
+ // the dialog stays mounted either way. Setter identities are stable, so the effect needs no other
+ // dependency — a `reset()` helper in the list would be a new function every render.
+ useEffect(() => {
+ if (open) return;
setSpec("");
setEcho("");
setAccepted(false);
- };
- const close = () => {
- reset();
- onCancel();
- };
+ setPassword("");
+ }, [open]);
const wanted = spec.trim();
- // Both gates must pass: the retyped spec matches exactly, AND the box is ticked.
- const ready = wanted.length > 0 && echo.trim() === wanted && accepted;
+ // Every gate must pass: the retyped spec matches exactly, the box is ticked, and the console
+ // password is re-entered (the BFF verifies it — a session cookie alone must not run new code).
+ const ready =
+ wanted.length > 0 &&
+ echo.trim() === wanted &&
+ accepted &&
+ password.length > 0;
return (
-