Skip to content

Commit cf0f8cf

Browse files
authored
Merge pull request #229 from petersdt/feat/api-rate-limiting
feat: add rate limiting to API routes using edge middleware and lru-c…
2 parents ea2ffda + 30ace3f commit cf0f8cf

File tree

7 files changed

+204
-54
lines changed

7 files changed

+204
-54
lines changed

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,16 @@ MIT
415415

416416
- This repository contains an OpenAPI descriptor for the current v1 API at `openapi.yaml` (root). The `servers` entry points to the `/api/v1` base path. Update `openapi.yaml` as you add endpoints.
417417

418+
**Rate Limiting**
419+
420+
- All `/api/*` routes are protected by a global rate limiter using in-memory `lru-cache`.
421+
- **Limits (per IP/session):**
422+
- **Auth endpoints (`/api/auth/*`)**: 10 requests per minute
423+
- **Write endpoints (`POST`, `PUT`, `DELETE`, `PATCH`)**: 50 requests per minute
424+
- **General read endpoints (`GET`)**: 100 requests per minute
425+
- **Whitelisted routes**: `/api/health` is exempt from rate limiting to ensure uptime monitoring is not blocked.
426+
- **Behavior**: When a limit is exceeded, the server responds with a `429 Too Many Requests` status code and a `Retry-After` header indicating the number of seconds until the rate limit resets. Rate limit information is also provided in the response headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`).
427+
418428
**Non-breaking guarantee**
419429

420430
- To meet the repository's backward-compatibility requirement, we keep `/api/*` behavior unchanged by rewriting it to `/api/v1/*`. This preserves compatibility for existing consumers.

app/api/auth/login/route.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
import { NextResponse } from 'next/server';
22
import { Keypair } from '@stellar/stellar-sdk';
33
import { getAndClearNonce } from '@/lib/auth-cache';
4+
import {
5+
createSession,
6+
getSessionCookieHeader,
7+
} from '../../../../lib/session';
8+
9+
export const dynamic = 'force-dynamic';
10+
11+
/**
12+
* Wallet-based auth flow:
13+
* 1. Frontend: user connects wallet (e.g. Freighter), gets address.
14+
* 2. Frontend: build a nonce message (e.g. "Sign in to Remitwise at {timestamp}").
15+
* 3. Frontend: sign message with wallet
16+
* 4. Frontend: POST /api/auth/login with { address, signature }
17+
* 5. Backend: verify with Keypair using stored server memory nonce; create encrypted session cookie.
18+
*/
419

520
export async function POST(request: Request) {
621
try {
@@ -42,11 +57,22 @@ export async function POST(request: Request) {
4257
);
4358
}
4459

45-
const response = NextResponse.json({ success: true, token: 'mock-session-token' });
46-
response.cookies.set('session', 'mock-session-cookie', { httpOnly: true, path: '/' });
47-
return response;
60+
const sealed = await createSession(address);
61+
const cookieHeader = getSessionCookieHeader(sealed);
62+
63+
return new Response(
64+
JSON.stringify({ ok: true, address }),
65+
{
66+
status: 200,
67+
headers: {
68+
'Content-Type': 'application/json',
69+
'Set-Cookie': cookieHeader,
70+
},
71+
}
72+
);
4873

49-
} catch {
50-
return NextResponse.json({ error: 'Bad Request' }, { status: 400 });
74+
} catch (err) {
75+
console.error('Login error:', err);
76+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
5177
}
5278
}

middleware.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { NextResponse } from 'next/server';
2+
import type { NextRequest } from 'next/server';
3+
import { LRUCache } from 'lru-cache';
4+
5+
// Configuration for Rate Limiting Limits
6+
const RATE_LIMITS = {
7+
auth: 10, // 10 req/min for /api/auth/*
8+
write: 50, // 50 req/min for POST/PUT/DELETE /api/*
9+
general: 100, // 100 req/min for GET /api/*
10+
};
11+
12+
// Rate limiting cache: max 10,000 IPs, items expire in 1 minute
13+
const rateLimitCache = new LRUCache<string, { count: number; expiresAt: number }>({
14+
max: 10000,
15+
ttl: 60 * 1000, // 1 minute
16+
});
17+
18+
export function middleware(request: NextRequest) {
19+
const { pathname } = request.nextUrl;
20+
21+
// Extract IP or fallback for key
22+
const forwardedFor = request.headers.get('x-forwarded-for');
23+
let ip = '127.0.0.1';
24+
if (forwardedFor) {
25+
ip = forwardedFor.split(',')[0].trim();
26+
} else {
27+
// Fallback for local dev or when header is missing
28+
const remoteAddr = request.headers.get('x-real-ip');
29+
if (remoteAddr) {
30+
ip = remoteAddr;
31+
}
32+
}
33+
34+
// 0. Whitelist test environments (Playwright E2E)
35+
if (
36+
request.headers.get('x-playwright-test') === 'true' &&
37+
process.env.NODE_ENV !== 'production'
38+
) {
39+
return NextResponse.next();
40+
}
41+
42+
// 1. Whitelist Health Check
43+
if (pathname === '/api/health' || pathname.startsWith('/api/health/')) {
44+
return NextResponse.next();
45+
}
46+
47+
// Determine Rate Limit based on route & method
48+
let limit = RATE_LIMITS.general;
49+
50+
if (pathname.startsWith('/api/auth/')) {
51+
limit = RATE_LIMITS.auth;
52+
} else if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(request.method)) {
53+
limit = RATE_LIMITS.write;
54+
}
55+
56+
// Construct Cache Key
57+
// e.g., '127.0.0.1:/api/auth' (grouping auth limits separately if desired,
58+
// but let's just do by IP + limit Type to be safe, or just IP.
59+
// Standard is usually just "IP:auth", "IP:write", "IP:general")
60+
let limitType = 'general';
61+
if (pathname.startsWith('/api/auth/')) {
62+
limitType = 'auth';
63+
} else if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(request.method)) {
64+
limitType = 'write';
65+
}
66+
67+
const cacheKey = `${ip}:${limitType}`;
68+
69+
// Check and update cache
70+
const now = Date.now();
71+
const tokenRecord = rateLimitCache.get(cacheKey) || { count: 0, expiresAt: now + 60000 };
72+
73+
if (now > tokenRecord.expiresAt) {
74+
tokenRecord.count = 0;
75+
tokenRecord.expiresAt = now + 60000;
76+
}
77+
78+
tokenRecord.count += 1;
79+
rateLimitCache.set(cacheKey, tokenRecord);
80+
81+
if (tokenRecord.count > limit) {
82+
// Exceeded limit
83+
const retryAfter = Math.ceil((tokenRecord.expiresAt - now) / 1000).toString();
84+
85+
return new NextResponse(
86+
JSON.stringify({
87+
error: 'Too Many Requests',
88+
message: 'Rate limit exceeded.',
89+
}),
90+
{
91+
status: 429,
92+
headers: {
93+
'Content-Type': 'application/json',
94+
'Retry-After': retryAfter,
95+
'X-RateLimit-Limit': limit.toString(),
96+
'X-RateLimit-Remaining': '0',
97+
'X-RateLimit-Reset': tokenRecord.expiresAt.toString(),
98+
},
99+
}
100+
);
101+
}
102+
103+
// Allow Request
104+
const response = NextResponse.next();
105+
response.headers.set('X-RateLimit-Limit', limit.toString());
106+
response.headers.set('X-RateLimit-Remaining', (limit - tokenRecord.count).toString());
107+
response.headers.set('X-RateLimit-Reset', tokenRecord.expiresAt.toString());
108+
109+
return response;
110+
}
111+
112+
// Config ensures middleware only runs on API routes
113+
export const config = {
114+
matcher: '/api/:path*',
115+
};

0 commit comments

Comments
 (0)