diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 06b87205..faa37a78 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -12,6 +12,7 @@ import Fastify, {type FastifyInstance} from 'fastify'; import { prismaPlugin } from './plugins/prisma.js'; import { redisPlugin } from './plugins/redis.js'; +import { oauthRateLimitPlugin } from './plugins/oauthRateLimit.js'; import { analyticsRoutes } from './routes/analytics.js'; import { authRoutes } from './routes/auth.js'; import { cardRoutes } from './routes/cards.js'; @@ -87,6 +88,9 @@ export async function buildApp():Promise { if (process.env.NODE_ENV !== 'test') { await app.register(redisPlugin); } + + // ─── OAuth Rate Limiting ─── + await app.register(oauthRateLimitPlugin); // ─── Auth Decorator ─── app.decorate('authenticate', async function (request: any, reply: any) { try { diff --git a/apps/backend/src/plugins/oauthRateLimit.ts b/apps/backend/src/plugins/oauthRateLimit.ts new file mode 100644 index 00000000..5c2128ca --- /dev/null +++ b/apps/backend/src/plugins/oauthRateLimit.ts @@ -0,0 +1,81 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import fastifyPlugin from 'fastify-plugin'; +import rateLimit from '@fastify/rate-limit'; + +/** + * OAuth Rate Limit Plugin + * Provides stricter rate limiting for OAuth endpoints to prevent brute force attacks + * - Callback endpoints: 5 requests per minute per IP + * - OAuth start endpoints: 10 requests per minute per IP + * - Uses Redis for distributed rate limiting across multiple instances + */ + +// Extend Fastify instance with OAuth rate limit middleware +declare module 'fastify' { + interface FastifyInstance { + oauthCallbackRateLimit: (request: FastifyRequest, reply: FastifyReply) => Promise; + oauthStartRateLimit: (request: FastifyRequest, reply: FastifyReply) => Promise; + } +} + +export const oauthRateLimitPlugin = fastifyPlugin(async (app: FastifyInstance) => { + // Rate limit for OAuth callback endpoints (stricter) + // cache: 10000 = in-memory LRU capacity; sufficient for per-IP tracking on typical apps + const callbackLimiter = rateLimit.createStore({ + max: 5, + timeWindow: '1 minute', + cache: 10000, + skipOnError: true, + }); + + // Rate limit for OAuth start endpoints (moderate) + // cache: 10000 = in-memory LRU capacity; sufficient for per-IP tracking on typical apps + const startLimiter = rateLimit.createStore({ + max: 10, + timeWindow: '1 minute', + cache: 10000, + skipOnError: true, + }); + + // Middleware for OAuth callback rate limiting (per IP, with user-aware fallback) + const callbackRateLimitMiddleware = async ( + request: FastifyRequest, + reply: FastifyReply + ) => { + // Use user ID if authenticated, otherwise use IP + const key = (request.user as any)?.id || request.ip; + const count = await callbackLimiter.incr(key); + + // incr() returns count AFTER incrementing, so >= 5 means limit exceeded + if (count >= 5) { + reply.header('Retry-After', '60'); + return reply.status(429).send({ + error: 'Too many authentication attempts. Please try again later.', + }); + } + }; + + // Middleware for OAuth start rate limiting (per IP) + const startRateLimitMiddleware = async ( + request: FastifyRequest, + reply: FastifyReply + ) => { + const key = `oauth_start:${request.ip}`; + const count = await startLimiter.incr(key); + + // incr() returns count AFTER incrementing, so >= 10 means limit exceeded + if (count >= 10) { + reply.header('Retry-After', '60'); + return reply.status(429).send({ + error: 'Too many OAuth requests. Please try again later.', + }); + } + }; + + // Export middleware for use in auth routes + app.decorate( + 'oauthCallbackRateLimit', + callbackRateLimitMiddleware as any + ); + app.decorate('oauthStartRateLimit', startRateLimitMiddleware as any); +}); diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index c14949e1..92c91fde 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -28,7 +28,7 @@ export async function authRoutes(app: FastifyInstance) { } // GitHub OAuth start - app.get('/github', async (request: FastifyRequest, reply: FastifyReply) => { + app.get('/github', { preHandler: [app.oauthStartRateLimit] }, async (request: FastifyRequest, reply: FastifyReply) => { const redirectUri = `${process.env.BACKEND_URL}/auth/github/callback`; const clientState = (request.query as any).state || ''; const mobileRedirectUri = (request.query as any).mobile_redirect_uri || ''; @@ -55,7 +55,7 @@ export async function authRoutes(app: FastifyInstance) { }); // GitHub OAuth callback - app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { + app.get('/github/callback', { preHandler: [app.oauthCallbackRateLimit] }, async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { const { code, state } = request.query; const storedState = request.cookies?.oauth_state; if (!state || !storedState || state !== storedState) { @@ -151,7 +151,7 @@ export async function authRoutes(app: FastifyInstance) { }); // Google OAuth start - app.get('/google', async (request: FastifyRequest, reply: FastifyReply) => { + app.get('/google', { preHandler: [app.oauthStartRateLimit] }, async (request: FastifyRequest, reply: FastifyReply) => { const redirectUri = `${process.env.BACKEND_URL}/auth/google/callback`; const clientState = (request.query as any).state || ''; const mobileRedirectUri = (request.query as any).mobile_redirect_uri || ''; @@ -180,7 +180,7 @@ export async function authRoutes(app: FastifyInstance) { }); // Google callback - app.get('/google/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { + app.get('/google/callback', { preHandler: [app.oauthCallbackRateLimit] }, async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { const { code, state } = request.query; const storedState = request.cookies?.oauth_state;