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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 5 additions & 7 deletions src/app/api/analytics/route.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
10 changes: 3 additions & 7 deletions src/app/api/auth/forgot-password/route.ts
Original file line number Diff line number Diff line change
@@ -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 || "");
Expand Down
12 changes: 4 additions & 8 deletions src/app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string, unknown>;
const parsed = loginSchema.safeParse(body);
Expand Down
10 changes: 3 additions & 7 deletions src/app/api/auth/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string, unknown>;
const parsed = registerSchema.safeParse(body);
Expand Down
10 changes: 3 additions & 7 deletions src/app/api/auth/reset-password/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
const { token, newPassword } = body as { token?: string; newPassword?: string };
Expand Down
9 changes: 3 additions & 6 deletions src/app/api/stellar/send/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string, unknown>;
const parsed = stellarSendSchema.safeParse(body);
Expand Down
157 changes: 157 additions & 0 deletions src/lib/__tests__/rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}): { 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);
}
});
});
Loading