feat(server): rate limit (login + pair) + CSP/security headers

This commit is contained in:
Mitchell R 2026-05-14 07:40:22 +02:00
parent 3ec2f3bf85
commit a6c1fb4d8d
4 changed files with 120 additions and 6 deletions

View file

@ -5,17 +5,35 @@
* a string/object directly. This helper wraps JSX output in a * a string/object directly. This helper wraps JSX output in a
* proper Response with text/html content type. * proper Response with text/html content type.
*/ */
/**
* Baseline security headers. CSP keeps 'unsafe-inline' for scripts because
* jsx-htmx's js() helper emits inline <script> blocks and htmx uses inline
* event handler attributes; tightening this needs per-render nonces.
*/
const SECURITY_HEADERS = {
"content-type": "text/html; charset=utf-8",
"content-security-policy":
"default-src 'self'; " +
"img-src 'self' data: blob:; " +
"script-src 'self' 'unsafe-inline'; " +
"style-src 'self' 'unsafe-inline'; " +
"frame-src 'self'; " +
"frame-ancestors 'none'; " +
"base-uri 'self'; " +
"form-action 'self'",
"x-frame-options": "DENY",
"x-content-type-options": "nosniff",
"referrer-policy": "strict-origin-when-cross-origin",
"strict-transport-security": "max-age=31536000; includeSubDomains",
} as const;
export function htmlPage(markup: unknown): Response { export function htmlPage(markup: unknown): Response {
return new Response(String(markup), { return new Response(String(markup), { headers: SECURITY_HEADERS });
headers: { "content-type": "text/html; charset=utf-8" },
});
} }
/** Same as htmlPage — separate name for htmx fragment swaps to read clearly. */ /** Same as htmlPage — separate name for htmx fragment swaps to read clearly. */
export function htmlFragment(markup: unknown): Response { export function htmlFragment(markup: unknown): Response {
return new Response(String(markup), { return new Response(String(markup), { headers: SECURITY_HEADERS });
headers: { "content-type": "text/html; charset=utf-8" },
});
} }
/** /**

View file

@ -6,6 +6,11 @@ import { htmlPage, redirectWithCookie, redirectClearCookie } from "./html-respon
import type { AdminDeps } from "./index.js"; import type { AdminDeps } from "./index.js";
import { LoginPage, TotpPage, RecoveryPage } from "../../web-templates/auth-pages.js"; import { LoginPage, TotpPage, RecoveryPage } from "../../web-templates/auth-pages.js";
import { audit } from "../../shared/audit.js"; import { audit } from "../../shared/audit.js";
import { createRateLimiter } from "../../shared/rate-limit.js";
// 8 attempts per 60s per IP — paired with the user-account lockout already in
// place via deps.auth.config.loginLockoutThreshold to defeat enumeration.
const loginGuard = createRateLimiter({ windowMs: 60_000, max: 8 });
export function registerAuthRoutes(app: H3, deps: AdminDeps): void { export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
@ -18,6 +23,20 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
}); });
app.post("/auth/login", async (event) => { app.post("/auth/login", async (event) => {
const ip = getRequestHeader(event, "x-real-ip")
?? getRequestHeader(event, "x-forwarded-for")?.split(",")[0]?.trim()
?? "anon";
if (!loginGuard.take(`login:${ip}`)) {
audit(deps.repo, event as any, "user.login", {
result: "failed",
metadata: { reason: "rate_limited", ip },
});
return new Response("Too many login attempts. Try again in a minute.", {
status: 429,
headers: { "retry-after": "60", "content-type": "text/plain" },
});
}
const body = await readBody<{ username?: string; password?: string }>(event); const body = await readBody<{ username?: string; password?: string }>(event);
const username = (body?.username ?? "").trim(); const username = (body?.username ?? "").trim();
const password = body?.password ?? ""; const password = body?.password ?? "";

View file

@ -23,7 +23,13 @@ import { generateBundle } from "../../shared/bundle.js";
import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js"; import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js";
import { initFirmware, type FirmwareApi } from "../../shared/firmware.js"; import { initFirmware, type FirmwareApi } from "../../shared/firmware.js";
import { envStr } from "../../shared/env-overrides.js"; import { envStr } from "../../shared/env-overrides.js";
import { createRateLimiter } from "../../shared/rate-limit.js";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
// Pairing initiation is unauth — guard it so a misbehaving kiosk or attacker
// can't spam codes. 20 per minute per IP is generous for legit retries.
const pairingGuard = createRateLimiter({ windowMs: 60_000, max: 20 });
const claimGuard = createRateLimiter({ windowMs: 60_000, max: 60 });
import type { Repository } from "../service-store/repository.js"; import type { Repository } from "../service-store/repository.js";
import type { AuthApi } from "../../shared/auth.js"; import type { AuthApi } from "../../shared/auth.js";
import type { SecretsApi } from "../../shared/secrets.js"; import type { SecretsApi } from "../../shared/secrets.js";
@ -194,6 +200,13 @@ function registerPairingRoutes(
): void { ): void {
// Kiosk initiates pairing — no auth required // Kiosk initiates pairing — no auth required
app.post("/api/pair/initiate", async (event) => { app.post("/api/pair/initiate", async (event) => {
const ip = getRequestHeader(event, "x-real-ip")
?? getRequestHeader(event, "x-forwarded-for")?.split(",")[0]?.trim()
?? "anon";
if (!pairingGuard.take(`pair:${ip}`)) {
throw createError({ statusCode: 429, statusMessage: "rate limited" });
}
const body = await readBody<{ const body = await readBody<{
proposed_name?: string; proposed_name?: string;
hardware_model?: string; hardware_model?: string;
@ -212,6 +225,13 @@ function registerPairingRoutes(
// Kiosk polls for claim result — no auth required // Kiosk polls for claim result — no auth required
app.post("/api/pair/claim", async (event) => { app.post("/api/pair/claim", async (event) => {
const ip = getRequestHeader(event, "x-real-ip")
?? getRequestHeader(event, "x-forwarded-for")?.split(",")[0]?.trim()
?? "anon";
if (!claimGuard.take(`claim:${ip}`)) {
throw createError({ statusCode: 429, statusMessage: "rate limited" });
}
const body = await readBody<{ code?: string }>(event); const body = await readBody<{ code?: string }>(event);
const code = (body?.code ?? "").trim().toUpperCase(); const code = (body?.code ?? "").trim().toUpperCase();
if (!code) throw createError({ statusCode: 400, statusMessage: "code required" }); if (!code) throw createError({ statusCode: 400, statusMessage: "code required" });

View file

@ -0,0 +1,57 @@
/**
* In-memory sliding-window rate limiter. Per-key bucket holds timestamps of
* recent hits; we trim entries older than the window on every check.
*
* Suits single-process BSB; if we scale horizontally later swap in Redis.
*
* const guard = createRateLimiter({ windowMs: 60_000, max: 5 });
* if (guard.take(`login:${ip}`)) ... // false = rate-limited
*/
export interface RateLimitConfig {
windowMs: number;
max: number;
}
export interface RateLimiter {
/** Returns true if allowed, false if over limit. */
take(key: string): boolean;
/** How many hits remain in the current window for this key. */
remaining(key: string): number;
/** Clear a specific key (e.g. after a successful auth). */
reset(key: string): void;
}
export function createRateLimiter(config: RateLimitConfig): RateLimiter {
const buckets = new Map<string, number[]>();
function trim(key: string, now: number): number[] {
const cutoff = now - config.windowMs;
const arr = buckets.get(key) ?? [];
const filtered = arr.filter((ts) => ts >= cutoff);
if (filtered.length === 0) {
buckets.delete(key);
} else {
buckets.set(key, filtered);
}
return filtered;
}
return {
take(key: string): boolean {
const now = Date.now();
const arr = trim(key, now);
if (arr.length >= config.max) return false;
arr.push(now);
buckets.set(key, arr);
return true;
},
remaining(key: string): number {
const arr = trim(key, Date.now());
return Math.max(0, config.max - arr.length);
},
reset(key: string): void {
buckets.delete(key);
},
};
}