From 16c5e47ee077814578124c8a1f3b7450e1eb3511 Mon Sep 17 00:00:00 2001 From: roshankumar0036singh Date: Mon, 1 Jun 2026 20:56:08 +0530 Subject: [PATCH 1/2] feat: add rate limiting to OAuth endpoints - Create oauthRateLimit plugin with per-IP bucket strategy - Apply stricter rate limits to OAuth callback endpoints (5 req/min) - Apply moderate rate limits to OAuth start endpoints (10 req/min) - Prevent brute force attacks and token guessing - Add per-user fallback for authenticated requests - Fixes: No Rate Limiting on OAuth Endpoints --- apps/backend/src/app.ts | 4 ++ apps/backend/src/plugins/oauthRateLimit.ts | 65 ++++++++++++++++++++++ apps/backend/src/routes/auth.ts | 8 +-- 3 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 apps/backend/src/plugins/oauthRateLimit.ts 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..c8bfbf89 --- /dev/null +++ b/apps/backend/src/plugins/oauthRateLimit.ts @@ -0,0 +1,65 @@ +import type { FastifyInstance, FastifyRequest } 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 + */ +export const oauthRateLimitPlugin = fastifyPlugin(async (app: FastifyInstance) => { + // Rate limit for OAuth callback endpoints (stricter) + const callbackLimiter = rateLimit.createStore({ + max: 5, + timeWindow: '1 minute', + cache: 10000, + skipOnError: true, + }); + + // Rate limit for OAuth start endpoints (moderate) + 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: any + ) => { + // Use user ID if authenticated, otherwise use IP + const key = (request.user as any)?.id || request.ip; + const limited = await callbackLimiter.incr(key); + + if (limited > 5) { + return reply.status(429).send({ + error: 'Too many authentication attempts. Please try again later.', + retryAfter: 60, + }); + } + }; + + // Middleware for OAuth start rate limiting (per IP) + const startRateLimitMiddleware = async ( + request: FastifyRequest, + reply: any + ) => { + const key = `oauth_start:${request.ip}`; + const limited = await startLimiter.incr(key); + + if (limited > 10) { + return reply.status(429).send({ + error: 'Too many OAuth requests. Please try again later.', + retryAfter: 60, + }); + } + }; + + // Export middleware for use in auth routes + app.decorate('oauthCallbackRateLimit', callbackRateLimitMiddleware); + app.decorate('oauthStartRateLimit', startRateLimitMiddleware); +}); diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index c14949e1..94aa5e8d 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 as any] }, 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 as any] }, 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 as any] }, 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 as any] }, async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { const { code, state } = request.query; const storedState = request.cookies?.oauth_state; From ea15a7aefdac44ef10ee3b380e11b0b683a33233 Mon Sep 17 00:00:00 2001 From: roshankumar0036singh Date: Mon, 1 Jun 2026 21:00:54 +0530 Subject: [PATCH 2/2] fix: improve OAuth rate limiting implementation - Fix off-by-one error: use >= instead of > for count checks - Add Retry-After HTTP header to 429 responses (standard approach) - Add type declaration merging for decorator properties - Remove as any casts from auth routes - Document cache:10000 reasoning in comments --- apps/backend/src/plugins/oauthRateLimit.ts | 38 +++++++++++++++------- apps/backend/src/routes/auth.ts | 8 ++--- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/apps/backend/src/plugins/oauthRateLimit.ts b/apps/backend/src/plugins/oauthRateLimit.ts index c8bfbf89..5c2128ca 100644 --- a/apps/backend/src/plugins/oauthRateLimit.ts +++ b/apps/backend/src/plugins/oauthRateLimit.ts @@ -1,4 +1,4 @@ -import type { FastifyInstance, FastifyRequest } from 'fastify'; +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import fastifyPlugin from 'fastify-plugin'; import rateLimit from '@fastify/rate-limit'; @@ -9,8 +9,18 @@ import rateLimit from '@fastify/rate-limit'; * - 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', @@ -19,6 +29,7 @@ export const oauthRateLimitPlugin = fastifyPlugin(async (app: FastifyInstance) = }); // 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', @@ -29,16 +40,17 @@ export const oauthRateLimitPlugin = fastifyPlugin(async (app: FastifyInstance) = // Middleware for OAuth callback rate limiting (per IP, with user-aware fallback) const callbackRateLimitMiddleware = async ( request: FastifyRequest, - reply: any + reply: FastifyReply ) => { // Use user ID if authenticated, otherwise use IP const key = (request.user as any)?.id || request.ip; - const limited = await callbackLimiter.incr(key); + const count = await callbackLimiter.incr(key); - if (limited > 5) { + // 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.', - retryAfter: 60, }); } }; @@ -46,20 +58,24 @@ export const oauthRateLimitPlugin = fastifyPlugin(async (app: FastifyInstance) = // Middleware for OAuth start rate limiting (per IP) const startRateLimitMiddleware = async ( request: FastifyRequest, - reply: any + reply: FastifyReply ) => { const key = `oauth_start:${request.ip}`; - const limited = await startLimiter.incr(key); + const count = await startLimiter.incr(key); - if (limited > 10) { + // 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.', - retryAfter: 60, }); } }; // Export middleware for use in auth routes - app.decorate('oauthCallbackRateLimit', callbackRateLimitMiddleware); - app.decorate('oauthStartRateLimit', startRateLimitMiddleware); + 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 94aa5e8d..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', { preHandler: [app.oauthStartRateLimit as any] }, 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', { preHandler: [app.oauthCallbackRateLimit as any] }, 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', { preHandler: [app.oauthStartRateLimit as any] }, 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', { preHandler: [app.oauthCallbackRateLimit as any] }, 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;