Skip to content
Merged
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
14 changes: 14 additions & 0 deletions app/api/admin/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import dbConnect from "@/lib/db";
import User, { IUser } from "@/models/User";
import { getAuth } from "@/lib/firebase-admin";
import { verifyRecaptcha } from "@/lib/recaptcha";
import { checkRateLimit, getClientIp } from "@/lib/rate-limit";

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

export async function POST(request: Request) {
try {
// IP rate limiting (5 requests per minute) — also throttles adminCode guessing
const ip = getClientIp(request);
if (!(await checkRateLimit(ip, 5, 60 * 1000))) {
return NextResponse.json(
{
success: false,
message: "Too many requests. Please try again later.",
error: { code: "rate_limit_exceeded", message: "Rate limit exceeded" },
},
{ status: 429 },
);
}

const body = await request.json();
const { name, email, password, adminCode, recaptcha_token } = body;

Expand Down
6 changes: 3 additions & 3 deletions app/api/evaluator/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import dbConnect from "@/lib/db";
import Evaluator from "@/models/Evaluator";
import User from "@/models/User";
import { getAuth } from "@/lib/firebase-admin";
import { checkRateLimit } from "@/lib/rate-limit";
import { checkRateLimit, getClientIp } from "@/lib/rate-limit";
import { verifyRecaptcha } from "@/lib/recaptcha";

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

Expand Down
45 changes: 19 additions & 26 deletions app/api/registration/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import path from "path";
import os from "os";
import dbConnect from "@/lib/db";
import User, { IUser } from "@/models/User";
import { checkRateLimit } from "@/lib/rate-limit";
import { checkRateLimit, getClientIp } from "@/lib/rate-limit";
import { verifyRecaptcha } from "@/lib/recaptcha";

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

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

// reCAPTCHA v3 — fail closed (rejects when the token is missing) and check
// the score + action, before any Firebase/Cloudinary/DB writes.
const captcha = await verifyRecaptcha(recaptcha_token, "register");
if (!captcha.ok) {
console.warn("[registration] reCAPTCHA rejected:", captcha.reason, captcha.score);
return NextResponse.json(
{
message: "reCAPTCHA validation failed",
error: "Security check failed. Please try again.",
},
{ status: 400 },
);
}

// Check if required resume file is present
if (!files.resume) {
return NextResponse.json(
Expand Down Expand Up @@ -493,28 +508,6 @@ export async function POST(request: Request) {
);
}

// Validate reCAPTCHA if token provided
if (recaptcha_token) {
const recaptchaSecretKey = process.env.RECAPTCHA_SECRET_KEY;

// Verify reCAPTCHA token
const recaptchaResponse = await fetch(
`https://www.google.com/recaptcha/api/siteverify?secret=${recaptchaSecretKey}&response=${recaptcha_token}`,
{ method: "POST" },
);
const recaptchaResult = await recaptchaResponse.json();

if (!recaptchaResult.success) {
return NextResponse.json(
{
message: "reCAPTCHA validation failed",
error: recaptchaResult["error-codes"],
},
{ status: 400 },
);
}
}

// Create user in Firebase Authentication
let authUid: string;
try {
Expand Down
10 changes: 10 additions & 0 deletions app/api/user/login/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import dbConnect from "@/lib/db";
import User from "@/models/User";
import { getAuth } from "@/lib/firebase-admin";
import { verifyRecaptcha } from "@/lib/recaptcha";
import { checkRateLimit, getClientIp } from "@/lib/rate-limit";

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

export async function POST(request: Request) {
try {
// IP rate limiting (10 requests per minute) — throttles password guessing
const ip = getClientIp(request);
if (!(await checkRateLimit(ip, 10, 60 * 1000))) {
return NextResponse.json(
{ message: "Too many requests. Please try again later." },
{ status: 429 },
);
}

const { email, password, recaptcha_token } = await request.json();

if (!email || !password) {
Expand Down
14 changes: 14 additions & 0 deletions app/api/user/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { cloudinaryV2 } from "@/c";
import dbConnect from "@/lib/db";
import User, { IUser } from "@/models/User";
import { verifyRecaptcha } from "@/lib/recaptcha";
import { checkRateLimit, getClientIp } from "@/lib/rate-limit";

// Configure route
export const dynamic = "force-dynamic";
Expand Down Expand Up @@ -67,6 +68,19 @@ async function uploadBase64ToCloudinary(

export async function POST(request: Request) {
try {
// IP rate limiting (5 requests per minute)
const ip = getClientIp(request);
if (!(await checkRateLimit(ip, 5, 60 * 1000))) {
return NextResponse.json(
{
success: false,
message: "Too many requests. Please try again later.",
error: { code: "rate_limit_exceeded", message: "Rate limit exceeded" },
},
{ status: 429 },
);
}

const REGISTRATION_DEADLINE = new Date("2026-07-19T10:00:00+05:30");
if (new Date() > REGISTRATION_DEADLINE) {
return NextResponse.json(
Expand Down
71 changes: 42 additions & 29 deletions lib/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,49 @@
type RateLimitInfo = {
count: number;
lastReset: number;
};
import dbConnect from "@/lib/db";
import RateLimit from "@/models/RateLimit";

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

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

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

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

if (info.count >= limit) {
return false;
}
const window = Math.floor(Date.now() / windowMs);
const bucketKey = `${key}:${window}`;
const expiresAt = new Date((window + 1) * windowMs);

// Atomic per-window counter; upsert avoids a check-then-write race.
const doc = await RateLimit.findOneAndUpdate(
{ key: bucketKey },
{ $inc: { count: 1 }, $setOnInsert: { expiresAt } },
{ upsert: true, new: true },
);

info.count++;
rateLimits.set(ip, info);
return true;
return doc.count <= limit;
} catch (error) {
console.error("[rate-limit] store unreachable, allowing request:", error);
return true;
}
}
24 changes: 24 additions & 0 deletions models/RateLimit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import mongoose, { Schema, Document, Model } from "mongoose";

// One document per (key, fixed-window) bucket. TTL on expiresAt cleans them up,
// so the collection self-prunes without a cron.
export interface IRateLimit extends Document {
key: string;
count: number;
expiresAt: Date;
}

const RateLimitSchema: Schema = new Schema({
key: { type: String, required: true, unique: true },
count: { type: Number, required: true, default: 0 },
expiresAt: { type: Date, required: true },
});

// Mongo TTL monitor deletes the doc once expiresAt passes.
RateLimitSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });

const RateLimit: Model<IRateLimit> =
mongoose.models.RateLimit ||
mongoose.model<IRateLimit>("RateLimit", RateLimitSchema);

export default RateLimit;
Loading