feat(web): harden login gate — throttle, scoped TLS, token-derived seal key

Remediates the two web-console residuals from the 2026-07-05 posture audit:

- Brute-force throttle (loginThrottle.ts): per-IP exponential backoff
  after 5 free attempts, plus a global floor for spread-out floods, keyed
  on the socket peer IP (not spoofable X-Forwarded-For) with a size-capped
  map. The constant-time compare already stopped the timing leak; this
  bounds guess *volume* against a by-design LAN-exposed console.
- Session seal key now derives from the high-entropy mgmt token instead of
  the low-entropy login password, so a captured cookie is no longer an
  offline password oracle. Falls back to the password only when no token
  is configured (dev/local). Rotating the token now invalidates sessions.
- Replace the process-wide NODE_TLS_REJECT_UNAUTHORIZED=0 with per-request
  Bun TLS scoped to the loopback proxy hop; a non-loopback mgmt URL now
  verifies normally. Dropped the env var from the systemd unit, Steam Deck
  installer, Windows run scripts, env examples, and web README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-06 07:28:39 +02:00
parent ab56536842
commit bda5556d37
11 changed files with 204 additions and 34 deletions
+48 -11
View File
@@ -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,
+85
View File
@@ -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<string, Entry>();
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);
}