mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 20:16:35 +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
|
* 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" },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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 ?? "";
|
||||||
|
|
|
||||||
|
|
@ -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" });
|
||||||
|
|
|
||||||
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