Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
73 changes: 72 additions & 1 deletion server.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@
summarizeSchema,
summarizeCredentialSchema,
sessionsLookupSchema,
knowledgeGapsSchema,

Check warning on line 22 in server.js

View workflow job for this annotation

GitHub Actions / Backend Tests (Node.js)

'knowledgeGapsSchema' is assigned a value but never used
MAX_QUESTION_LENGTH,
} = require("./validators/schemas");
const { clientIpFromRequest } = require("./security/ip");
const { createRedisClient } = require("./security/redis");
const authRoutes = require("./src/routes/authRoutes");

Check warning on line 27 in server.js

View workflow job for this annotation

GitHub Actions / Backend Tests (Node.js)

'authRoutes' is assigned a value but never used

const RAG_SERVICE_URL = process.env.RAG_SERVICE_URL || "http://localhost:5000";
const getInternalRagToken = () => (process.env.INTERNAL_RAG_TOKEN || "").trim();
Expand Down Expand Up @@ -380,6 +380,25 @@
"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({
Expand Down Expand Up @@ -442,6 +461,39 @@
},
});

// ─── 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,
Expand Down Expand Up @@ -493,7 +545,19 @@
// 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.
Expand Down Expand Up @@ -1698,6 +1762,13 @@
});
});

// Export rate limiters for use in route files
module.exports = {
app,
authLoginLimiter,
authSignupLimiter,
};
Comment thread
Namraa310806 marked this conversation as resolved.

if (require.main === module) {
try {
requireInternalRagToken();
Expand Down
150 changes: 150 additions & 0 deletions server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
28 changes: 28 additions & 0 deletions src/data/users.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment thread
Namraa310806 marked this conversation as resolved.
}
]
Loading