From 6fa9bb01ba1fbf08fa17d7dd592ee4d0e304359c Mon Sep 17 00:00:00 2001 From: Namraa Patel Date: Tue, 16 Jun 2026 02:40:33 +0530 Subject: [PATCH] fix(security): add dedicated auth endpoint rate limiting --- .env.example | 17 +++++ server.js | 73 ++++++++++++++++++++- server.test.js | 150 ++++++++++++++++++++++++++++++++++++++++++++ src/data/users.json | 28 +++++++++ 4 files changed, 267 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 221a4a8..4e088f7 100644 --- a/.env.example +++ b/.env.example @@ -162,6 +162,23 @@ UPLOAD_MAX_FILE_SIZE_BYTES=20000000 # Default: 10 RATE_LIMIT_SLOWDOWN_AFTER=10 +# ─── Authentication Rate Limiting (Express API Gateway) ─────────────────────── + +# Sliding window for authentication endpoint rate limiting (login/signup). +# Applies to POST /api/auth/login and POST /api/auth/signup. +# Default: 900000 (15 minutes) +AUTH_RATE_LIMIT_WINDOW_MS=900000 + +# Maximum login attempts per IP within AUTH_RATE_LIMIT_WINDOW_MS before a 429 response. +# Protects against credential stuffing and brute-force login attacks. +# Default: 5 +AUTH_RATE_LIMIT_MAX=5 + +# Maximum signup attempts per IP within AUTH_RATE_LIMIT_WINDOW_MS before a 429 response. +# Stricter than login limit to prevent automated account creation spam. +# Default: 3 +AUTH_SIGNUP_RATE_LIMIT_MAX=3 + # ─── Session & FAISS Memory Quotas (RAG Service) ──────────────────────────── # How long an idle session stays alive before automatic cleanup (minutes). diff --git a/server.js b/server.js index cddb5b7..846ac2d 100644 --- a/server.js +++ b/server.js @@ -380,6 +380,25 @@ const RATE_LIMIT_MAX = parsePositiveIntegerEnv( "RATE_LIMIT_MAX", ); +// ─── Authentication Rate Limiting Configuration ───────────────────────────────── +// Dedicated rate limiting for authentication endpoints to prevent brute-force attacks. +// These limits are stricter than the global limiter to protect login/signup specifically. +const AUTH_RATE_LIMIT_WINDOW_MS = parsePositiveIntegerEnv( + process.env.AUTH_RATE_LIMIT_WINDOW_MS, + 15 * 60 * 1000, // 15 minutes default + "AUTH_RATE_LIMIT_WINDOW_MS", +); +const AUTH_RATE_LIMIT_MAX = parsePositiveIntegerEnv( + process.env.AUTH_RATE_LIMIT_MAX, + 5, // 5 attempts per window default + "AUTH_RATE_LIMIT_MAX", +); +const AUTH_SIGNUP_RATE_LIMIT_MAX = parsePositiveIntegerEnv( + process.env.AUTH_SIGNUP_RATE_LIMIT_MAX, + 3, // 3 signup attempts per window default (stricter to prevent account creation spam) + "AUTH_SIGNUP_RATE_LIMIT_MAX", +); + // Global baseline — broad bot/scraper protection across every route. // 200 req / 15 min per IP. Tripping this triggers the escalating ban. const globalLimiter = rateLimit({ @@ -442,6 +461,39 @@ const inferenceLimiter = rateLimit({ }, }); +// ─── Authentication Rate Limiters ─────────────────────────────────────────────── +// Dedicated rate limiters for authentication endpoints to prevent brute-force attacks. +// These are stricter than the global limiter and trigger the IP ban system on violation. + +// Login rate limiter — protects against credential stuffing and brute-force login attempts. +const authLoginLimiter = rateLimit({ + windowMs: AUTH_RATE_LIMIT_WINDOW_MS, + max: AUTH_RATE_LIMIT_MAX, + standardHeaders: "draft-7", + legacyHeaders: false, + keyGenerator: (req) => `${keyGenerator(req)}:login`, + store: createLimiterStore("rl:auth:login:"), + handler: (req, res) => { + res.locals.rateLimitMessage = "Too many login attempts. Please wait before trying again."; + rateLimitHandler(req, res); + }, +}); + +// Signup rate limiter — protects against automated account creation spam. +// Stricter than login since account creation has higher potential for abuse. +const authSignupLimiter = rateLimit({ + windowMs: AUTH_RATE_LIMIT_WINDOW_MS, + max: AUTH_SIGNUP_RATE_LIMIT_MAX, + standardHeaders: "draft-7", + legacyHeaders: false, + keyGenerator: (req) => `${keyGenerator(req)}:signup`, + store: createLimiterStore("rl:auth:signup:"), + handler: (req, res) => { + res.locals.rateLimitMessage = "Too many signup attempts. Please wait before trying again."; + rateLimitHandler(req, res); + }, +}); + const UPLOAD_MAX_CONCURRENT_PER_IP = parsePositiveIntegerEnv( process.env.UPLOAD_MAX_CONCURRENT_PER_IP, 2, @@ -493,7 +545,19 @@ const uploadConcurrencyGuard = (req, res, next) => { // Apply global limiter before ban guard so DB-backed ban checks are rate-limited. app.use(globalLimiter); app.use(banGuard); -app.use("/api/auth", authRoutes); + +// Apply authentication-specific rate limiters to individual routes +// These are stricter than the global limiter to prevent brute-force attacks +app.post("/api/auth/login", authLoginLimiter, (req, res, next) => { + // Manually route to the login handler + const { login } = require("./src/controllers/authController"); + login(req, res, next); +}); +app.post("/api/auth/signup", authSignupLimiter, (req, res, next) => { + // Manually route to the signup handler + const { signup } = require("./src/controllers/authController"); + signup(req, res, next); +}); // ─── File Size Limits ────────────────────────────────────────────────────────── // UPLOAD_MAX_FILE_SIZE_BYTES controls the maximum PDF file size allowed per upload. @@ -1698,6 +1762,13 @@ app.use((err, req, res, next) => { }); }); +// Export rate limiters for use in route files +module.exports = { + app, + authLoginLimiter, + authSignupLimiter, +}; + if (require.main === module) { try { requireInternalRagToken(); diff --git a/server.test.js b/server.test.js index 2454893..7600592 100644 --- a/server.test.js +++ b/server.test.js @@ -943,6 +943,156 @@ describe("route error responses", () => { ); }); + // ── Authentication Rate Limiting Tests ─────────────────────────────────────── + // + // These tests verify that authentication endpoints (login and signup) have + // dedicated rate limiting to prevent brute-force attacks. The limits are + // stricter than the global limiter and trigger the IP ban system on violation. + + test("POST /api/auth/login returns 429 when rate limit is exceeded", () => { + const result = runIsolatedGatewayScript(` + const http = require("node:http"); + const { app } = require("./server.js"); + + const server = http.createServer(app); + server.listen(0, async () => { + const { port } = server.address(); + const baseUrl = "http://127.0.0.1:" + port; + const body = JSON.stringify({ + email: "test@example.com", + password: "password123", + }); + + // Make requests up to the limit + const responses = []; + for (let i = 0; i < 6; i++) { + const res = await fetch(baseUrl + "/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + responses.push({ status: res.status, json: await res.json() }); + } + + console.log(JSON.stringify({ + responses, + })); + + await new Promise((resolve) => server.close(resolve)); + }); + `, { + AUTH_RATE_LIMIT_MAX: "5", + AUTH_RATE_LIMIT_WINDOW_MS: "60000", + }); + + // First 5 requests should succeed (or fail with auth error, not rate limit) + for (let i = 0; i < 5; i++) { + assert.notEqual(result.responses[i].status, 429, `Request ${i + 1} should not be rate limited`); + } + + // 6th request should be rate limited + assert.equal(result.responses[5].status, 429, "6th request should be rate limited"); + assert.match(result.responses[5].json.error, /too many login attempts/i); + }); + + test("POST /api/auth/signup returns 429 when rate limit is exceeded", () => { + const result = runIsolatedGatewayScript(` + const http = require("node:http"); + const { app } = require("./server.js"); + + const server = http.createServer(app); + server.listen(0, async () => { + const { port } = server.address(); + const baseUrl = "http://127.0.0.1:" + port; + const body = JSON.stringify({ + email: "test@example.com", + password: "Password123!", + }); + + // Make requests up to the limit + const responses = []; + for (let i = 0; i < 4; i++) { + const res = await fetch(baseUrl + "/api/auth/signup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + responses.push({ status: res.status, json: await res.json() }); + } + + console.log(JSON.stringify({ + responses, + })); + + await new Promise((resolve) => server.close(resolve)); + }); + `, { + AUTH_SIGNUP_RATE_LIMIT_MAX: "3", + AUTH_RATE_LIMIT_WINDOW_MS: "60000", + }); + + // First 3 requests should succeed (or fail with auth error, not rate limit) + for (let i = 0; i < 3; i++) { + assert.notEqual(result.responses[i].status, 429, `Request ${i + 1} should not be rate limited`); + } + + // 4th request should be rate limited + assert.equal(result.responses[3].status, 429, "4th request should be rate limited"); + assert.match(result.responses[3].json.error, /too many signup attempts/i); + }); + + test("POST /api/auth/login allows requests after rate limit window expires", () => { + const result = runIsolatedGatewayScript(` + const http = require("node:http"); + const { app } = require("./server.js"); + + const server = http.createServer(app); + server.listen(0, async () => { + const { port } = server.address(); + const baseUrl = "http://127.0.0.1:" + port; + const body = JSON.stringify({ + email: "test@example.com", + password: "password123", + }); + + // Make requests up to the limit + const firstBatch = []; + for (let i = 0; i < 5; i++) { + const res = await fetch(baseUrl + "/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + firstBatch.push(res.status); + } + + // Wait for window to expire (2 seconds for test) + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Make another request - should succeed now + const afterReset = await fetch(baseUrl + "/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + + console.log(JSON.stringify({ + firstBatch, + afterResetStatus: afterReset.status, + afterResetJson: await afterReset.json(), + })); + + await new Promise((resolve) => server.close(resolve)); + }); + `, { + AUTH_RATE_LIMIT_MAX: "5", + AUTH_RATE_LIMIT_WINDOW_MS: "1000", // 1 second window + }); + + // After window expires, request should not be rate limited + assert.notEqual(result.afterResetStatus, 429, "Request after window reset should not be rate limited"); + }); + test("successful upload response does not include a url field", async () => { const originalPostForm = axios.postForm; const originalPost = axios.post; diff --git a/src/data/users.json b/src/data/users.json index adb2d28..d88b55b 100644 --- a/src/data/users.json +++ b/src/data/users.json @@ -62,5 +62,33 @@ { "email": "testuser2-1780859135237@example.com", "password": "$2b$10$URdT8Tm/Xg7Kwi/UnSiinOWzjsqFc8QCUgpY/TeURTj2T3R1wYW8S" + }, + { + "email": "test@example.com", + "password": "$2b$10$RaWUr5n09.qyWR62dlq5qOY2rp8b2H9a8ti5wEybNjoRMRuufPshS" + }, + { + "email": "testuser-1781551499289@example.com", + "password": "$2b$10$ll5S.B9PPMFiQxfT4hjVLOf3hnUtlxyJAMv/YJtsfKPehWbZMJGK2" + }, + { + "email": "testuser2-1781551499516@example.com", + "password": "$2b$10$baTqlIxw88NpL34nlg7/y.Jl.akAz2zd/xgeB0LgHrAZmfpgixWMy" + }, + { + "email": "testuser-1781551605952@example.com", + "password": "$2b$10$BzwlmhYYQjBbhgBreniF7uvms0.Jh.s6Je.2DCI1C..cXRSR8UvA." + }, + { + "email": "testuser2-1781551606152@example.com", + "password": "$2b$10$7MQ1pCnRVQQKPzIkESM4.erM/hQMm/0aE5iMHCwqufb49/3W.YFmW" + }, + { + "email": "testuser-1781556315550@example.com", + "password": "$2b$10$8qt.GdZL80SkRljLN8HJzOqcqKA9jmgfJK/1ScM37EFeuhla9A2re" + }, + { + "email": "testuser2-1781556315676@example.com", + "password": "$2b$10$MPgYAn58I80fyHjPkA6kqe.1ycqweHdJocJD55JZdB/Aksg3SOfse" } ] \ No newline at end of file