fix(web): one bad password from anywhere stops locking out the whole console
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>
This commit is contained in:
@@ -28,6 +28,18 @@ const ws = import.meta._websocket
|
||||
? wsAdapter(nitroApp.h3App.websocket)
|
||||
: undefined;
|
||||
|
||||
// The socket peer, handed to the app as a trusted header.
|
||||
//
|
||||
// Nitro's `localFetch` (below) hands the app a SYNTHETIC request whose socket has no
|
||||
// `remoteAddress`, so h3's `getRequestIP()` returns undefined *inside* the app and every
|
||||
// per-peer decision collapses onto one shared bucket. That silently defeated the login
|
||||
// throttle: five wrong passwords from anywhere locked out everyone, including the operator
|
||||
// (and, since the update-apply route shares that budget, locked out host updates too).
|
||||
// `server.requestIP(req)` is the only place the real peer is knowable, so we stamp it here.
|
||||
// Any inbound copy is deleted first, so a client cannot forge it.
|
||||
// Read back by `peerAddress()` in server/util/auth.ts — keep the two names in sync.
|
||||
const PEER_IP_HEADER = "x-pf-peer-ip";
|
||||
|
||||
// TLS from the host's identity cert (file PATHS → Bun.file, not PEM-in-env). Absent ⇒ plain HTTP.
|
||||
const certPath = process.env.PUNKTFUNK_UI_TLS_CERT;
|
||||
const keyPath = process.env.PUNKTFUNK_UI_TLS_KEY;
|
||||
@@ -53,10 +65,15 @@ const server = Bun.serve({
|
||||
if (req.body) {
|
||||
body = await req.arrayBuffer();
|
||||
}
|
||||
// Strip any client-supplied value BEFORE stamping the real one (see PEER_IP_HEADER).
|
||||
const headers = new Headers(req.headers);
|
||||
headers.delete(PEER_IP_HEADER);
|
||||
const peer = server.requestIP(req)?.address;
|
||||
if (peer) headers.set(PEER_IP_HEADER, peer);
|
||||
return nitroApp.localFetch(url.pathname + url.search, {
|
||||
host: url.hostname,
|
||||
protocol: url.protocol,
|
||||
headers: req.headers,
|
||||
headers,
|
||||
method: req.method,
|
||||
redirect: req.redirect,
|
||||
body,
|
||||
|
||||
Reference in New Issue
Block a user