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>
33 lines
1.6 KiB
TypeScript
33 lines
1.6 KiB
TypeScript
// 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);
|
|
});
|