Files
punktfunk/web/server/routes/api/v1/update/apply.post.ts
T
enricobuehlerandClaude Fable 5 bb1f93d90e 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>
2026-07-30 15:55:27 +02:00

90 lines
2.8 KiB
TypeScript

// 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();
});