Skip to content

Commit 4553d06

Browse files
committed
feat: implement IP rate limiting across registration and login routes
1 parent aa2eee3 commit 4553d06

7 files changed

Lines changed: 126 additions & 58 deletions

File tree

app/api/admin/register/route.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import dbConnect from "@/lib/db";
55
import User, { IUser } from "@/models/User";
66
import { getAuth } from "@/lib/firebase-admin";
77
import { verifyRecaptcha } from "@/lib/recaptcha";
8+
import { checkRateLimit, getClientIp } from "@/lib/rate-limit";
89

910
export const dynamic = 'force-dynamic';
1011
export const runtime = 'nodejs';
@@ -21,6 +22,19 @@ const validatePassword = (password: string) => {
2122

2223
export async function POST(request: Request) {
2324
try {
25+
// IP rate limiting (5 requests per minute) — also throttles adminCode guessing
26+
const ip = getClientIp(request);
27+
if (!(await checkRateLimit(ip, 5, 60 * 1000))) {
28+
return NextResponse.json(
29+
{
30+
success: false,
31+
message: "Too many requests. Please try again later.",
32+
error: { code: "rate_limit_exceeded", message: "Rate limit exceeded" },
33+
},
34+
{ status: 429 },
35+
);
36+
}
37+
2438
const body = await request.json();
2539
const { name, email, password, adminCode, recaptcha_token } = body;
2640

app/api/evaluator/register/route.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import dbConnect from "@/lib/db";
44
import Evaluator from "@/models/Evaluator";
55
import User from "@/models/User";
66
import { getAuth } from "@/lib/firebase-admin";
7-
import { checkRateLimit } from "@/lib/rate-limit";
7+
import { checkRateLimit, getClientIp } from "@/lib/rate-limit";
88
import { verifyRecaptcha } from "@/lib/recaptcha";
99

1010
export const dynamic = 'force-dynamic';
@@ -34,8 +34,8 @@ function createErrorResponse(message: string, code: string, status: number) {
3434
*/
3535
export async function POST(request: NextRequest) {
3636
try {
37-
const ip = request.headers.get("x-forwarded-for") || "unknown";
38-
if (!checkRateLimit(ip, 5, 60 * 1000)) {
37+
const ip = getClientIp(request);
38+
if (!(await checkRateLimit(ip, 5, 60 * 1000))) {
3939
return createErrorResponse("Too many requests. Please try again later.", "RATE_LIMIT_EXCEEDED", 429);
4040
}
4141

app/api/registration/route.ts

Lines changed: 19 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import path from "path";
1010
import os from "os";
1111
import dbConnect from "@/lib/db";
1212
import User, { IUser } from "@/models/User";
13-
import { checkRateLimit } from "@/lib/rate-limit";
13+
import { checkRateLimit, getClientIp } from "@/lib/rate-limit";
14+
import { verifyRecaptcha } from "@/lib/recaptcha";
1415

1516
// Utility functions for format validation
1617
const validateEmail = (email: string) =>
@@ -206,9 +207,9 @@ const getOrCreateBatchDocument = async () => {
206207

207208
export async function POST(request: Request) {
208209
try {
209-
// Basic IP Rate limiting (5 requests per minute)
210-
const ip = request.headers.get("x-forwarded-for") || "unknown";
211-
if (!checkRateLimit(ip, 5, 60 * 1000)) {
210+
// IP rate limiting (5 requests per minute)
211+
const ip = getClientIp(request);
212+
if (!(await checkRateLimit(ip, 5, 60 * 1000))) {
212213
return NextResponse.json(
213214
{
214215
message: "Too many requests. Please try again later.",
@@ -234,6 +235,20 @@ export async function POST(request: Request) {
234235
const data = { ...fields };
235236
const { recaptcha_token, password } = data;
236237

238+
// reCAPTCHA v3 — fail closed (rejects when the token is missing) and check
239+
// the score + action, before any Firebase/Cloudinary/DB writes.
240+
const captcha = await verifyRecaptcha(recaptcha_token, "register");
241+
if (!captcha.ok) {
242+
console.warn("[registration] reCAPTCHA rejected:", captcha.reason, captcha.score);
243+
return NextResponse.json(
244+
{
245+
message: "reCAPTCHA validation failed",
246+
error: "Security check failed. Please try again.",
247+
},
248+
{ status: 400 },
249+
);
250+
}
251+
237252
// Check if required resume file is present
238253
if (!files.resume) {
239254
return NextResponse.json(
@@ -493,28 +508,6 @@ export async function POST(request: Request) {
493508
);
494509
}
495510

496-
// Validate reCAPTCHA if token provided
497-
if (recaptcha_token) {
498-
const recaptchaSecretKey = process.env.RECAPTCHA_SECRET_KEY;
499-
500-
// Verify reCAPTCHA token
501-
const recaptchaResponse = await fetch(
502-
`https://www.google.com/recaptcha/api/siteverify?secret=${recaptchaSecretKey}&response=${recaptcha_token}`,
503-
{ method: "POST" },
504-
);
505-
const recaptchaResult = await recaptchaResponse.json();
506-
507-
if (!recaptchaResult.success) {
508-
return NextResponse.json(
509-
{
510-
message: "reCAPTCHA validation failed",
511-
error: recaptchaResult["error-codes"],
512-
},
513-
{ status: 400 },
514-
);
515-
}
516-
}
517-
518511
// Create user in Firebase Authentication
519512
let authUid: string;
520513
try {

app/api/user/login/route.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import dbConnect from "@/lib/db";
66
import User from "@/models/User";
77
import { getAuth } from "@/lib/firebase-admin";
88
import { verifyRecaptcha } from "@/lib/recaptcha";
9+
import { checkRateLimit, getClientIp } from "@/lib/rate-limit";
910

1011
const ADMIN_EMAIL_DOMAIN = process.env.ADMIN_EMAIL_DOMAIN;
1112
const SECRET_CODE = process.env.SECRET_CODE;
@@ -28,6 +29,15 @@ async function authenticateUser(email: string, password: string, isAdminAttempt:
2829

2930
export async function POST(request: Request) {
3031
try {
32+
// IP rate limiting (10 requests per minute) — throttles password guessing
33+
const ip = getClientIp(request);
34+
if (!(await checkRateLimit(ip, 10, 60 * 1000))) {
35+
return NextResponse.json(
36+
{ message: "Too many requests. Please try again later." },
37+
{ status: 429 },
38+
);
39+
}
40+
3141
const { email, password, recaptcha_token } = await request.json();
3242

3343
if (!email || !password) {

app/api/user/register/route.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { cloudinaryV2 } from "@/c";
88
import dbConnect from "@/lib/db";
99
import User, { IUser } from "@/models/User";
1010
import { verifyRecaptcha } from "@/lib/recaptcha";
11+
import { checkRateLimit, getClientIp } from "@/lib/rate-limit";
1112

1213
// Configure route
1314
export const dynamic = "force-dynamic";
@@ -67,6 +68,19 @@ async function uploadBase64ToCloudinary(
6768

6869
export async function POST(request: Request) {
6970
try {
71+
// IP rate limiting (5 requests per minute)
72+
const ip = getClientIp(request);
73+
if (!(await checkRateLimit(ip, 5, 60 * 1000))) {
74+
return NextResponse.json(
75+
{
76+
success: false,
77+
message: "Too many requests. Please try again later.",
78+
error: { code: "rate_limit_exceeded", message: "Rate limit exceeded" },
79+
},
80+
{ status: 429 },
81+
);
82+
}
83+
7084
const REGISTRATION_DEADLINE = new Date("2026-07-19T10:00:00+05:30");
7185
if (new Date() > REGISTRATION_DEADLINE) {
7286
return NextResponse.json(

lib/rate-limit.ts

Lines changed: 42 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,49 @@
1-
type RateLimitInfo = {
2-
count: number;
3-
lastReset: number;
4-
};
1+
import dbConnect from "@/lib/db";
2+
import RateLimit from "@/models/RateLimit";
53

6-
const rateLimits = new Map<string, RateLimitInfo>();
4+
/**
5+
* Resolve the real client IP. On Netlify the only trustworthy value is
6+
* x-nf-client-connection-ip (set by the edge). x-forwarded-for is
7+
* client-spoofable, so it's a last resort and we take the left-most hop only.
8+
*/
9+
export function getClientIp(request: Request): string {
10+
const netlify = request.headers.get("x-nf-client-connection-ip");
11+
if (netlify) return netlify.trim();
712

8-
// Cleans up the Map every 5 minutes to prevent memory leaks
9-
setInterval(() => {
10-
const now = Date.now();
11-
for (const [ip, info] of rateLimits.entries()) {
12-
if (now - info.lastReset > 5 * 60 * 1000) {
13-
rateLimits.delete(ip);
14-
}
15-
}
16-
}, 5 * 60 * 1000);
13+
const xff = request.headers.get("x-forwarded-for");
14+
if (xff) return xff.split(",")[0].trim();
1715

18-
export function checkRateLimit(ip: string, limit: number, windowMs: number): boolean {
19-
const now = Date.now();
20-
const info = rateLimits.get(ip) || { count: 0, lastReset: now };
16+
return request.headers.get("x-real-ip")?.trim() || "unknown";
17+
}
2118

22-
if (now - info.lastReset > windowMs) {
23-
info.count = 1;
24-
info.lastReset = now;
25-
rateLimits.set(ip, info);
26-
return true;
27-
}
19+
/**
20+
* Fixed-window rate limit backed by MongoDB so it holds across serverless
21+
* instances (an in-memory Map resets on every cold start). Returns true if the
22+
* request is allowed, false if the limit is exceeded. Fails open if the store
23+
* is unreachable so a DB blip can't lock everyone out.
24+
*/
25+
export async function checkRateLimit(
26+
key: string,
27+
limit: number,
28+
windowMs: number,
29+
): Promise<boolean> {
30+
try {
31+
await dbConnect();
2832

29-
if (info.count >= limit) {
30-
return false;
31-
}
33+
const window = Math.floor(Date.now() / windowMs);
34+
const bucketKey = `${key}:${window}`;
35+
const expiresAt = new Date((window + 1) * windowMs);
36+
37+
// Atomic per-window counter; upsert avoids a check-then-write race.
38+
const doc = await RateLimit.findOneAndUpdate(
39+
{ key: bucketKey },
40+
{ $inc: { count: 1 }, $setOnInsert: { expiresAt } },
41+
{ upsert: true, new: true },
42+
);
3243

33-
info.count++;
34-
rateLimits.set(ip, info);
35-
return true;
44+
return doc.count <= limit;
45+
} catch (error) {
46+
console.error("[rate-limit] store unreachable, allowing request:", error);
47+
return true;
48+
}
3649
}

models/RateLimit.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import mongoose, { Schema, Document, Model } from "mongoose";
2+
3+
// One document per (key, fixed-window) bucket. TTL on expiresAt cleans them up,
4+
// so the collection self-prunes without a cron.
5+
export interface IRateLimit extends Document {
6+
key: string;
7+
count: number;
8+
expiresAt: Date;
9+
}
10+
11+
const RateLimitSchema: Schema = new Schema({
12+
key: { type: String, required: true, unique: true },
13+
count: { type: Number, required: true, default: 0 },
14+
expiresAt: { type: Date, required: true },
15+
});
16+
17+
// Mongo TTL monitor deletes the doc once expiresAt passes.
18+
RateLimitSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
19+
20+
const RateLimit: Model<IRateLimit> =
21+
mongoose.models.RateLimit ||
22+
mongoose.model<IRateLimit>("RateLimit", RateLimitSchema);
23+
24+
export default RateLimit;

0 commit comments

Comments
 (0)