From 707ba172c0019879b6c561d9e5f1c3f955cbb6fc Mon Sep 17 00:00:00 2001 From: KingKDot Date: Sat, 30 May 2026 23:20:15 -0500 Subject: [PATCH] optimize docker, more rate limiting, remove not needed shit in build-prod-package. --- Dockerfile | 1 + Overlord-Server/scripts/minify-public.ts | 21 ++++- Overlord-Server/src/main-server.ts | 5 +- Overlord-Server/src/rateLimit.test.ts | 37 +++++++- Overlord-Server/src/rateLimit.ts | 90 +++++++++++++++++++ .../src/server/http-dispatch.test.ts | 37 ++++++++ Overlord-Server/src/server/http-dispatch.ts | 21 ++++- .../src/server/routes/page-routes.ts | 33 ++++++- build-prod-package.bat | 4 +- 9 files changed, 235 insertions(+), 14 deletions(-) create mode 100644 Overlord-Server/src/server/http-dispatch.test.ts diff --git a/Dockerfile b/Dockerfile index f01ad4e..cb46f8f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -111,6 +111,7 @@ RUN if [ -f dist-clients/BackstageCapture.x64.dll ]; then \ # Tailwind CSS, vendored frontend assets, and bundled Bun server runtime. RUN bun run build \ + && bun run minify \ && test -s ./public/assets/tailwind.css \ && test -d ./public/vendor/fontawesome \ && test -s ./dist/index.js \ diff --git a/Overlord-Server/scripts/minify-public.ts b/Overlord-Server/scripts/minify-public.ts index e88289f..7032a1b 100644 --- a/Overlord-Server/scripts/minify-public.ts +++ b/Overlord-Server/scripts/minify-public.ts @@ -33,6 +33,7 @@ const htmlOpts = { type Stats = { js: number; css: number; html: number; savedBytes: number }; const stats: Stats = { js: 0, css: 0, html: 0, savedBytes: 0 }; +let failures = 0; async function collectFiles(dir: string, exts: Set): Promise { const results: string[] = []; @@ -94,11 +95,25 @@ console.log(`Minifying ${jsFiles.length} JS, ${cssFiles.length} CSS, ${htmlFiles // Process in parallel batches await Promise.all([ - ...jsFiles.map((f) => minifyJS(f).catch((e) => console.error(`JS error ${f}: ${e.message}`))), - ...cssFiles.map((f) => minifyCSS(f).catch((e) => console.error(`CSS error ${f}: ${e.message}`))), - ...htmlFiles.map((f) => minifyHTML(f).catch((e) => console.error(`HTML error ${f}: ${e.message}`))), + ...jsFiles.map((f) => minifyJS(f).catch((e) => { + failures++; + console.error(`JS error ${f}: ${e.message}`); + })), + ...cssFiles.map((f) => minifyCSS(f).catch((e) => { + failures++; + console.error(`CSS error ${f}: ${e.message}`); + })), + ...htmlFiles.map((f) => minifyHTML(f).catch((e) => { + failures++; + console.error(`HTML error ${f}: ${e.message}`); + })), ]); console.log( `Done: ${stats.js} JS, ${stats.css} CSS, ${stats.html} HTML — saved ${(stats.savedBytes / 1024).toFixed(1)} KB`, ); + +if (failures > 0) { + console.error(`Minification failed for ${failures} file(s).`); + process.exit(1); +} diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts index f848d4d..8139dfb 100644 --- a/Overlord-Server/src/main-server.ts +++ b/Overlord-Server/src/main-server.ts @@ -680,7 +680,10 @@ async function startServer() { requestIP: (srv as any).requestIP, }), (req, url) => handleAssetsRoutes(req, url, routeDeps.assets), - (req, url) => handlePageRoutes(req, url, routeDeps.page), + (req, url, srv) => handlePageRoutes(req, url, { + ...routeDeps.page, + requestIP: (srv as any).requestIP, + }), (req, url) => handleWebrtcRoutes(req, url), (req, url, srv) => handleClientRoutes(req, url, srv as any, routeDeps.client), (req, url, srv) => handleWsUpgradeRoutes(req, url, srv as any, routeDeps.wsUpgrade), diff --git a/Overlord-Server/src/rateLimit.test.ts b/Overlord-Server/src/rateLimit.test.ts index 7d2d28b..e85ee66 100644 --- a/Overlord-Server/src/rateLimit.test.ts +++ b/Overlord-Server/src/rateLimit.test.ts @@ -1,6 +1,17 @@ -import { describe, expect, test } from "bun:test"; +import { beforeEach, describe, expect, test } from "bun:test"; import { getConfig } from "./config"; -import { isRateLimited, recordFailedAttempt, recordSuccessfulAttempt } from "./rateLimit"; +import { + clearRequestRateLimitsForTests, + consumeLoginPageRateLimit, + consumeUnauthorizedRateLimit, + isRateLimited, + recordFailedAttempt, + recordSuccessfulAttempt, +} from "./rateLimit"; + +beforeEach(() => { + clearRequestRateLimitsForTests(); +}); describe("rateLimit", () => { test("locks out after repeated failures", () => { @@ -26,4 +37,26 @@ describe("rateLimit", () => { recordSuccessfulAttempt(ip); expect(isRateLimited(ip).limited).toBe(false); }); + + test("limits repeated login page requests by IP", () => { + const ip = `10.0.2.${Date.now()}`; + for (let i = 0; i < 60; i += 1) { + expect(consumeLoginPageRateLimit(ip).limited).toBe(false); + } + + const result = consumeLoginPageRateLimit(ip); + expect(result.limited).toBe(true); + expect(result.retryAfter).toBeGreaterThan(0); + }); + + test("limits repeated unauthorized responses by IP", () => { + const ip = `10.0.3.${Date.now()}`; + for (let i = 0; i < 120; i += 1) { + expect(consumeUnauthorizedRateLimit(ip).limited).toBe(false); + } + + const result = consumeUnauthorizedRateLimit(ip); + expect(result.limited).toBe(true); + expect(result.retryAfter).toBeGreaterThan(0); + }); }); diff --git a/Overlord-Server/src/rateLimit.ts b/Overlord-Server/src/rateLimit.ts index 7eeef81..142ca0d 100644 --- a/Overlord-Server/src/rateLimit.ts +++ b/Overlord-Server/src/rateLimit.ts @@ -7,10 +7,22 @@ interface RateLimitEntry { lockedUntil?: number; } +type RequestRateLimitPolicy = { + maxRequests: number; + windowMs: number; + lockoutMs: number; +}; + const rateLimitStore = new Map(); +const requestRateLimitStore = new Map(); const CLEANUP_INTERVAL = 60 * 1000; +function numberFromEnv(name: string, fallback: number): number { + const parsed = Number(process.env[name]); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + function getRateLimitPolicy() { const security = getConfig().security; const maxAttempts = Math.min(50, Math.max(1, Number(security.loginMaxAttempts) || 5)); @@ -85,6 +97,75 @@ export function recordSuccessfulAttempt(ip: string): void { rateLimitStore.delete(ip); } +function makeRequestPolicy(prefix: string, defaults: RequestRateLimitPolicy): RequestRateLimitPolicy { + return { + maxRequests: Math.min( + 1000, + Math.max(1, numberFromEnv(`OVERLORD_${prefix}_RATE_LIMIT_MAX`, defaults.maxRequests)), + ), + windowMs: Math.min( + 60 * 60 * 1000, + Math.max(10 * 1000, numberFromEnv(`OVERLORD_${prefix}_RATE_LIMIT_WINDOW_SECONDS`, defaults.windowMs / 1000) * 1000), + ), + lockoutMs: Math.min( + 60 * 60 * 1000, + Math.max(10 * 1000, numberFromEnv(`OVERLORD_${prefix}_RATE_LIMIT_LOCKOUT_SECONDS`, defaults.lockoutMs / 1000) * 1000), + ), + }; +} + +function consumeRequestRateLimit( + key: string, + label: string, + policy: RequestRateLimitPolicy, +): { limited: boolean; retryAfter?: number } { + const now = Date.now(); + const entry = requestRateLimitStore.get(key); + + if (entry?.lockedUntil && entry.lockedUntil > now) { + return { limited: true, retryAfter: Math.ceil((entry.lockedUntil - now) / 1000) }; + } + + if (!entry || now - entry.firstAttempt > policy.windowMs) { + requestRateLimitStore.set(key, { attempts: 1, firstAttempt: now }); + return { limited: false }; + } + + entry.attempts++; + if (entry.attempts > policy.maxRequests) { + entry.lockedUntil = now + policy.lockoutMs; + const retryAfter = Math.ceil(policy.lockoutMs / 1000); + logger.warn( + `[rate-limit] ${label} rate limit exceeded for ${key}; locked for ${retryAfter}s after ${entry.attempts} requests`, + ); + return { limited: true, retryAfter }; + } + + return { limited: false }; +} + +export function consumeLoginPageRateLimit(ip: string): { limited: boolean; retryAfter?: number } { + const policy = makeRequestPolicy("LOGIN_PAGE", { + maxRequests: 60, + windowMs: 5 * 60 * 1000, + lockoutMs: 10 * 60 * 1000, + }); + return consumeRequestRateLimit(`login-page:${ip}`, "Login page", policy); +} + +export function consumeUnauthorizedRateLimit(ip: string): { limited: boolean; retryAfter?: number } { + const policy = makeRequestPolicy("UNAUTHORIZED", { + maxRequests: 120, + windowMs: 5 * 60 * 1000, + lockoutMs: 10 * 60 * 1000, + }); + return consumeRequestRateLimit(`unauthorized:${ip}`, "Unauthorized request", policy); +} + +export function clearRequestRateLimitsForTests(): void { + requestRateLimitStore.clear(); +} + function cleanupExpired(): void { const policy = getRateLimitPolicy(); const now = Date.now(); @@ -100,6 +181,15 @@ function cleanupExpired(): void { } } + for (const [key, entry] of requestRateLimitStore.entries()) { + const expired = now - entry.firstAttempt > 60 * 60 * 1000; + const lockoutExpired = entry.lockedUntil && entry.lockedUntil < now; + if (expired || lockoutExpired) { + requestRateLimitStore.delete(key); + cleaned++; + } + } + if (cleaned > 0) { logger.debug(`[rate-limit] Cleaned up ${cleaned} expired entries`); } diff --git a/Overlord-Server/src/server/http-dispatch.test.ts b/Overlord-Server/src/server/http-dispatch.test.ts new file mode 100644 index 0000000..e767f69 --- /dev/null +++ b/Overlord-Server/src/server/http-dispatch.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { clearRequestRateLimitsForTests } from "../rateLimit"; +import { createHttpFetchHandler } from "./http-dispatch"; + +const metrics = { + withHttpMetrics: (fn: () => Promise) => fn(), +}; + +function makeServer(ip: string) { + return { + requestIP: () => ({ address: ip }), + upgrade: () => false, + }; +} + +beforeEach(() => { + clearRequestRateLimitsForTests(); +}); + +describe("createHttpFetchHandler rate limiting", () => { + test("throttles repeated unauthorized responses from the same IP", async () => { + const handler = createHttpFetchHandler({ + metrics, + CORS_HEADERS: {}, + routes: [async () => new Response("Unauthorized", { status: 401 })], + }); + + for (let i = 0; i < 120; i += 1) { + const res = await handler(new Request("https://localhost/api/private"), makeServer("203.0.113.10")); + expect(res.status).toBe(401); + } + + const limited = await handler(new Request("https://localhost/api/private"), makeServer("203.0.113.10")); + expect(limited.status).toBe(429); + expect(limited.headers.get("Retry-After")).toBeTruthy(); + }); +}); diff --git a/Overlord-Server/src/server/http-dispatch.ts b/Overlord-Server/src/server/http-dispatch.ts index 5263562..d1356f3 100644 --- a/Overlord-Server/src/server/http-dispatch.ts +++ b/Overlord-Server/src/server/http-dispatch.ts @@ -1,7 +1,18 @@ import { wrapServerWithClientIp, type RequestServerLike } from "./client-ip"; +import { consumeUnauthorizedRateLimit } from "../rateLimit"; export type RouteHandler = (req: Request, url: URL, server: unknown) => Promise; +function tooManyRequestsResponse(retryAfter = 60): Response { + return new Response("Too many requests", { + status: 429, + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Retry-After": String(retryAfter), + }, + }); +} + export function createHttpFetchHandler(deps: { metrics: { withHttpMetrics: (fn: () => Promise) => Promise }; CORS_HEADERS: Record; @@ -14,9 +25,17 @@ export function createHttpFetchHandler(deps: { } const url = new URL(req.url); const wrapped = wrapServerWithClientIp(server as RequestServerLike); + const ip = wrapped.requestIP(req)?.address || "unknown"; + for (const route of deps.routes) { const response = await route(req, url, wrapped); - if (response) return response; + if (response) { + if (response.status === 401) { + const limited = consumeUnauthorizedRateLimit(ip); + if (limited.limited) return tooManyRequestsResponse(limited.retryAfter); + } + return response; + } } return new Response("Not found", { status: 404 }); }); diff --git a/Overlord-Server/src/server/routes/page-routes.ts b/Overlord-Server/src/server/routes/page-routes.ts index 11c9a6e..2444bbc 100644 --- a/Overlord-Server/src/server/routes/page-routes.ts +++ b/Overlord-Server/src/server/routes/page-routes.ts @@ -1,5 +1,6 @@ import { authenticateRequest } from "../../auth"; import { getConfig } from "../../config"; +import { consumeLoginPageRateLimit } from "../../rateLimit"; import { requirePermission, type Permission } from "../../rbac"; import { canUserAccessClient, getUserById, type UserRole } from "../../users"; @@ -7,9 +8,24 @@ type PageRouteDeps = { PUBLIC_ROOT: string; secureHeaders: (contentType?: string) => Record; mimeType: (path: string) => string; + requestIP?: (req: Request) => { address?: string } | null | undefined; }; -async function serveLoginOrUnauthorized(deps: PageRouteDeps): Promise { +function tooManyRequestsResponse(retryAfter = 60): Response { + return new Response("Too many requests", { + status: 429, + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Retry-After": String(retryAfter), + }, + }); +} + +async function serveLoginOrUnauthorized(req: Request, deps: PageRouteDeps): Promise { + const ip = deps.requestIP?.(req)?.address || "unknown"; + const limited = consumeLoginPageRateLimit(ip); + if (limited.limited) return tooManyRequestsResponse(limited.retryAfter); + const loginFile = Bun.file(`${deps.PUBLIC_ROOT}/login.html`); if (await loginFile.exists()) { return new Response(loginFile, { headers: deps.secureHeaders(deps.mimeType("/login.html")) }); @@ -143,10 +159,19 @@ export async function handlePageRoutes( const filePath = authed ? "/index.html" : "/login.html"; const file = Bun.file(`${deps.PUBLIC_ROOT}${filePath}`); if (await file.exists()) { + if (!authed) { + const limited = consumeLoginPageRateLimit(deps.requestIP?.(req)?.address || "unknown"); + if (limited.limited) return tooManyRequestsResponse(limited.retryAfter); + } return new Response(file, { headers: deps.secureHeaders(deps.mimeType(filePath)) }); } } + // ---- Explicit login page ---- + if (url.pathname === "/login.html") { + return serveLoginOrUnauthorized(req, deps); + } + // ---- Change password (always accessible) ---- if (url.pathname === "/change-password.html") { return serveFile(deps, "change-password.html"); @@ -162,7 +187,7 @@ export async function handlePageRoutes( if (url.pathname !== page.path) continue; const user = await authenticateRequest(req); - if (!user) return serveLoginOrUnauthorized(deps); + if (!user) return serveLoginOrUnauthorized(req, deps); if (page.path === "/screenshots" && !getConfig().thumbnails.wallEnabled) { return new Response("Screenshot wall is disabled by the administrator", { status: 403 }); @@ -193,7 +218,7 @@ export async function handlePageRoutes( if (url.pathname !== page.path) continue; const user = await authenticateRequest(req); - if (!user) return serveLoginOrUnauthorized(deps); + if (!user) return serveLoginOrUnauthorized(req, deps); const maybeChange = await serveChangePasswordIfRequired(deps, user.userId); if (maybeChange) return maybeChange; @@ -216,7 +241,7 @@ export async function handlePageRoutes( if (!match) continue; const user = await authenticateRequest(req); - if (!user) return serveLoginOrUnauthorized(deps); + if (!user) return serveLoginOrUnauthorized(req, deps); if (user.role === "viewer") { return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 }); diff --git a/build-prod-package.bat b/build-prod-package.bat index 6b613e7..182cf62 100644 --- a/build-prod-package.bat +++ b/build-prod-package.bat @@ -27,12 +27,10 @@ if errorlevel 1 ( exit /b 1 ) -echo [1/10] Building server bundle... +echo [1/10] Building server bundle and web assets... pushd "%SERVER_SRC%" call bun install if errorlevel 1 goto :err -call bun run build:css -if errorlevel 1 goto :err call bun run build if errorlevel 1 goto :err echo [2/10] Compiling Windows production executable...