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>
66 lines
2.2 KiB
TypeScript
66 lines
2.2 KiB
TypeScript
// 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();
|
|
}
|