fix(web): one bad password from anywhere stops locking out the whole console

The console's login throttle was documented as per-IP and was not. Nitro's
`localFetch` hands the app a synthetic request whose socket has no
`remoteAddress`, so `getRequestIP()` returned undefined for every request and
every attempt was charged to one shared "unknown" bucket. Five wrong guesses
from any LAN peer locked out everyone — including the operator, and including
the update-apply route, which shares that budget. The Bun entry is the only
place the real peer is knowable, so it now stamps it into a header (deleting
any client-supplied copy first) and `peerAddress()` reads it back.

Verified on a real build bound to 0.0.0.0: seven wrong logins from 127.0.0.1
lock 127.0.0.1 out, a different peer still logs in on the first try, and a
request forging the header is charged to its real address.

Also on the way through:

- Installing an unreviewed package and adding a catalog source now re-ask for
  the console password, like applying an update already did. A 7-day session
  cookie should not be able to run new code on the host, and `store/install`
  with `accept_unverified` did exactly that through the generic passthrough.
  The gate sits at the trust boundary — adding a source, or a raw spec — not
  on every install from a source the operator already chose to trust.
- The ui-credential denylist is matched against the normalised path too, so
  `/api//v1/...` and friends can no longer walk around it.
- The console serves nosniff, a no-referrer policy, and a CSP that pins
  frame-ancestors, object-src and base-uri.
- A plugin UI's response no longer re-emits the content-encoding that `fetch`
  already decoded (which made compressed plugin pages fail to load), no longer
  sets cookies on the console's origin, and OPTIONS reaches the plugin instead
  of being refused 405 by us.
- An unreachable host reads as 502 on these routes, matching the passthrough,
  instead of a bare 500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 00:20:10 +02:00
co-authored by Claude Opus 5
parent 6a4ffcb15c
commit 4575134c21
17 changed files with 544 additions and 162 deletions
+3
View File
@@ -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.",
+3
View File
@@ -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.",
+18 -1
View File
@@ -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,
+18
View File
@@ -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 <base> 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
+4 -4
View File
@@ -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);
+12 -2
View File
@@ -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",
@@ -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<InstallBody>(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);
});
@@ -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<SourceBody>(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,
);
});
+14 -82
View File
@@ -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,
});
});
+37 -5
View File
@@ -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<Response | null> => {
@@ -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,
});
}
+60 -3
View File
@@ -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
+55
View File
@@ -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);
}
+65
View File
@@ -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<string> {
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();
}
+12 -2
View File
@@ -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";
+48 -17
View File
@@ -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 (
<Dialog open={open} onOpenChange={(next) => !next && close()}>
<Dialog open={open} onOpenChange={(next) => !next && onCancel()}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
@@ -170,16 +181,36 @@ export const SpecInstallDialog: FC<{
<span>{m.store_spec_checkbox()}</span>
</Label>
<div className="space-y-2">
<Label htmlFor="store-spec-password">{m.store_spec_password()}</Label>
<Input
id="store-spec-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
{m.store_spec_password_help()}
</p>
{wrongPassword && (
<p role="alert" className="text-xs text-destructive">
{m.update_apply_wrong_password()}
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={close} disabled={isPending}>
<Button variant="outline" onClick={onCancel} disabled={isPending}>
{m.common_cancel()}
</Button>
<Button
variant="destructive"
disabled={!ready || isPending}
onClick={() => {
reset();
onConfirm(wanted);
// Keep the dialog's state until the call settles: a rejected password must
// leave the operator's typed spec in place, not make them start over.
onConfirm(wanted, password);
}}
>
{m.store_spec_confirm()}
+87 -41
View File
@@ -7,7 +7,7 @@ import {
ShieldOff,
Trash2,
} from "lucide-react";
import { type FC, type FormEvent, useState } from "react";
import { type FC, type FormEvent, useEffect, useState } from "react";
import { ApiError } from "@/api/fetcher";
import {
type SourceBody,
@@ -33,8 +33,9 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { m } from "@/paraglide/messages";
/** A source the operator has filled in but not yet agreed to trust. */
type SourceDraft = SourceBody & { name: string };
/** A source the operator has filled in but not yet agreed to trust. The console password is NOT
* part of the draft — it is collected by the trust dialog, at the moment the decision is made. */
type SourceDraft = Omit<SourceBody, "password"> & { name: string };
/** Unix seconds → a locale date-time, or "never" for a source that has never fetched. */
const fmtFetched = (secs: number): string =>
@@ -53,19 +54,27 @@ export const SourcesTab: FC = () => {
// The draft waiting on the trust dialog, and a key that re-mounts (and so clears) the form.
const [draft, setDraft] = useState<SourceDraft | null>(null);
const [formKey, setFormKey] = useState(0);
const [wrongPassword, setWrongPassword] = useState(false);
const onRefresh = () =>
refresh.mutate(undefined, {
onError: () => toast.error(m.store_refresh_failed()),
});
const onConfirmAdd = async () => {
const onConfirmAdd = async (password: string) => {
if (!draft) return;
setWrongPassword(false);
try {
await save.mutateAsync(draft);
await save.mutateAsync({ ...draft, password });
setDraft(null);
setFormKey((k) => k + 1);
} catch {
} catch (e) {
// A rejected password keeps the dialog open so the operator can retry without refilling
// the form; anything else is a genuine failure to write the source.
if (e instanceof ApiError && e.status === 401) {
setWrongPassword(true);
return;
}
toast.error(m.store_add_source_failed());
}
};
@@ -103,7 +112,11 @@ export const SourcesTab: FC = () => {
<TrustSourceDialog
draft={draft}
isSaving={save.isPending}
onCancel={() => setDraft(null)}
wrongPassword={wrongPassword}
onCancel={() => {
setDraft(null);
setWrongPassword(false);
}}
onConfirm={onConfirmAdd}
/>
</div>
@@ -308,40 +321,73 @@ export const TrustSourceDialog: FC<{
draft: SourceDraft | null;
isSaving: boolean;
onCancel: () => void;
onConfirm: () => void;
}> = ({ draft, isSaving, onCancel, onConfirm }) => (
<Dialog open={draft !== null} onOpenChange={(open) => !open && onCancel()}>
{draft && (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="size-5 shrink-0 text-amber-600 dark:text-amber-500" />
{m.store_source_trust_title()}
</DialogTitle>
<DialogDescription>
{m.store_source_trust_body({ name: draft.name })}
</DialogDescription>
</DialogHeader>
onConfirm: (password: string) => void;
/** Set when the BFF rejected the password (401) — say so and keep the dialog open. */
wrongPassword?: boolean;
}> = ({ draft, isSaving, onCancel, onConfirm, wrongPassword }) => {
const [password, setPassword] = useState("");
// The dialog stays mounted between drafts; clear the password whenever it closes.
useEffect(() => {
if (!draft) setPassword("");
}, [draft]);
return (
<Dialog open={draft !== null} onOpenChange={(open) => !open && onCancel()}>
{draft && (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="size-5 shrink-0 text-amber-600 dark:text-amber-500" />
{m.store_source_trust_title()}
</DialogTitle>
<DialogDescription>
{m.store_source_trust_body({ name: draft.name })}
</DialogDescription>
</DialogHeader>
<p className="rounded-md bg-muted px-3 py-2 font-mono text-xs break-all text-muted-foreground">
{draft.url}
</p>
{!draft.public_key && (
<p className="rounded-md border border-amber-600/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-600 dark:border-amber-500/40 dark:text-amber-500">
{m.store_source_trust_unsigned()}
<p className="rounded-md bg-muted px-3 py-2 font-mono text-xs break-all text-muted-foreground">
{draft.url}
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={isSaving}>
{m.common_cancel()}
</Button>
<Button disabled={isSaving} onClick={onConfirm}>
{m.store_source_trust_confirm()}
</Button>
</DialogFooter>
</DialogContent>
)}
</Dialog>
);
{!draft.public_key && (
<p className="rounded-md border border-amber-600/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-600 dark:border-amber-500/40 dark:text-amber-500">
{m.store_source_trust_unsigned()}
</p>
)}
{/* Adding a source is a trust-root change: every future install rides on it, so the
console password is re-entered here and verified at the BFF, exactly as for a
host update. */}
<div className="space-y-2">
<Label htmlFor="store-source-password">
{m.store_source_password()}
</Label>
<Input
id="store-source-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{wrongPassword && (
<p role="alert" className="text-xs text-destructive">
{m.update_apply_wrong_password()}
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={isSaving}>
{m.common_cancel()}
</Button>
<Button
disabled={isSaving || password.length === 0}
onClick={() => onConfirm(password)}
>
{m.store_source_trust_confirm()}
</Button>
</DialogFooter>
</DialogContent>
)}
</Dialog>
);
};
+43 -5
View File
@@ -33,6 +33,7 @@ export const SectionStore: FC = () => {
// The catalog entry awaiting its install confirmation, and the raw-spec dialog's open state.
const [target, setTarget] = useState<StoreEntry | null>(null);
const [specOpen, setSpecOpen] = useState(false);
const [specWrongPassword, setSpecWrongPassword] = useState(false);
// The job the host is running for us, if any. Cleared by the operator, not by completion — a
// finished job's log is the only record of what happened.
const [jobId, setJobId] = useState<string | null>(null);
@@ -61,15 +62,48 @@ export const SectionStore: FC = () => {
await start({ source: entry.source, id: entry.id });
};
const onConfirmSpec = async (spec: string) => {
setSpecOpen(false);
await start({ spec, accept_unverified: true });
const onConfirmSpec = async (spec: string, password: string) => {
setSpecWrongPassword(false);
try {
const { job } = await install.mutateAsync({
spec,
accept_unverified: true,
password,
});
setSpecOpen(false);
setJobId(job);
} catch (e) {
// A rejected password keeps the dialog open with everything the operator typed still in
// it; anything else is an ordinary install failure.
if (e instanceof ApiError && e.status === 401) {
setSpecWrongPassword(true);
return;
}
setSpecOpen(false);
failed(e, m.store_install_failed());
}
};
// An update from the Installed tab installs the CATALOG version — so it goes through the very
// same tier-appropriate dialog a fresh install would, warning included.
//
// Resolve by the entry the plugin was actually installed FROM (source + entry id) before falling
// back to the package name: two sources may carry the same `pkg`, and matching on the name alone
// could offer a row badged "verified" an entry from somebody else's source at a different version.
const onUpdate = (plugin: InstalledPlugin) => {
const entry = catalog.data?.plugins.find((e) => e.pkg === plugin.pkg);
const entries = catalog.data?.plugins ?? [];
const entry =
(plugin.source && plugin.entry_id
? entries.find(
(e) => e.source === plugin.source && e.id === plugin.entry_id,
)
: undefined) ??
(plugin.source
? entries.find(
(e) => e.source === plugin.source && e.pkg === plugin.pkg,
)
: undefined) ??
entries.find((e) => e.pkg === plugin.pkg);
if (!entry) {
toast.error(m.store_update_no_entry());
return;
@@ -140,7 +174,11 @@ export const SectionStore: FC = () => {
<SpecInstallDialog
open={specOpen}
isPending={install.isPending}
onCancel={() => setSpecOpen(false)}
wrongPassword={specWrongPassword}
onCancel={() => {
setSpecOpen(false);
setSpecWrongPassword(false);
}}
onConfirm={onConfirmSpec}
/>
</div>