feat: Rate limiting on all public endpoints (PRD §5.6)
In-memory rate limiter with configurable limits per scope: - auth: 5 req/min (login, passkey — future) - public: 20 req/min (setup mail test, public forms) - api: 100 req/min (public REST API v1) - license: 30 req/min (license validate/activate — future) - mcp: 60 req/min (MCP server) - webhook: 10 req/min (webhook test trigger) Applied to 7 endpoints: - /api/mcp (MCP JSON-RPC — 60/min) - /api/v1/companies (public REST — 100/min) - /api/v1/invoices (public REST — 100/min) - /api/v1/licenses (public REST — 100/min) - /api/v1/webhooks/test (webhook test — 10/min) - /api/aos/setup/test-mail (public form — 20/min) 429 response with Retry-After + X-RateLimit headers. Recreated /api/v1/webhooks/test/route.ts (lost in git merge).
This commit is contained in:
1 parent
2614ce4e3d
commit
d5dc2a1832
8 files changed
+161
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { checkRateLimit } from '@/lib/rate-limit'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import * as net from 'net'
|
||||
@@ -91,6 +92,8 @@ export async function POST(req: Request) {
|
||||
}
|
||||
const body = parsed.data
|
||||
|
||||
const rl = checkRateLimit(req, 'public')
|
||||
if (rl) return rl
|
||||
const [smtp, imap] = await Promise.all([
|
||||
testConnection(body.smtpHost ?? '', Number(body.smtpPort) || 0),
|
||||
testConnection(body.imapHost ?? '', Number(body.imapPort) || 0),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { verifyToken, hasScope } from '@/lib/api-auth'
|
||||
import { checkRateLimit } from '@/lib/rate-limit'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 30
|
||||
@@ -904,6 +905,10 @@ const TOOLS: McpTool[] = [
|
||||
|
||||
// JSON-RPC handler
|
||||
export async function POST(req: Request) {
|
||||
// Rate limit (PRD §5.6: rate limiting on license endpoints and public forms)
|
||||
const rateLimited = checkRateLimit(req, 'mcp')
|
||||
if (rateLimited) return rateLimited
|
||||
|
||||
const authHeader = req.headers.get('authorization')
|
||||
const auth = await verifyToken(authHeader)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { verifyToken, requireScope } from '@/lib/api-auth'
|
||||
import { checkRateLimit } from '@/lib/rate-limit'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -7,6 +8,9 @@ export const dynamic = 'force-dynamic'
|
||||
// Public API: list companies. Requires scope "crm.company:read".
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const rateLimited = checkRateLimit(req, 'api')
|
||||
if (rateLimited) return rateLimited
|
||||
|
||||
const auth = await verifyToken(req.headers.get('authorization'))
|
||||
const check = requireScope(auth, 'crm.company:read')
|
||||
if (!check.allowed) return check.error!
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { verifyToken, requireScope } from '@/lib/api-auth'
|
||||
import { checkRateLimit } from '@/lib/rate-limit'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -7,6 +8,8 @@ export const dynamic = 'force-dynamic'
|
||||
// Public API: list invoices. Requires scope "finance.invoice:read".
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const rl = checkRateLimit(req, 'api')
|
||||
if (rl) return rl
|
||||
const auth = await verifyToken(req.headers.get('authorization'))
|
||||
const check = requireScope(auth, 'finance.invoice:read')
|
||||
if (!check.allowed) return check.error!
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { verifyToken, requireScope } from '@/lib/api-auth'
|
||||
import { checkRateLimit } from '@/lib/rate-limit'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -7,6 +8,8 @@ export const dynamic = 'force-dynamic'
|
||||
// Public API: list issued licenses. Requires scope "licenses.license:read".
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const rl = checkRateLimit(req, 'api')
|
||||
if (rl) return rl
|
||||
const auth = await verifyToken(req.headers.get('authorization'))
|
||||
const check = requireScope(auth, 'licenses.license:read')
|
||||
if (!check.allowed) return check.error!
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Simple in-memory rate limiter (PRD §5.6: "Rate limiting on auth, license endpoints and public forms")
|
||||
// For production with multiple instances, use Redis instead.
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number
|
||||
resetAt: number
|
||||
}
|
||||
|
||||
const store = new Map<string, RateLimitEntry>()
|
||||
|
||||
// Check + increment rate limit
|
||||
// Returns { allowed: boolean, remaining: number, resetAt: number }
|
||||
export function rateLimit(
|
||||
key: string,
|
||||
maxRequests: number = 10,
|
||||
windowMs: number = 60_000
|
||||
): { allowed: boolean; remaining: number; resetAt: number } {
|
||||
const now = Date.now()
|
||||
const entry = store.get(key)
|
||||
|
||||
if (!entry || entry.resetAt < now) {
|
||||
// First request or window expired
|
||||
const resetAt = now + windowMs
|
||||
store.set(key, { count: 1, resetAt })
|
||||
return { allowed: true, remaining: maxRequests - 1, resetAt }
|
||||
}
|
||||
|
||||
if (entry.count >= maxRequests) {
|
||||
return { allowed: false, remaining: 0, resetAt: entry.resetAt }
|
||||
}
|
||||
|
||||
entry.count++
|
||||
return { allowed: true, remaining: maxRequests - entry.count, resetAt: entry.resetAt }
|
||||
}
|
||||
|
||||
// Get client identifier from request (IP + path for scoping)
|
||||
export function getRateLimitKey(req: Request, scope: string): string {
|
||||
const headers = new Headers(req.headers)
|
||||
const ip = headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||
?? headers.get('x-real-ip')
|
||||
?? 'unknown'
|
||||
return `${scope}:${ip}`
|
||||
}
|
||||
|
||||
// Rate limit configs per endpoint type (PRD §5.6)
|
||||
export const RATE_LIMITS = {
|
||||
auth: { max: 5, window: 60_000 }, // 5 attempts/minute (login, passkey)
|
||||
public: { max: 20, window: 60_000 }, // 20 requests/minute (public forms)
|
||||
api: { max: 100, window: 60_000 }, // 100 requests/minute (authenticated API)
|
||||
license: { max: 30, window: 60_000 }, // 30 requests/minute (license validate/activate)
|
||||
mcp: { max: 60, window: 60_000 }, // 60 requests/minute (MCP server)
|
||||
webhook: { max: 10, window: 60_000 }, // 10 triggers/minute (webhook test)
|
||||
} as const
|
||||
|
||||
// Helper: check rate limit and return 429 Response if exceeded
|
||||
export function checkRateLimit(req: Request, scope: keyof typeof RATE_LIMITS): Response | null {
|
||||
const config = RATE_LIMITS[scope]
|
||||
const key = getRateLimitKey(req, scope)
|
||||
const result = rateLimit(key, config.max, config.window)
|
||||
|
||||
if (!result.allowed) {
|
||||
const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'rate_limit_exceeded',
|
||||
message: `Too many requests. Try again in ${retryAfter} seconds.`,
|
||||
retryAfter,
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'retry-after': String(retryAfter),
|
||||
'x-rate-limit-limit': String(config.max),
|
||||
'x-rate-limit-remaining': '0',
|
||||
'x-rate-limit-reset': String(result.resetAt),
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return null // allowed
|
||||
}
|
||||
Reference in new issue
Block a user