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 ( - !next && close()}> + !next && onCancel()}> @@ -170,16 +181,36 @@ export const SpecInstallDialog: FC<{ {m.store_spec_checkbox()} +
+ + setPassword(e.target.value)} + /> +

+ {m.store_spec_password_help()} +

+ {wrongPassword && ( +

+ {m.update_apply_wrong_password()} +

+ )} +
+ - - - -
- )} -
-); + {!draft.public_key && ( +

+ {m.store_source_trust_unsigned()} +

+ )} + + {/* 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. */} +
+ + setPassword(e.target.value)} + /> + {wrongPassword && ( +

+ {m.update_apply_wrong_password()} +

+ )} +
+ + + + + + + )} +
+ ); +}; diff --git a/web/src/sections/Store/index.tsx b/web/src/sections/Store/index.tsx index b3f2985c..532e8a81 100644 --- a/web/src/sections/Store/index.tsx +++ b/web/src/sections/Store/index.tsx @@ -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(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(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 = () => { setSpecOpen(false)} + wrongPassword={specWrongPassword} + onCancel={() => { + setSpecOpen(false); + setSpecWrongPassword(false); + }} onConfirm={onConfirmSpec} />