mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 17:56:34 +00:00
feat(server): rate limit (login + pair) + CSP/security headers
This commit is contained in:
parent
3ec2f3bf85
commit
a6c1fb4d8d
4 changed files with 120 additions and 6 deletions
|
|
@ -5,17 +5,35 @@
|
|||
* a string/object directly. This helper wraps JSX output in a
|
||||
* 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 {
|
||||
return new Response(String(markup), {
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
return new Response(String(markup), { headers: SECURITY_HEADERS });
|
||||
}
|
||||
|
||||
/** Same as htmlPage — separate name for htmx fragment swaps to read clearly. */
|
||||
export function htmlFragment(markup: unknown): Response {
|
||||
return new Response(String(markup), {
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
return new Response(String(markup), { headers: SECURITY_HEADERS });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@ import { htmlPage, redirectWithCookie, redirectClearCookie } from "./html-respon
|
|||
import type { AdminDeps } from "./index.js";
|
||||
import { LoginPage, TotpPage, RecoveryPage } from "../../web-templates/auth-pages.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 {
|
||||
|
|
@ -18,6 +23,20 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
|||
});
|
||||
|
||||
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 username = (body?.username ?? "").trim();
|
||||
const password = body?.password ?? "";
|
||||
|
|
|
|||
|
|
@ -23,7 +23,13 @@ import { generateBundle } from "../../shared/bundle.js";
|
|||
import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js";
|
||||
import { initFirmware, type FirmwareApi } from "../../shared/firmware.js";
|
||||
import { envStr } from "../../shared/env-overrides.js";
|
||||
import { createRateLimiter } from "../../shared/rate-limit.js";
|
||||
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 { AuthApi } from "../../shared/auth.js";
|
||||
import type { SecretsApi } from "../../shared/secrets.js";
|
||||
|
|
@ -194,6 +200,13 @@ function registerPairingRoutes(
|
|||
): void {
|
||||
// Kiosk initiates pairing — no auth required
|
||||
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<{
|
||||
proposed_name?: string;
|
||||
hardware_model?: string;
|
||||
|
|
@ -212,6 +225,13 @@ function registerPairingRoutes(
|
|||
|
||||
// Kiosk polls for claim result — no auth required
|
||||
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 code = (body?.code ?? "").trim().toUpperCase();
|
||||
if (!code) throw createError({ statusCode: 400, statusMessage: "code required" });
|
||||
|
|
|
|||
57
server/src/shared/rate-limit.ts
Normal file
57
server/src/shared/rate-limit.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue