Skip to content

Commit b0028ac

Browse files
committed
security: add rate limiting to auth endpoints
- Create KV-based sliding window rate limiter middleware - Apply rate limits to auth endpoints: - Login (JSON + form): 5 requests/minute - Register (JSON + form): 3 requests/minute - Password reset: 3 requests/15 minutes - Seed admin: 2 requests/minute - Includes Retry-After and X-RateLimit-* response headers - Graceful degradation: skips rate limiting if CACHE_KV not available - Export rateLimit from middleware/index.ts Fixes VULN-004
1 parent 2250de9 commit b0028ac

3 files changed

Lines changed: 92 additions & 6 deletions

File tree

packages/core/src/middleware/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ export { AuthManager, requireAuth, requireRole, optionalAuth } from './auth'
1616
// Metrics middleware
1717
export { metricsMiddleware } from './metrics'
1818

19+
// Rate limiting middleware
20+
export { rateLimit } from './rate-limit'
21+
1922
// Re-export types and functions that are referenced but implemented in monolith
2023
// These are placeholder exports to maintain API compatibility
2124
export type Permission = string
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { Context, Next } from 'hono'
2+
3+
interface RateLimitOptions {
4+
max: number
5+
windowMs: number
6+
keyPrefix: string
7+
}
8+
9+
interface RateLimitEntry {
10+
count: number
11+
resetAt: number
12+
}
13+
14+
/**
15+
* KV-based sliding window rate limiter middleware.
16+
* Gracefully skips if CACHE_KV binding is not available.
17+
*/
18+
export function rateLimit(options: RateLimitOptions) {
19+
const { max, windowMs, keyPrefix } = options
20+
21+
return async (c: Context, next: Next) => {
22+
const kv = (c.env as any)?.CACHE_KV
23+
if (!kv) {
24+
// No KV binding available — skip rate limiting
25+
return await next()
26+
}
27+
28+
const ip = c.req.header('cf-connecting-ip') || c.req.header('x-forwarded-for') || 'unknown'
29+
const key = `ratelimit:${keyPrefix}:${ip}`
30+
31+
try {
32+
const now = Date.now()
33+
const stored = await kv.get(key, 'json') as RateLimitEntry | null
34+
35+
let entry: RateLimitEntry
36+
if (stored && stored.resetAt > now) {
37+
entry = stored
38+
} else {
39+
entry = { count: 0, resetAt: now + windowMs }
40+
}
41+
42+
entry.count++
43+
44+
// Calculate TTL in seconds (KV expiration)
45+
const ttlSeconds = Math.ceil((entry.resetAt - now) / 1000)
46+
47+
if (entry.count > max) {
48+
// Store the updated count even when rejecting
49+
await kv.put(key, JSON.stringify(entry), { expirationTtl: Math.max(ttlSeconds, 1) })
50+
51+
const retryAfter = Math.ceil((entry.resetAt - now) / 1000)
52+
c.header('Retry-After', String(retryAfter))
53+
c.header('X-RateLimit-Limit', String(max))
54+
c.header('X-RateLimit-Remaining', '0')
55+
c.header('X-RateLimit-Reset', String(Math.ceil(entry.resetAt / 1000)))
56+
return c.json({ error: 'Too many requests. Please try again later.' }, 429)
57+
}
58+
59+
await kv.put(key, JSON.stringify(entry), { expirationTtl: Math.max(ttlSeconds, 1) })
60+
61+
c.header('X-RateLimit-Limit', String(max))
62+
c.header('X-RateLimit-Remaining', String(max - entry.count))
63+
c.header('X-RateLimit-Reset', String(Math.ceil(entry.resetAt / 1000)))
64+
65+
return await next()
66+
} catch (error) {
67+
// Rate limiting should never break the app
68+
console.error('Rate limiter error (non-fatal):', error)
69+
return await next()
70+
}
71+
}
72+
}

packages/core/src/routes/auth.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Hono } from 'hono'
33
import { z } from 'zod'
44
import { setCookie } from 'hono/cookie'
55
import { html } from 'hono/html'
6-
import { AuthManager, requireAuth } from '../middleware'
6+
import { AuthManager, requireAuth, rateLimit } from '../middleware'
77
import { renderLoginPage, LoginPageData } from '../templates/pages/auth-login.template'
88
import { renderRegisterPage, RegisterPageData } from '../templates/pages/auth-register.template'
99
import { getCacheService, CACHE_CONFIGS } from '../services'
@@ -71,6 +71,7 @@ const loginSchema = z.object({
7171

7272
// Register new user
7373
authRoutes.post('/register',
74+
rateLimit({ max: 3, windowMs: 60 * 1000, keyPrefix: 'register' }),
7475
async (c) => {
7576
try {
7677
const db = c.env.DB
@@ -186,7 +187,9 @@ authRoutes.post('/register',
186187
)
187188

188189
// Login user
189-
authRoutes.post('/login', async (c) => {
190+
authRoutes.post('/login',
191+
rateLimit({ max: 5, windowMs: 60 * 1000, keyPrefix: 'login' }),
192+
async (c) => {
190193
try {
191194
const body = await c.req.json()
192195
const validation = loginSchema.safeParse(body)
@@ -341,7 +344,9 @@ authRoutes.post('/refresh', requireAuth(), async (c) => {
341344
})
342345

343346
// Form-based registration handler (for HTML forms)
344-
authRoutes.post('/register/form', async (c) => {
347+
authRoutes.post('/register/form',
348+
rateLimit({ max: 3, windowMs: 60 * 1000, keyPrefix: 'register' }),
349+
async (c) => {
345350
try {
346351
const db = c.env.DB
347352

@@ -470,7 +475,9 @@ authRoutes.post('/register/form', async (c) => {
470475
})
471476

472477
// Form-based login handler (for HTML forms)
473-
authRoutes.post('/login/form', async (c) => {
478+
authRoutes.post('/login/form',
479+
rateLimit({ max: 5, windowMs: 60 * 1000, keyPrefix: 'login' }),
480+
async (c) => {
474481
try {
475482
const formData = await c.req.formData()
476483
const email = formData.get('email') as string
@@ -561,7 +568,9 @@ authRoutes.post('/login/form', async (c) => {
561568
})
562569

563570
// Test seeding endpoint (only for development/testing)
564-
authRoutes.post('/seed-admin', async (c) => {
571+
authRoutes.post('/seed-admin',
572+
rateLimit({ max: 2, windowMs: 60 * 1000, keyPrefix: 'seed-admin' }),
573+
async (c) => {
565574
try {
566575
const db = c.env.DB
567576

@@ -907,7 +916,9 @@ authRoutes.post('/accept-invitation', async (c) => {
907916
})
908917

909918
// Request password reset
910-
authRoutes.post('/request-password-reset', async (c) => {
919+
authRoutes.post('/request-password-reset',
920+
rateLimit({ max: 3, windowMs: 15 * 60 * 1000, keyPrefix: 'password-reset' }),
921+
async (c) => {
911922
try {
912923
const formData = await c.req.formData()
913924
const email = formData.get('email')?.toString()?.trim()?.toLowerCase()

0 commit comments

Comments
 (0)