diff --git a/.env.example b/.env.example index f9848e8..205ca51 100644 --- a/.env.example +++ b/.env.example @@ -100,3 +100,11 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000 # openssl rand -base64 32 # Keep this the same across deploys of the same environment. CSRF_SECRET= + +# Optional. How many trusted proxies sit between the client and this app and +# append to X-Forwarded-For. Rate limiting reads the client address that many +# entries from the right, so a caller cannot mint a fresh rate-limit bucket by +# seeding the header themselves. Default 1 (a single reverse proxy / platform +# edge, which is what Vercel provides). Raise it to 2 if you put an extra CDN +# in front of your own proxy. +RATE_LIMIT_TRUSTED_PROXY_HOPS=1 diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 265022d..09d3f2c 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -1,14 +1,12 @@ import { NextResponse } from "next/server"; -import { rateLimit, logSecurityEvent } from "@/lib/security"; +import { enforceRateLimit, getClientIp } from "@/lib/security"; export async function POST(request: Request) { try { - const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"; - const rl = rateLimit(`analytics:${ip}`, 60, 60_000); - if (!rl.allowed) { - logSecurityEvent("rate_limited", { ip, endpoint: "analytics" }); - return NextResponse.json({ success: false }, { status: 429 }); - } + const limited = enforceRateLimit(request, "analytics"); + if (limited) return limited; + + const ip = getClientIp(request) ?? "unknown"; const body = await request.json(); const { url, referrer, ts } = body; diff --git a/src/app/api/auth/forgot-password/route.ts b/src/app/api/auth/forgot-password/route.ts index 328e5c2..827b74b 100644 --- a/src/app/api/auth/forgot-password/route.ts +++ b/src/app/api/auth/forgot-password/route.ts @@ -1,16 +1,12 @@ import { NextRequest } from "next/server"; import { supabase } from "@/lib/supabase"; import { successResponse, errorResponse } from "@/lib/api-response"; -import { generateResetToken, rateLimit, sanitizeEmail, logSecurityEvent } from "@/lib/security"; +import { generateResetToken, enforceRateLimit, sanitizeEmail, logSecurityEvent } from "@/lib/security"; export async function POST(request: NextRequest) { try { - const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"; - const rl = rateLimit(`forgot-password:${ip}`, 3, 60_000); - if (!rl.allowed) { - logSecurityEvent("rate_limited", { ip, endpoint: "forgot-password" }); - return errorResponse("Too many requests. Please try again later.", 429); - } + const limited = enforceRateLimit(request, "forgot-password"); + if (limited) return limited; const body = await request.json(); const email = sanitizeEmail(body.email || ""); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 5dc6a6a..e78ea99 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -6,7 +6,7 @@ import { verifyTurnstileToken } from "@/lib/turnstile"; import { successResponse, errorResponse } from "@/lib/api-response"; import type { User } from "@/lib/types"; import { - rateLimit, + enforceRateLimit, checkAccountLockout, recordFailedLogin, resetLoginAttempts, @@ -17,13 +17,9 @@ import { export async function POST(request: NextRequest) { try { - // Rate limit by IP - const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"; - const rl = rateLimit(`login:${ip}`, 10, 60_000); - if (!rl.allowed) { - logSecurityEvent("rate_limited", { ip, endpoint: "login" }); - return errorResponse("Too many attempts. Please try again later.", 429); - } + // Rate limit by IP (policy + key derivation live in src/lib/security.ts) + const limited = enforceRateLimit(request, "login"); + if (limited) return limited; const body = (await readBodyWithLimit(request)) as Record; const parsed = loginSchema.safeParse(body); diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 5fee83a..f0ea43c 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -6,7 +6,7 @@ import { verifyTurnstileToken } from "@/lib/turnstile"; import { successResponse, errorResponse } from "@/lib/api-response"; import type { User } from "@/lib/types"; import { - rateLimit, + enforceRateLimit, sanitizeInput, sanitizeEmail, logSecurityEvent, @@ -16,12 +16,8 @@ import { export async function POST(request: NextRequest) { try { // Rate limit by IP - const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"; - const rl = rateLimit(`register:${ip}`, 5, 60_000); - if (!rl.allowed) { - logSecurityEvent("rate_limited", { ip, endpoint: "register" }); - return errorResponse("Too many registration attempts. Please try again later.", 429); - } + const limited = enforceRateLimit(request, "register"); + if (limited) return limited; const body = (await readBodyWithLimit(request)) as Record; const parsed = registerSchema.safeParse(body); diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts index 7e1dc20..ff5fc3c 100644 --- a/src/app/api/auth/reset-password/route.ts +++ b/src/app/api/auth/reset-password/route.ts @@ -4,19 +4,15 @@ import { hashPassword } from "@/lib/auth"; import { successResponse, errorResponse } from "@/lib/api-response"; import { validateResetToken, - rateLimit, + enforceRateLimit, logSecurityEvent, readBodyWithLimit, } from "@/lib/security"; export async function POST(request: NextRequest) { try { - const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"; - const rl = rateLimit(`reset-password:${ip}`, 5, 60_000); - if (!rl.allowed) { - logSecurityEvent("rate_limited", { ip, endpoint: "reset-password" }); - return errorResponse("Too many attempts. Please try again later.", 429); - } + const limited = enforceRateLimit(request, "reset-password"); + if (limited) return limited; const body = (await readBodyWithLimit(request)) as Record; const { token, newPassword } = body as { token?: string; newPassword?: string }; diff --git a/src/app/api/stellar/send/route.ts b/src/app/api/stellar/send/route.ts index 3ab4dab..429b708 100644 --- a/src/app/api/stellar/send/route.ts +++ b/src/app/api/stellar/send/route.ts @@ -5,7 +5,7 @@ import { buildSendTransaction, fetchRate } from "@/lib/stellar"; import { stellarSendSchema } from "@/lib/validations"; import { successResponse, errorResponse, unauthorizedResponse } from "@/lib/api-response"; import { - rateLimit, + enforceRateLimit, sanitizeInput, detectPromptInjection, logSecurityEvent, @@ -24,11 +24,8 @@ export async function POST(request: NextRequest) { } // Rate limit per user - const rl = rateLimit(`send:${user.id}`, 20, 60_000); - if (!rl.allowed) { - logSecurityEvent("rate_limited", { userId: user.id, endpoint: "stellar/send" }); - return errorResponse("Too many send requests. Please try again later.", 429); - } + const limited = enforceRateLimit(request, "stellar-send", user.id); + if (limited) return limited; const body = (await readBodyWithLimit(request)) as Record; const parsed = stellarSendSchema.safeParse(body); diff --git a/src/lib/__tests__/rate-limit.test.ts b/src/lib/__tests__/rate-limit.test.ts new file mode 100644 index 0000000..c5048ec --- /dev/null +++ b/src/lib/__tests__/rate-limit.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + getClientIp, + enforceRateLimit, + rateLimit, + resetRateLimits, + RATE_LIMIT_POLICIES, + MAX_RATE_BUCKETS, +} from "@/lib/security"; + +function req(headers: Record = {}): { headers: Headers } { + return { headers: new Headers(headers) }; +} + +beforeEach(() => { + resetRateLimits(); +}); + +describe("getClientIp", () => { + it("prefers x-real-ip, which a proxy sets and a client cannot append to", () => { + expect( + getClientIp( + req({ "x-real-ip": "203.0.113.7", "x-forwarded-for": "1.2.3.4" }) + ) + ).toBe("203.0.113.7"); + }); + + it("ignores a client-seeded leftmost X-Forwarded-For entry", () => { + // Client sends "9.9.9.9"; the proxy appends the address it actually saw. + expect(getClientIp(req({ "x-forwarded-for": "9.9.9.9, 203.0.113.7" }))).toBe( + "203.0.113.7" + ); + }); + + it("returns the single entry when only the proxy wrote the header", () => { + expect(getClientIp(req({ "x-forwarded-for": "203.0.113.7" }))).toBe( + "203.0.113.7" + ); + }); + + it("tolerates padding and empty entries", () => { + expect(getClientIp(req({ "x-forwarded-for": " 9.9.9.9 , , 203.0.113.7 " }))).toBe( + "203.0.113.7" + ); + }); + + it("returns null when neither header is present", () => { + expect(getClientIp(req())).toBeNull(); + expect(getClientIp(req({ "x-forwarded-for": "" }))).toBeNull(); + }); +}); + +describe("enforceRateLimit", () => { + it("allows exactly the policy limit, then blocks (happy path + failure path)", () => { + const { limit } = RATE_LIMIT_POLICIES.login; + const headers = { "x-real-ip": "198.51.100.1" }; + + for (let i = 0; i < limit; i += 1) { + expect(enforceRateLimit(req(headers), "login")).toBeNull(); + } + expect(enforceRateLimit(req(headers), "login")).not.toBeNull(); + }); + + it("returns the api-response error shape with a Retry-After header", async () => { + const { limit, message } = RATE_LIMIT_POLICIES["forgot-password"]; + const headers = { "x-real-ip": "198.51.100.2" }; + + for (let i = 0; i < limit; i += 1) { + enforceRateLimit(req(headers), "forgot-password"); + } + const blocked = enforceRateLimit(req(headers), "forgot-password"); + + expect(blocked).not.toBeNull(); + expect(blocked!.status).toBe(429); + expect(await blocked!.json()).toEqual({ success: false, error: message }); + + const retryAfter = Number(blocked!.headers.get("Retry-After")); + expect(retryAfter).toBeGreaterThanOrEqual(1); + expect(retryAfter).toBeLessThanOrEqual(60); + }); + + it("does NOT hand out a fresh bucket when the caller rotates X-Forwarded-For", () => { + // Regression: reading the leftmost entry let one caller mint a new bucket + // per request and bypass the limit entirely. + const { limit } = RATE_LIMIT_POLICIES.login; + let blocked = false; + + for (let i = 0; i < limit + 5; i += 1) { + const spoofed = req({ "x-forwarded-for": `10.0.0.${i}, 198.51.100.3` }); + if (enforceRateLimit(spoofed, "login")) blocked = true; + } + expect(blocked).toBe(true); + }); + + it("keeps different callers in separate buckets", () => { + const { limit } = RATE_LIMIT_POLICIES.login; + for (let i = 0; i < limit; i += 1) { + enforceRateLimit(req({ "x-real-ip": "198.51.100.4" }), "login"); + } + expect(enforceRateLimit(req({ "x-real-ip": "198.51.100.4" }), "login")).not.toBeNull(); + expect(enforceRateLimit(req({ "x-real-ip": "198.51.100.5" }), "login")).toBeNull(); + }); + + it("keeps policies independent of each other for the same caller", () => { + const headers = { "x-real-ip": "198.51.100.6" }; + for (let i = 0; i < RATE_LIMIT_POLICIES["forgot-password"].limit; i += 1) { + enforceRateLimit(req(headers), "forgot-password"); + } + expect(enforceRateLimit(req(headers), "forgot-password")).not.toBeNull(); + expect(enforceRateLimit(req(headers), "login")).toBeNull(); + }); + + it("keys user-scoped policies by user id, not by address", () => { + const { limit } = RATE_LIMIT_POLICIES["stellar-send"]; + const shared = req({ "x-real-ip": "198.51.100.7" }); + + for (let i = 0; i < limit; i += 1) { + expect(enforceRateLimit(shared, "stellar-send", "user-a")).toBeNull(); + } + expect(enforceRateLimit(shared, "stellar-send", "user-a")).not.toBeNull(); + // Same address, different user: unaffected. + expect(enforceRateLimit(shared, "stellar-send", "user-b")).toBeNull(); + }); + + it("throws if a user-scoped policy is used without a subject", () => { + expect(() => enforceRateLimit(req(), "stellar-send")).toThrow(/user-scoped/); + }); + + it("still limits callers whose address cannot be resolved", () => { + const { limit } = RATE_LIMIT_POLICIES.login; + for (let i = 0; i < limit; i += 1) { + expect(enforceRateLimit(req(), "login")).toBeNull(); + } + expect(enforceRateLimit(req(), "login")).not.toBeNull(); + }); +}); + +describe("rate-limit bucket store", () => { + it("stays bounded when flooded with distinct keys", () => { + for (let i = 0; i < MAX_RATE_BUCKETS + 500; i += 1) { + rateLimit(`flood:${i}`, 5, 60_000); + } + // Buckets are not directly observable; the cap is enforced by evicting the + // entries closest to expiry, so the earliest keys must have been dropped + // and therefore start a fresh window. + expect(rateLimit("flood:0", 1, 60_000).allowed).toBe(true); + }); + + it("declares a usable policy for every route name", () => { + for (const [name, policy] of Object.entries(RATE_LIMIT_POLICIES)) { + expect(policy.limit, name).toBeGreaterThan(0); + expect(policy.windowMs, name).toBeGreaterThan(0); + expect(policy.message.length, name).toBeGreaterThan(0); + expect(["ip", "user"], name).toContain(policy.scope); + } + }); +}); diff --git a/src/lib/security.ts b/src/lib/security.ts index 1f13767..d7f6997 100644 --- a/src/lib/security.ts +++ b/src/lib/security.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import crypto from "crypto"; +import { errorResponse } from "@/lib/api-response"; /** * ── Security utilities for RemitX ──────────────────────────────────────── @@ -11,6 +12,28 @@ import crypto from "crypto"; // For production with multiple instances, replace with Redis/Upstash. const rateBuckets = new Map(); +// Hard cap so a flood of distinct keys (spoofed headers, many users) cannot +// grow this map without bound. Expired buckets are swept first; if the map is +// still at the cap, the buckets closest to expiry are evicted. +export const MAX_RATE_BUCKETS = 10_000; + +function pruneRateBuckets(now: number): void { + for (const [key, bucket] of rateBuckets) { + if (bucket.resetAt <= now) { + rateBuckets.delete(key); + } + } + if (rateBuckets.size < MAX_RATE_BUCKETS) return; + + const byExpiry = [...rateBuckets.entries()].sort( + (a, b) => a[1].resetAt - b[1].resetAt + ); + const overflow = rateBuckets.size - MAX_RATE_BUCKETS + 1; + for (let i = 0; i < overflow; i += 1) { + rateBuckets.delete(byExpiry[i][0]); + } +} + export function rateLimit( key: string, limit: number, @@ -20,6 +43,9 @@ export function rateLimit( const bucket = rateBuckets.get(key); if (!bucket || bucket.resetAt <= now) { + if (!bucket && rateBuckets.size >= MAX_RATE_BUCKETS) { + pruneRateBuckets(now); + } rateBuckets.set(key, { count: 1, resetAt: now + windowMs }); return { allowed: true, retryAfterMs: 0 }; } @@ -32,6 +58,163 @@ export function rateLimit( return { allowed: true, retryAfterMs: 0 }; } +/** Test-only helper: drop all rate-limit state. */ +export function resetRateLimits(): void { + rateBuckets.clear(); +} + +// ── Client IP resolution ──────────────────────────────────────────────── +// `X-Forwarded-For` is a list the client can seed: anything the caller sends +// stays in the header and proxies only *append* to it. Reading the leftmost +// entry therefore reads an attacker-controlled value, which lets a caller mint +// a fresh rate-limit bucket per request simply by rotating the header. The +// rightmost entry is the address the nearest trusted proxy actually saw. +// +// RATE_LIMIT_TRUSTED_PROXY_HOPS = how many trusted proxies sit between the +// client and this app and append to the header (default 1 — a single reverse +// proxy / platform edge). The client address is the Nth entry from the right. +const DEFAULT_TRUSTED_PROXY_HOPS = 1; + +function trustedProxyHops(): number { + const parsed = Number(process.env.RATE_LIMIT_TRUSTED_PROXY_HOPS); + if (!Number.isFinite(parsed) || parsed < 1) return DEFAULT_TRUSTED_PROXY_HOPS; + return Math.floor(parsed); +} + +/** + * Resolve the caller's address, or `null` when no proxy header is present + * (direct connection, or a misconfigured proxy that forwards neither header). + */ +export function getClientIp(request: { headers: Headers }): string | null { + // Set by the proxy itself and single-valued, so it cannot be appended to. + const realIp = request.headers.get("x-real-ip")?.trim(); + if (realIp) return realIp; + + const hops = (request.headers.get("x-forwarded-for") || "") + .split(",") + .map((hop) => hop.trim()) + .filter(Boolean); + if (hops.length === 0) return null; + + const index = hops.length - trustedProxyHops(); + return hops[index] ?? hops[0]; +} + +// ── Declarative per-route rate-limit policies ─────────────────────────── +// Single source of truth: routes name a policy instead of repeating a limit, +// a window and their own copy of the IP-extraction logic. +export type RateLimitScope = "ip" | "user"; + +export interface RateLimitPolicy { + /** Requests allowed per window. */ + limit: number; + windowMs: number; + /** What the bucket is keyed by: caller address, or authenticated user id. */ + scope: RateLimitScope; + /** Message returned on 429, in the `api-response` error shape. */ + message: string; +} + +const POLICIES = { + login: { + limit: 10, + windowMs: 60_000, + scope: "ip", + message: "Too many attempts. Please try again later.", + }, + register: { + limit: 5, + windowMs: 60_000, + scope: "ip", + message: "Too many registration attempts. Please try again later.", + }, + "forgot-password": { + limit: 3, + windowMs: 60_000, + scope: "ip", + message: "Too many requests. Please try again later.", + }, + "reset-password": { + limit: 5, + windowMs: 60_000, + scope: "ip", + message: "Too many attempts. Please try again later.", + }, + analytics: { + limit: 60, + windowMs: 60_000, + scope: "ip", + message: "Too many requests. Please try again later.", + }, + "stellar-send": { + limit: 20, + windowMs: 60_000, + scope: "user", + message: "Too many send requests. Please try again later.", + }, +} as const; + +export type RateLimitPolicyName = keyof typeof POLICIES; + +export const RATE_LIMIT_POLICIES: Record = + POLICIES; + +// When no address can be resolved, all such callers share one bucket. That is +// deliberately fail-closed (better than handing every caller its own bucket and +// silently disabling the limit), but it also throttles unrelated callers, so +// surface it once per process instead of failing quietly. +const UNRESOLVED_IP = "unresolved"; +let warnedAboutUnresolvedIp = false; + +/** + * Apply a named policy. Returns `null` when the request may proceed, or a ready + * 429 response (api-response error shape + `Retry-After`) when it may not. + * + * `subject` is required for `user`-scoped policies and ignored otherwise. + */ +export function enforceRateLimit( + request: { headers: Headers }, + policyName: RateLimitPolicyName, + subject?: string | null +): NextResponse | null { + const policy = RATE_LIMIT_POLICIES[policyName]; + + let subjectKey: string; + if (policy.scope === "user") { + if (!subject) { + throw new Error( + `Rate-limit policy "${policyName}" is user-scoped but no subject was provided` + ); + } + subjectKey = `user:${subject}`; + } else { + const ip = getClientIp(request); + if (!ip && !warnedAboutUnresolvedIp) { + warnedAboutUnresolvedIp = true; + logSecurityEvent("rate_limit_unresolved_ip", { endpoint: policyName }); + } + subjectKey = `ip:${ip ?? UNRESOLVED_IP}`; + } + + const result = rateLimit( + `${policyName}:${subjectKey}`, + policy.limit, + policy.windowMs + ); + if (result.allowed) return null; + + const retryAfterSeconds = Math.max(1, Math.ceil(result.retryAfterMs / 1000)); + logSecurityEvent("rate_limited", { + endpoint: policyName, + subject: subjectKey, + retryAfterSeconds, + }); + + const response = errorResponse(policy.message, 429); + response.headers.set("Retry-After", String(retryAfterSeconds)); + return response; +} + // ── CSRF token generation & validation ────────────────────────────────── const CSRF_SECRET = process.env.CSRF_SECRET || "dev-csrf-secret-change-in-production"; @@ -124,6 +307,7 @@ export type SecurityEventType = | "password_reset_complete" | "csrf_blocked" | "rate_limited" + | "rate_limit_unresolved_ip" | "invalid_token" | "unauthorized_access" | "upload_blocked"