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:
@@ -3,8 +3,9 @@
|
|||||||
# Installed by the punktfunk-web .deb to /usr/lib/systemd/user/. AUTO-WIRED — no env editing:
|
# 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)
|
# 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
|
# 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
|
# at the host's loopback HTTPS mgmt API. The self-signed cert is accepted only for that loopback hop,
|
||||||
# proxy's only outbound hop, which is loopback). Enable per user:
|
# 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
|
# systemctl --user enable --now punktfunk-web
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=punktfunk management web console
|
Description=punktfunk management web console
|
||||||
@@ -20,7 +21,6 @@ Type=simple
|
|||||||
EnvironmentFile=%h/.config/punktfunk/mgmt-token
|
EnvironmentFile=%h/.config/punktfunk/mgmt-token
|
||||||
EnvironmentFile=-%h/.config/punktfunk/web-password
|
EnvironmentFile=-%h/.config/punktfunk/web-password
|
||||||
Environment=PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990
|
Environment=PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990
|
||||||
Environment=NODE_TLS_REJECT_UNAUTHORIZED=0
|
|
||||||
Environment=PORT=47992
|
Environment=PORT=47992
|
||||||
Environment=HOST=0.0.0.0
|
Environment=HOST=0.0.0.0
|
||||||
# Serve HTTPS (HTTP/1.1 over TLS) with the host's own identity cert; mark the
|
# Serve HTTPS (HTTP/1.1 over TLS) with the host's own identity cert; mark the
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ Description=punktfunk management web console
|
|||||||
After=punktfunk-host.service
|
After=punktfunk-host.service
|
||||||
|
|
||||||
[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
|
Restart=on-failure
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ rem Fixed deployment wiring (the Windows analogue of scripts/punktfunk-web.servi
|
|||||||
set "PORT=47992"
|
set "PORT=47992"
|
||||||
set "HOST=0.0.0.0"
|
set "HOST=0.0.0.0"
|
||||||
set "PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990"
|
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.
|
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_CERT=%CERTFILE%"
|
||||||
set "PUNKTFUNK_UI_TLS_KEY=%KEYFILE%"
|
set "PUNKTFUNK_UI_TLS_KEY=%KEYFILE%"
|
||||||
|
|||||||
+8
-6
@@ -19,13 +19,15 @@ PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990
|
|||||||
# the proxy gets 401.
|
# the proxy gets 401.
|
||||||
PUNKTFUNK_MGMT_TOKEN=
|
PUNKTFUNK_MGMT_TOKEN=
|
||||||
|
|
||||||
# REQUIRED with the HTTPS mgmt API: the host presents a SELF-SIGNED identity cert on loopback,
|
# NOTE: NODE_TLS_REJECT_UNAUTHORIZED is intentionally NOT set. The host's self-signed loopback cert
|
||||||
# which the proxy's fetch would otherwise reject (→ 502). The web server makes no other outbound
|
# is accepted only for the /api proxy's loopback hop — scoped inside the proxy code (Bun per-request
|
||||||
# TLS calls, so disabling verification here only affects the loopback hop to the host's own cert.
|
# TLS: server/routes/api/[...].ts), so it can never silently unverify some other outbound TLS. A
|
||||||
NODE_TLS_REJECT_UNAUTHORIZED=0
|
# 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
|
# OPTIONAL: explicit cookie-sealing secret (>= 32 chars). If unset, the key is derived from the
|
||||||
# from PUNKTFUNK_UI_PASSWORD (changing the password then invalidates sessions).
|
# 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=
|
# PUNKTFUNK_UI_SECRET=
|
||||||
|
|
||||||
# TLS: serve the console over HTTPS (HTTP/1.1 over TLS) using the HOST's own identity cert (the cert
|
# TLS: serve the console over HTTPS (HTTP/1.1 over TLS) using the HOST's own identity cert (the cert
|
||||||
|
|||||||
+4
-3
@@ -52,13 +52,14 @@ LAN console.)
|
|||||||
bun run build # → .output/ (Nitro `bun` preset + our Bun.serve TLS entry)
|
bun run build # → .output/ (Nitro `bun` preset + our Bun.serve TLS entry)
|
||||||
PORT=47992 HOST=0.0.0.0 \
|
PORT=47992 HOST=0.0.0.0 \
|
||||||
PUNKTFUNK_UI_PASSWORD=… PUNKTFUNK_MGMT_TOKEN=… \
|
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_CERT=~/.config/punktfunk/cert.pem \
|
||||||
PUNKTFUNK_UI_TLS_KEY=~/.config/punktfunk/key.pem PUNKTFUNK_UI_SECURE=1 \
|
PUNKTFUNK_UI_TLS_KEY=~/.config/punktfunk/key.pem PUNKTFUNK_UI_SECURE=1 \
|
||||||
bun run start # = bun run .output/server/index.mjs
|
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).
|
# 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
|
# The host's self-signed mgmt cert is accepted only for the proxy's loopback hop, scoped in code
|
||||||
# mgmt cert; the console makes no other outbound TLS calls. See .env.example.
|
# (Bun per-request TLS: server/routes/api/[...].ts) — no process-wide NODE_TLS_REJECT_UNAUTHORIZED.
|
||||||
|
# See .env.example.
|
||||||
bun run lint # tsc --noEmit
|
bun run lint # tsc --noEmit
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
// POST /_auth/login {password} — verify the shared password (constant-time), then seal an
|
// 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
|
// authenticated session cookie. Public (allowlisted in the gate) so an unauthenticated user
|
||||||
// can actually log in.
|
// can actually log in. Brute force is bounded by an in-memory per-IP throttle (loginThrottle):
|
||||||
import { createError, defineEventHandler, readBody, useSession } from "h3";
|
// the constant-time compare stops a timing leak, the throttle stops guessing at volume.
|
||||||
|
import {
|
||||||
|
createError,
|
||||||
|
defineEventHandler,
|
||||||
|
getRequestIP,
|
||||||
|
readBody,
|
||||||
|
setResponseHeader,
|
||||||
|
useSession,
|
||||||
|
} from "h3";
|
||||||
import {
|
import {
|
||||||
type SessionData,
|
type SessionData,
|
||||||
sessionConfig,
|
sessionConfig,
|
||||||
timingSafeEqual,
|
timingSafeEqual,
|
||||||
uiPassword,
|
uiPassword,
|
||||||
} from "../../util/auth";
|
} from "../../util/auth";
|
||||||
|
import {
|
||||||
|
recordLoginFailure,
|
||||||
|
recordLoginSuccess,
|
||||||
|
throttleRetryAfterMs,
|
||||||
|
} from "../../util/loginThrottle";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const expected = uiPassword();
|
const expected = uiPassword();
|
||||||
@@ -17,11 +30,28 @@ export default defineEventHandler(async (event) => {
|
|||||||
statusMessage: "auth not configured",
|
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 body = await readBody<{ password?: string }>(event);
|
||||||
const password = String(body?.password ?? "");
|
const password = String(body?.password ?? "");
|
||||||
if (!timingSafeEqual(password, expected)) {
|
if (!timingSafeEqual(password, expected)) {
|
||||||
|
recordLoginFailure(ip);
|
||||||
throw createError({ statusCode: 401, statusMessage: "invalid password" });
|
throw createError({ statusCode: 401, statusMessage: "invalid password" });
|
||||||
}
|
}
|
||||||
|
recordLoginSuccess(ip);
|
||||||
const session = await useSession<SessionData>(event, sessionConfig());
|
const session = await useSession<SessionData>(event, sessionConfig());
|
||||||
await session.update({ authenticated: true });
|
await session.update({ authenticated: true });
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import {
|
|||||||
proxyRequest,
|
proxyRequest,
|
||||||
setResponseStatus,
|
setResponseStatus,
|
||||||
} from "h3";
|
} from "h3";
|
||||||
import { mgmtToken, mgmtUrl } from "../../util/auth";
|
import { isLoopbackUrl, mgmtToken, mgmtUrl } from "../../util/auth";
|
||||||
|
|
||||||
export default defineEventHandler((event) => {
|
export default defineEventHandler((event) => {
|
||||||
const { pathname, search } = getRequestURL(event);
|
const { pathname, search } = getRequestURL(event);
|
||||||
const target = `${mgmtUrl()}${pathname}${search}`;
|
const base = mgmtUrl();
|
||||||
|
const target = `${base}${pathname}${search}`;
|
||||||
const token = mgmtToken();
|
const token = mgmtToken();
|
||||||
// The mgmt API now requires a token always. Without one configured, forwarding an empty bearer
|
// 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
|
// 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)",
|
"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, {
|
return proxyRequest(event, target, {
|
||||||
|
fetchOptions,
|
||||||
headers: {
|
headers: {
|
||||||
// Overwrite, not append: the host-held token replaces anything the browser sent.
|
// Overwrite, not append: the host-held token replaces anything the browser sent.
|
||||||
authorization: `Bearer ${token}`,
|
authorization: `Bearer ${token}`,
|
||||||
|
|||||||
+46
-9
@@ -29,22 +29,59 @@ export function mgmtToken(): string {
|
|||||||
return process.env.PUNKTFUNK_MGMT_TOKEN ?? "";
|
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
|
* The cookie-sealing key for h3 `useSession` (must be ≥ 32 chars). Precedence:
|
||||||
* if set; otherwise derive a stable 64-hex key from the password so single-var config works
|
* 1. PUNKTFUNK_UI_SECRET — explicit operator override.
|
||||||
* (changing the password then invalidates existing sessions, which is fine).
|
* 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 {
|
export function sessionConfig(): SessionConfig {
|
||||||
const secret = process.env.PUNKTFUNK_UI_SECRET;
|
const explicit = process.env.PUNKTFUNK_UI_SECRET;
|
||||||
const password =
|
const token = mgmtToken();
|
||||||
secret && secret.length >= 32
|
let secret: string;
|
||||||
? secret
|
if (explicit && explicit.length >= 32) {
|
||||||
: createHash("sha256")
|
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()}`)
|
.update(`punktfunk-session-v1:${uiPassword()}`)
|
||||||
.digest("hex");
|
.digest("hex");
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
name: SESSION_NAME,
|
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
|
// Bounds a stolen/replayed cookie's lifetime (sets the cookie Max-Age AND the iron
|
||||||
// seal TTL). 7 days for a single-user console.
|
// seal TTL). 7 days for a single-user console.
|
||||||
maxAge: 60 * 60 * 24 * 7,
|
maxAge: 60 * 60 * 24 * 7,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
+2
-1
@@ -38,7 +38,8 @@ rem Fixed deployment wiring (the Windows analogue of scripts/punktfunk-web.servi
|
|||||||
set "PORT=47992"
|
set "PORT=47992"
|
||||||
set "HOST=0.0.0.0"
|
set "HOST=0.0.0.0"
|
||||||
set "PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990"
|
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.
|
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_CERT=%CERTFILE%"
|
||||||
set "PUNKTFUNK_UI_TLS_KEY=%KEYFILE%"
|
set "PUNKTFUNK_UI_TLS_KEY=%KEYFILE%"
|
||||||
|
|||||||
+3
-4
@@ -2,7 +2,7 @@
|
|||||||
#
|
#
|
||||||
# On a `apt install punktfunk-web` install you DO NOT edit anything: the systemd --user units wire
|
# On a `apt install punktfunk-web` install you DO NOT edit anything: the systemd --user units wire
|
||||||
# everything automatically —
|
# 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:
|
# 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/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)
|
# ~/.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`
|
# 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
|
# 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
|
# HTTPS with the host's self-signed loopback cert; the proxy accepts it ONLY for that loopback hop,
|
||||||
# (its only outbound TLS hop is that loopback connection).
|
# scoped in code (Bun per-request TLS) — so NODE_TLS_REJECT_UNAUTHORIZED is deliberately unset.
|
||||||
PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990
|
PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990
|
||||||
NODE_TLS_REJECT_UNAUTHORIZED=0
|
|
||||||
PORT=47992
|
PORT=47992
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user