feat(web,docs): the Update-now flow — password re-entry at the BFF, restart-tolerant progress, same-origin hardening
apply.post.ts intercepts the one proxied route that restarts the host: the console password is re-verified per apply (login throttle shared, stripped before forwarding) so a 7-day cookie alone can't do it. New Sec-Fetch-Site same-origin check on every mutating request (login CSRF included). The card's apply flow: confirm dialog → live-session force escalation → download/verify/restart progress rendered from the last snapshot while polls fail (the host and, on Windows, this very server restart mid-flow) → durable success/failure from last_result. Docs: the one-click section + kill switch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
// PUNKTFUNK_UI_PASSWORD is unset, so a misconfigured LAN-exposed server admits no one.
|
||||
import {
|
||||
defineEventHandler,
|
||||
getRequestHeader,
|
||||
getRequestURL,
|
||||
sendRedirect,
|
||||
setResponseStatus,
|
||||
@@ -18,6 +19,24 @@ import {
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { pathname } = getRequestURL(event);
|
||||
|
||||
// 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
|
||||
// from a page; absent (curl, very old browsers) ⇒ allowed — the console's threat here is
|
||||
// a BROWSER being ridden cross-site, and every riding browser sends the header.
|
||||
// `same-site` is rejected too: with an IP-address origin, another port on the same box
|
||||
// counts as same-site, and nothing on another port has business mutating the console.
|
||||
// Applies to public paths as well (login CSRF), before any session logic.
|
||||
const method = event.method?.toUpperCase?.() ?? "GET";
|
||||
if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {
|
||||
const site = getRequestHeader(event, "sec-fetch-site")?.toLowerCase();
|
||||
if (site && site !== "same-origin" && site !== "none") {
|
||||
setResponseStatus(event, 403);
|
||||
return { error: "cross-site request refused" };
|
||||
}
|
||||
}
|
||||
|
||||
if (isPublicPath(pathname)) return;
|
||||
|
||||
// Misconfigured: refuse everything rather than serve open on the LAN.
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// 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.
|
||||
//
|
||||
// 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";
|
||||
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user