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