diff --git a/scripts/punktfunk-web.service b/scripts/punktfunk-web.service index 1210db7..cf38283 100644 --- a/scripts/punktfunk-web.service +++ b/scripts/punktfunk-web.service @@ -3,8 +3,9 @@ # Installed by the punktfunk-web .deb to /usr/lib/systemd/user/. AUTO-WIRED — no env editing: # it sources the host's mgmt token + the generated login password, serves HTTPS (HTTP/1.1 over TLS) # with the host's own identity cert (~/.config/punktfunk/{cert,key}.pem), and points the /api proxy -# at the host's loopback HTTPS mgmt API (self-signed cert → NODE_TLS_REJECT_UNAUTHORIZED for the -# proxy's only outbound hop, which is loopback). Enable per user: +# at the host's loopback HTTPS mgmt API. The self-signed cert is accepted only for that loopback hop, +# scoped inside the proxy code (Bun per-request TLS) — no process-wide NODE_TLS_REJECT_UNAUTHORIZED. +# Enable per user: # systemctl --user enable --now punktfunk-web [Unit] Description=punktfunk management web console @@ -20,7 +21,6 @@ Type=simple EnvironmentFile=%h/.config/punktfunk/mgmt-token EnvironmentFile=-%h/.config/punktfunk/web-password Environment=PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990 -Environment=NODE_TLS_REJECT_UNAUTHORIZED=0 Environment=PORT=47992 Environment=HOST=0.0.0.0 # Serve HTTPS (HTTP/1.1 over TLS) with the host's own identity cert; mark the diff --git a/scripts/steamdeck/install.sh b/scripts/steamdeck/install.sh index a59eeeb..fafa031 100755 --- a/scripts/steamdeck/install.sh +++ b/scripts/steamdeck/install.sh @@ -208,7 +208,7 @@ Description=punktfunk management web console After=punktfunk-host.service [Service] -ExecStart=$DISTROBOX enter $BOX -- bash -lc 'cd $SRC/web; set -a; . $CONFIG/mgmt-token; . $CONFIG/web.env; set +a; export PUNKTFUNK_MGMT_URL=https://127.0.0.1:$MGMT_PORT NODE_TLS_REJECT_UNAUTHORIZED=0 PORT=$WEB_PORT HOST=0.0.0.0 NITRO_PORT=$WEB_PORT NITRO_HOST=0.0.0.0 PUNKTFUNK_UI_TLS_CERT=$CONFIG/cert.pem PUNKTFUNK_UI_TLS_KEY=$CONFIG/key.pem PUNKTFUNK_UI_SECURE=1; exec bun .output/server/index.mjs' +ExecStart=$DISTROBOX enter $BOX -- bash -lc 'cd $SRC/web; set -a; . $CONFIG/mgmt-token; . $CONFIG/web.env; set +a; export PUNKTFUNK_MGMT_URL=https://127.0.0.1:$MGMT_PORT PORT=$WEB_PORT HOST=0.0.0.0 NITRO_PORT=$WEB_PORT NITRO_HOST=0.0.0.0 PUNKTFUNK_UI_TLS_CERT=$CONFIG/cert.pem PUNKTFUNK_UI_TLS_KEY=$CONFIG/key.pem PUNKTFUNK_UI_SECURE=1; exec bun .output/server/index.mjs' Restart=on-failure RestartSec=3 diff --git a/scripts/windows/web-run.cmd b/scripts/windows/web-run.cmd index 693378a..aea1691 100644 --- a/scripts/windows/web-run.cmd +++ b/scripts/windows/web-run.cmd @@ -37,7 +37,8 @@ rem Fixed deployment wiring (the Windows analogue of scripts/punktfunk-web.servi set "PORT=47992" set "HOST=0.0.0.0" set "PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990" -set "NODE_TLS_REJECT_UNAUTHORIZED=0" +rem No NODE_TLS_REJECT_UNAUTHORIZED: the host's self-signed cert is accepted only for the loopback +rem proxy hop, scoped inside the proxy code (Bun per-request TLS), not process-wide. rem Serve HTTPS (HTTP/1.1 over TLS) with the host's identity cert; mark the session cookie Secure. set "PUNKTFUNK_UI_TLS_CERT=%CERTFILE%" set "PUNKTFUNK_UI_TLS_KEY=%KEYFILE%" diff --git a/web/.env.example b/web/.env.example index 768ecf4..3eece80 100644 --- a/web/.env.example +++ b/web/.env.example @@ -19,13 +19,15 @@ PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990 # the proxy gets 401. PUNKTFUNK_MGMT_TOKEN= -# REQUIRED with the HTTPS mgmt API: the host presents a SELF-SIGNED identity cert on loopback, -# which the proxy's fetch would otherwise reject (→ 502). The web server makes no other outbound -# TLS calls, so disabling verification here only affects the loopback hop to the host's own cert. -NODE_TLS_REJECT_UNAUTHORIZED=0 +# NOTE: NODE_TLS_REJECT_UNAUTHORIZED is intentionally NOT set. The host's self-signed loopback cert +# is accepted only for the /api proxy's loopback hop — scoped inside the proxy code (Bun per-request +# TLS: server/routes/api/[...].ts), so it can never silently unverify some other outbound TLS. A +# NON-loopback PUNKTFUNK_MGMT_URL is verified normally (present a valid chain). -# OPTIONAL: explicit cookie-sealing secret (>= 32 chars). If unset, a stable key is derived -# from PUNKTFUNK_UI_PASSWORD (changing the password then invalidates sessions). +# OPTIONAL: explicit cookie-sealing secret (>= 32 chars). If unset, the key is derived from the +# high-entropy PUNKTFUNK_MGMT_TOKEN (so a captured cookie is NOT an offline password oracle); only if +# no token is configured (dev/local) does it fall back to deriving from PUNKTFUNK_UI_PASSWORD. +# Rotating the mgmt token invalidates existing sessions. # PUNKTFUNK_UI_SECRET= # TLS: serve the console over HTTPS (HTTP/1.1 over TLS) using the HOST's own identity cert (the cert diff --git a/web/README.md b/web/README.md index d120e54..ecb6ee1 100644 --- a/web/README.md +++ b/web/README.md @@ -52,13 +52,14 @@ LAN console.) bun run build # → .output/ (Nitro `bun` preset + our Bun.serve TLS entry) PORT=47992 HOST=0.0.0.0 \ PUNKTFUNK_UI_PASSWORD=… PUNKTFUNK_MGMT_TOKEN=… \ - PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990 NODE_TLS_REJECT_UNAUTHORIZED=0 \ + PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990 \ PUNKTFUNK_UI_TLS_CERT=~/.config/punktfunk/cert.pem \ PUNKTFUNK_UI_TLS_KEY=~/.config/punktfunk/key.pem PUNKTFUNK_UI_SECURE=1 \ bun run start # = bun run .output/server/index.mjs # PUNKTFUNK_UI_TLS_* unset ⇒ plain HTTP (local dev); both set ⇒ HTTPS (HTTP/1.1 over TLS). -# NODE_TLS_REJECT_UNAUTHORIZED=0 is only for the proxy's loopback fetch to the host's self-signed -# mgmt cert; the console makes no other outbound TLS calls. See .env.example. +# The host's self-signed mgmt cert is accepted only for the proxy's loopback hop, scoped in code +# (Bun per-request TLS: server/routes/api/[...].ts) — no process-wide NODE_TLS_REJECT_UNAUTHORIZED. +# See .env.example. bun run lint # tsc --noEmit ``` diff --git a/web/server/routes/_auth/login.post.ts b/web/server/routes/_auth/login.post.ts index bf7e3e6..42ddd65 100644 --- a/web/server/routes/_auth/login.post.ts +++ b/web/server/routes/_auth/login.post.ts @@ -1,13 +1,26 @@ // POST /_auth/login {password} — verify the shared password (constant-time), then seal an // authenticated session cookie. Public (allowlisted in the gate) so an unauthenticated user -// can actually log in. -import { createError, defineEventHandler, readBody, useSession } from "h3"; +// can actually log in. Brute force is bounded by an in-memory per-IP throttle (loginThrottle): +// the constant-time compare stops a timing leak, the throttle stops guessing at volume. +import { + createError, + defineEventHandler, + getRequestIP, + readBody, + setResponseHeader, + useSession, +} from "h3"; import { type SessionData, sessionConfig, timingSafeEqual, uiPassword, } from "../../util/auth"; +import { + recordLoginFailure, + recordLoginSuccess, + throttleRetryAfterMs, +} from "../../util/loginThrottle"; export default defineEventHandler(async (event) => { const expected = uiPassword(); @@ -17,11 +30,28 @@ export default defineEventHandler(async (event) => { statusMessage: "auth not configured", }); } + // The socket peer address — deliberately NOT trusting X-Forwarded-For (spoofable unless we sit + // behind a known proxy, which the packaged console does not). Falls back to a single shared bucket + // if the address is somehow unavailable, so the throttle still applies. + const ip = getRequestIP(event) ?? "unknown"; + + // Throttle BEFORE touching the password so a locked-out client can't keep the guess loop spinning. + 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 }>(event); const password = String(body?.password ?? ""); if (!timingSafeEqual(password, expected)) { + recordLoginFailure(ip); throw createError({ statusCode: 401, statusMessage: "invalid password" }); } + recordLoginSuccess(ip); const session = await useSession(event, sessionConfig()); await session.update({ authenticated: true }); return { ok: true }; diff --git a/web/server/routes/api/[...].ts b/web/server/routes/api/[...].ts index 0f4c7f4..2b716f1 100644 --- a/web/server/routes/api/[...].ts +++ b/web/server/routes/api/[...].ts @@ -9,11 +9,12 @@ import { proxyRequest, setResponseStatus, } from "h3"; -import { mgmtToken, mgmtUrl } from "../../util/auth"; +import { isLoopbackUrl, mgmtToken, mgmtUrl } from "../../util/auth"; export default defineEventHandler((event) => { const { pathname, search } = getRequestURL(event); - const target = `${mgmtUrl()}${pathname}${search}`; + const base = mgmtUrl(); + const target = `${base}${pathname}${search}`; const token = mgmtToken(); // The mgmt API now requires a token always. Without one configured, forwarding an empty bearer // would just bounce as 401 — fail fast and legibly instead (the packaged service sources the @@ -25,7 +26,20 @@ export default defineEventHandler((event) => { "management token not configured (PUNKTFUNK_MGMT_TOKEN / ~/.config/punktfunk/mgmt-token)", }; } + // TLS scoping (replaces the old process-wide NODE_TLS_REJECT_UNAUTHORIZED=0): the host presents a + // SELF-SIGNED, no-SAN identity cert on loopback, which normal verification rejects. We relax + // verification ONLY for this one loopback hop, via Bun's per-request `tls` option — so any OTHER + // outbound TLS the process ever makes still verifies normally (the global env unverified + // everything). If the operator points PUNKTFUNK_MGMT_URL at a NON-loopback host, we do NOT relax: + // a remote mgmt API must present a valid chain, which is stricter than the old blanket accept. + const fetchOptions = isLoopbackUrl(base) + ? // `tls` is a Bun.fetch extension (the console runs on bun — Bun.serve/`bun .output/...`), not + // in the standard RequestInit type, so cast through unknown. + ({ tls: { rejectUnauthorized: false } } as unknown as RequestInit) + : undefined; + return proxyRequest(event, target, { + fetchOptions, headers: { // Overwrite, not append: the host-held token replaces anything the browser sent. authorization: `Bearer ${token}`, diff --git a/web/server/util/auth.ts b/web/server/util/auth.ts index 599a96c..ca3e86a 100644 --- a/web/server/util/auth.ts +++ b/web/server/util/auth.ts @@ -29,22 +29,59 @@ export function mgmtToken(): string { return process.env.PUNKTFUNK_MGMT_TOKEN ?? ""; } +/** Whether `url`'s host is a loopback address — the only place the proxy relaxes TLS verification + * for the host's self-signed cert. IPv4 127.0.0.0/8, IPv6 ::1, and the `localhost` name. */ +export function isLoopbackUrl(url: string): boolean { + let host: string; + try { + host = new URL(url).hostname; + } catch { + return false; + } + // URL wraps IPv6 in brackets in .host but strips them in .hostname; normalize anyway. + const h = host.replace(/^\[|\]$/g, "").toLowerCase(); + if (h === "localhost" || h === "::1") return true; + return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h); +} + /** - * The cookie-sealing key for h3 `useSession` (must be ≥ 32 chars). Use PUNKTFUNK_UI_SECRET - * if set; otherwise derive a stable 64-hex key from the password so single-var config works - * (changing the password then invalidates existing sessions, which is fine). + * The cookie-sealing key for h3 `useSession` (must be ≥ 32 chars). Precedence: + * 1. PUNKTFUNK_UI_SECRET — explicit operator override. + * 2. Derived from the MANAGEMENT TOKEN (a 32-byte / 64-hex CSPRNG value) — the packaged deployment + * always has one, so the seal key is high-entropy without any extra config. + * 3. Only as a last resort (dev/local with no token) derive from the password. + * + * Why not (2)→password by default: the password is low-entropy (a human picks it), so a key DERIVED + * from it turns any captured session cookie into an OFFLINE dictionary oracle — an attacker unseals + * candidate cookies locally, no server round-trips, so the login throttle can't help. The mgmt token + * is unguessable, so a cookie sealed under it leaks nothing about the password. (Deriving from the + * token instead of the password also means changing the password no longer silently invalidates + * sessions; rotating the mgmt token does — the correct, security-relevant trigger.) */ export function sessionConfig(): SessionConfig { - const secret = process.env.PUNKTFUNK_UI_SECRET; - const password = - secret && secret.length >= 32 - ? secret - : createHash("sha256") - .update(`punktfunk-session-v1:${uiPassword()}`) - .digest("hex"); + const explicit = process.env.PUNKTFUNK_UI_SECRET; + const token = mgmtToken(); + let secret: string; + if (explicit && explicit.length >= 32) { + secret = explicit; + } else if (token) { + // High-entropy source: the CSPRNG mgmt token. Hash it (never use the raw admin token as the + // seal key) with a distinct label so the two uses can't be conflated. + secret = createHash("sha256") + .update(`punktfunk-session-v1:token:${token}`) + .digest("hex"); + } else { + // Last resort (no token configured — dev/local only). No worse than before; a real deployment + // always has a token and never reaches here. + secret = createHash("sha256") + .update(`punktfunk-session-v1:${uiPassword()}`) + .digest("hex"); + } return { name: SESSION_NAME, - password, + // h3's `useSession` calls this seal key `password` (it's the iron/AES-GCM key, not the login + // password — see the derivation above). + password: secret, // Bounds a stolen/replayed cookie's lifetime (sets the cookie Max-Age AND the iron // seal TTL). 7 days for a single-user console. maxAge: 60 * 60 * 24 * 7, diff --git a/web/server/util/loginThrottle.ts b/web/server/util/loginThrottle.ts new file mode 100644 index 0000000..0fc6a4e --- /dev/null +++ b/web/server/util/loginThrottle.ts @@ -0,0 +1,85 @@ +// In-memory brute-force throttle for the login gate. The password compare is constant-time (no +// timing leak), but nothing bounded the *volume* of guesses — a LAN peer could hammer +// PUNKTFUNK_UI_PASSWORD at network speed against a console that is, by design, LAN-exposed. This is +// the console analogue of the host's PAIRING_COOLDOWN / single-use pairing PIN: every other secret +// gate in the project is rate-limited, so this one is too. +// +// State is a module-level Map — it lives for the lifetime of the single Bun/Nitro server process, +// which is exactly the scope we want (a restart clearing it is fine; lockouts are short). Keyed on +// the socket peer IP (NOT a spoofable X-Forwarded-For — see the caller). The map is size-capped so a +// distinct-IP flood can't grow it without bound, and a global counter adds a mild floor so a +// botnet-style spread across many IPs still can't get unlimited aggregate guesses. + +/** Backoff schedule. After `FREE_ATTEMPTS` failures from an IP, each further failure locks that IP + * for `BASE_LOCKOUT_MS * 2^(n)` up to `MAX_LOCKOUT_MS`. Generous enough never to bother a human who + * fat-fingered their password a couple of times, punishing enough to make brute force hopeless. */ +const FREE_ATTEMPTS = 5; +const BASE_LOCKOUT_MS = 1_000; +const MAX_LOCKOUT_MS = 5 * 60_000; // 5 minutes +/** Forget an IP that's been quiet this long (also the idle-eviction horizon). */ +const ENTRY_TTL_MS = 15 * 60_000; +/** Cap the tracking map so a distinct-IP flood can't grow memory without bound. */ +const MAX_TRACKED_IPS = 4_096; +/** Global floor: once this many failures accumulate inside GLOBAL_WINDOW_MS across ALL IPs, every + * login attempt waits out a small fixed delay too, so a spread-out flood is still bounded. */ +const GLOBAL_FAIL_THRESHOLD = 100; +const GLOBAL_WINDOW_MS = 60_000; +const GLOBAL_LOCK_MS = 1_000; + +interface Entry { + fails: number; + /** Epoch ms before which this IP may not attempt again. */ + lockedUntil: number; + /** Last activity (for TTL eviction). */ + seen: number; +} + +const byIp = new Map(); +let globalFails: number[] = []; // epoch-ms timestamps of recent failures (any IP) + +function sweep(now: number): void { + if (byIp.size <= MAX_TRACKED_IPS) { + // Cheap path: only drop clearly-stale entries when we're not under memory pressure. + for (const [ip, e] of byIp) { + if (now - e.seen > ENTRY_TTL_MS) byIp.delete(ip); + } + return; + } + // Over the cap: evict oldest-seen first until back under it. + const oldest = [...byIp.entries()].sort((a, b) => a[1].seen - b[1].seen); + for (const [ip, e] of oldest) { + if (byIp.size <= MAX_TRACKED_IPS && now - e.seen <= ENTRY_TTL_MS) break; + byIp.delete(ip); + } +} + +/** Call BEFORE checking the password. If the IP (or the global floor) is currently locked out, + * returns the number of ms the caller should tell the client to wait; otherwise 0 (proceed). */ +export function throttleRetryAfterMs(ip: string, now = Date.now()): number { + const e = byIp.get(ip); + if (e && now < e.lockedUntil) return e.lockedUntil - now; + globalFails = globalFails.filter((t) => now - t < GLOBAL_WINDOW_MS); + if (globalFails.length >= GLOBAL_FAIL_THRESHOLD) return GLOBAL_LOCK_MS; + return 0; +} + +/** Record a failed attempt and arm/extend this IP's lockout with exponential backoff. */ +export function recordLoginFailure(ip: string, now = Date.now()): void { + sweep(now); + const e = byIp.get(ip) ?? { fails: 0, lockedUntil: 0, seen: now }; + e.fails += 1; + e.seen = now; + const over = e.fails - FREE_ATTEMPTS; + if (over > 0) { + const lock = Math.min(MAX_LOCKOUT_MS, BASE_LOCKOUT_MS * 2 ** (over - 1)); + e.lockedUntil = now + lock; + } + byIp.set(ip, e); + globalFails.push(now); +} + +/** Record a successful login — clears the IP's failure state so a legitimate user isn't penalized + * for earlier typos. */ +export function recordLoginSuccess(ip: string): void { + byIp.delete(ip); +} diff --git a/web/web-run.cmd b/web/web-run.cmd index 79c38a6..410e721 100644 --- a/web/web-run.cmd +++ b/web/web-run.cmd @@ -38,7 +38,8 @@ rem Fixed deployment wiring (the Windows analogue of scripts/punktfunk-web.servi set "PORT=47992" set "HOST=0.0.0.0" set "PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990" -set "NODE_TLS_REJECT_UNAUTHORIZED=0" +rem No NODE_TLS_REJECT_UNAUTHORIZED: the host's self-signed cert is accepted only for the loopback +rem proxy hop, scoped inside the proxy code (Bun per-request TLS), not process-wide. rem Serve HTTPS (HTTP/1.1 over TLS) with the host's identity cert; mark the session cookie Secure. set "PUNKTFUNK_UI_TLS_CERT=%CERTFILE%" set "PUNKTFUNK_UI_TLS_KEY=%KEYFILE%" diff --git a/web/web.env.example b/web/web.env.example index 7104e61..d81b7f4 100644 --- a/web/web.env.example +++ b/web/web.env.example @@ -2,7 +2,7 @@ # # On a `apt install punktfunk-web` install you DO NOT edit anything: the systemd --user units wire # everything automatically — -# punktfunk-web.service sets PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990, NODE_TLS_REJECT_UNAUTHORIZED=0, +# punktfunk-web.service sets PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990, # PORT=47992, HOST=0.0.0.0, the PUNKTFUNK_UI_TLS_* cert paths + PUNKTFUNK_UI_SECURE=1, and sources: # ~/.config/punktfunk/mgmt-token (written by the host's `serve` — the shared bearer token) # ~/.config/punktfunk/web-password (written by punktfunk-web-init — the console login password) @@ -10,10 +10,9 @@ # # This file documents the variables for a MANUAL deploy (running `bun .output/server/index.mjs` # yourself — the console runs on bun: `Bun.serve` is a Bun API, node can't run it). The mgmt API is -# HTTPS with the host's self-signed loopback cert, so the proxy needs NODE_TLS_REJECT_UNAUTHORIZED=0 -# (its only outbound TLS hop is that loopback connection). +# HTTPS with the host's self-signed loopback cert; the proxy accepts it ONLY for that loopback hop, +# scoped in code (Bun per-request TLS) — so NODE_TLS_REJECT_UNAUTHORIZED is deliberately unset. PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990 -NODE_TLS_REJECT_UNAUTHORIZED=0 PORT=47992 HOST=0.0.0.0