diff --git a/relay/src/middleware/admin-auth.ts b/relay/src/middleware/admin-auth.ts index 36f03513..42fe5bb0 100644 --- a/relay/src/middleware/admin-auth.ts +++ b/relay/src/middleware/admin-auth.ts @@ -5,6 +5,7 @@ */ import { Request, Response, NextFunction } from "express"; +import { isRateLimited, recordFailedAttempt } from "./token-auth"; import { secureCompare, hashToken } from "../utils/security"; import { authConfig } from "../config"; import { loggers } from "../utils/logger"; @@ -27,6 +28,19 @@ function getAdminPasswordHash(): string | null { * Uses timing-safe comparison to prevent timing attacks */ export function adminAuthMiddleware(req: Request, res: Response, next: NextFunction): void { + const clientIp = req.ip || req.connection?.remoteAddress || req.socket?.remoteAddress || "unknown"; + + if (isRateLimited(clientIp)) { + log.warn( + { ip: clientIp, path: req.path }, + "Admin auth failed - rate limited" + ); + res.status(429).json({ + success: false, + error: "Too many failed authentication attempts. Please try again later.", + }); + return; + } // Check Authorization header (Bearer token) const authHeader = req.headers["authorization"]; const bearerToken = authHeader && authHeader.split(" ")[1]; @@ -45,6 +59,7 @@ export function adminAuthMiddleware(req: Request, res: Response, next: NextFunct }, "Admin auth failed - no token" ); + recordFailedAttempt(clientIp); res.status(401).json({ success: false, error: "Unauthorized - Admin token required", @@ -83,6 +98,7 @@ export function adminAuthMiddleware(req: Request, res: Response, next: NextFunct }, "Admin auth failed - invalid token" ); + recordFailedAttempt(clientIp); res.status(401).json({ success: false, error: "Unauthorized - Invalid admin token", diff --git a/relay/src/tests/admin-auth.test.ts b/relay/src/tests/admin-auth.test.ts new file mode 100644 index 00000000..0ef25213 --- /dev/null +++ b/relay/src/tests/admin-auth.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { adminAuthMiddleware } from "../middleware/admin-auth"; +import { isRateLimited, recordFailedAttempt } from "../middleware/token-auth"; +import { Request, Response, NextFunction } from "express"; + +// Mock dependencies +vi.mock("../utils/logger", () => ({ + loggers: { + server: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + }, +})); + +vi.mock("../config", () => ({ + authConfig: { adminPassword: "test-admin-password", strictSessionIp: true }, +})); + +vi.mock("../middleware/token-auth", () => { + return { + isRateLimited: vi.fn(), + recordFailedAttempt: vi.fn(), + }; +}); + +describe("adminAuthMiddleware rate limiting", () => { + let mockReq: Partial; + let mockRes: Partial; + let mockNext: NextFunction; + + beforeEach(() => { + vi.useFakeTimers(); + mockReq = { + headers: {}, + ip: "192.168.1.100", + path: "/api/system/stats", + }; + mockRes = { + status: vi.fn().mockReturnThis(), + json: vi.fn(), + }; + mockNext = vi.fn(); + + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should block requests when rate limited", () => { + vi.mocked(isRateLimited).mockReturnValueOnce(true); + + adminAuthMiddleware(mockReq as Request, mockRes as Response, mockNext); + + expect(isRateLimited).toHaveBeenCalledWith(mockReq.ip); + expect(mockRes.status).toHaveBeenCalledWith(429); + expect(mockRes.json).toHaveBeenCalledWith({ + success: false, + error: "Too many failed authentication attempts. Please try again later.", + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it("should record failed attempt when no token is provided", () => { + vi.mocked(isRateLimited).mockReturnValueOnce(false); + + adminAuthMiddleware(mockReq as Request, mockRes as Response, mockNext); + + expect(recordFailedAttempt).toHaveBeenCalledWith(mockReq.ip); + expect(mockRes.status).toHaveBeenCalledWith(401); + }); + + it("should record failed attempt when an invalid token is provided", () => { + vi.mocked(isRateLimited).mockReturnValueOnce(false); + mockReq.headers = { authorization: "Bearer invalid-token" }; + + adminAuthMiddleware(mockReq as Request, mockRes as Response, mockNext); + + expect(recordFailedAttempt).toHaveBeenCalledWith(mockReq.ip); + expect(mockRes.status).toHaveBeenCalledWith(401); + }); +});