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:
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user