optimize docker, more rate limiting, remove not needed shit in build-prod-package.
This commit is contained in:
@@ -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<Response>) => 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();
|
||||
});
|
||||
});
|
||||
@@ -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<Response | null>;
|
||||
|
||||
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<Response>) => Promise<Response> };
|
||||
CORS_HEADERS: Record<string, string>;
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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<string, string>;
|
||||
mimeType: (path: string) => string;
|
||||
requestIP?: (req: Request) => { address?: string } | null | undefined;
|
||||
};
|
||||
|
||||
async function serveLoginOrUnauthorized(deps: PageRouteDeps): Promise<Response> {
|
||||
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<Response> {
|
||||
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 });
|
||||
|
||||
Reference in New Issue
Block a user