diff --git a/packages/core/src/middleware/index.ts b/packages/core/src/middleware/index.ts index 96abeb470..091deca07 100644 --- a/packages/core/src/middleware/index.ts +++ b/packages/core/src/middleware/index.ts @@ -16,6 +16,9 @@ export { AuthManager, requireAuth, requireRole, optionalAuth } from './auth' // Metrics middleware export { metricsMiddleware } from './metrics' +// Rate limiting middleware +export { rateLimit } from './rate-limit' + // Re-export types and functions that are referenced but implemented in monolith // These are placeholder exports to maintain API compatibility export type Permission = string diff --git a/packages/core/src/middleware/rate-limit.ts b/packages/core/src/middleware/rate-limit.ts new file mode 100644 index 000000000..1a8cf115c --- /dev/null +++ b/packages/core/src/middleware/rate-limit.ts @@ -0,0 +1,72 @@ +import { Context, Next } from 'hono' + +interface RateLimitOptions { + max: number + windowMs: number + keyPrefix: string +} + +interface RateLimitEntry { + count: number + resetAt: number +} + +/** + * KV-based sliding window rate limiter middleware. + * Gracefully skips if CACHE_KV binding is not available. + */ +export function rateLimit(options: RateLimitOptions) { + const { max, windowMs, keyPrefix } = options + + return async (c: Context, next: Next) => { + const kv = (c.env as any)?.CACHE_KV + if (!kv) { + // No KV binding available — skip rate limiting + return await next() + } + + const ip = c.req.header('cf-connecting-ip') || c.req.header('x-forwarded-for') || 'unknown' + const key = `ratelimit:${keyPrefix}:${ip}` + + try { + const now = Date.now() + const stored = await kv.get(key, 'json') as RateLimitEntry | null + + let entry: RateLimitEntry + if (stored && stored.resetAt > now) { + entry = stored + } else { + entry = { count: 0, resetAt: now + windowMs } + } + + entry.count++ + + // Calculate TTL in seconds (KV expiration) + const ttlSeconds = Math.ceil((entry.resetAt - now) / 1000) + + if (entry.count > max) { + // Store the updated count even when rejecting + await kv.put(key, JSON.stringify(entry), { expirationTtl: Math.max(ttlSeconds, 1) }) + + const retryAfter = Math.ceil((entry.resetAt - now) / 1000) + c.header('Retry-After', String(retryAfter)) + c.header('X-RateLimit-Limit', String(max)) + c.header('X-RateLimit-Remaining', '0') + c.header('X-RateLimit-Reset', String(Math.ceil(entry.resetAt / 1000))) + return c.json({ error: 'Too many requests. Please try again later.' }, 429) + } + + await kv.put(key, JSON.stringify(entry), { expirationTtl: Math.max(ttlSeconds, 1) }) + + c.header('X-RateLimit-Limit', String(max)) + c.header('X-RateLimit-Remaining', String(max - entry.count)) + c.header('X-RateLimit-Reset', String(Math.ceil(entry.resetAt / 1000))) + + return await next() + } catch (error) { + // Rate limiting should never break the app + console.error('Rate limiter error (non-fatal):', error) + return await next() + } + } +} diff --git a/packages/core/src/routes/auth.ts b/packages/core/src/routes/auth.ts index c36513ff2..f8566eadc 100644 --- a/packages/core/src/routes/auth.ts +++ b/packages/core/src/routes/auth.ts @@ -3,7 +3,7 @@ import { Hono } from 'hono' import { z } from 'zod' import { setCookie } from 'hono/cookie' import { html } from 'hono/html' -import { AuthManager, requireAuth } from '../middleware' +import { AuthManager, requireAuth, rateLimit } from '../middleware' import { renderLoginPage, LoginPageData } from '../templates/pages/auth-login.template' import { renderRegisterPage, RegisterPageData } from '../templates/pages/auth-register.template' import { getCacheService, CACHE_CONFIGS } from '../services' @@ -71,6 +71,7 @@ const loginSchema = z.object({ // Register new user authRoutes.post('/register', + rateLimit({ max: 3, windowMs: 60 * 1000, keyPrefix: 'register' }), async (c) => { try { const db = c.env.DB @@ -186,7 +187,9 @@ authRoutes.post('/register', ) // Login user -authRoutes.post('/login', async (c) => { +authRoutes.post('/login', + rateLimit({ max: 5, windowMs: 60 * 1000, keyPrefix: 'login' }), + async (c) => { try { const body = await c.req.json() const validation = loginSchema.safeParse(body) @@ -341,7 +344,9 @@ authRoutes.post('/refresh', requireAuth(), async (c) => { }) // Form-based registration handler (for HTML forms) -authRoutes.post('/register/form', async (c) => { +authRoutes.post('/register/form', + rateLimit({ max: 3, windowMs: 60 * 1000, keyPrefix: 'register' }), + async (c) => { try { const db = c.env.DB @@ -470,7 +475,9 @@ authRoutes.post('/register/form', async (c) => { }) // Form-based login handler (for HTML forms) -authRoutes.post('/login/form', async (c) => { +authRoutes.post('/login/form', + rateLimit({ max: 5, windowMs: 60 * 1000, keyPrefix: 'login' }), + async (c) => { try { const formData = await c.req.formData() const email = formData.get('email') as string @@ -561,7 +568,9 @@ authRoutes.post('/login/form', async (c) => { }) // Test seeding endpoint (only for development/testing) -authRoutes.post('/seed-admin', async (c) => { +authRoutes.post('/seed-admin', + rateLimit({ max: 2, windowMs: 60 * 1000, keyPrefix: 'seed-admin' }), + async (c) => { try { const db = c.env.DB @@ -907,7 +916,9 @@ authRoutes.post('/accept-invitation', async (c) => { }) // Request password reset -authRoutes.post('/request-password-reset', async (c) => { +authRoutes.post('/request-password-reset', + rateLimit({ max: 3, windowMs: 15 * 60 * 1000, keyPrefix: 'password-reset' }), + async (c) => { try { const formData = await c.req.formData() const email = formData.get('email')?.toString()?.trim()?.toLowerCase()