Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/core/src/middleware/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions packages/core/src/middleware/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
23 changes: 17 additions & 6 deletions packages/core/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down